augment_data.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. """
  2. Data augmentation for labor arbitration training data.
  3. Strategies:
  4. 1. Synonym replacement (legal domain vocabulary)
  5. 2. Entity masking and variation
  6. 3. Qwen synthetic case generation via Ollama
  7. """
  8. from __future__ import annotations
  9. import json
  10. import random
  11. import re
  12. import sys
  13. from pathlib import Path
  14. from typing import Any
  15. from copy import deepcopy
  16. BACKEND = Path(__file__).resolve().parent.parent.parent / "backend"
  17. sys.path.insert(0, str(BACKEND))
  18. TRAINING_DATASET = BACKEND / "data" / "training_dataset.json"
  19. OUTPUT = BACKEND / "data" / "augmented_dataset.json"
  20. # Legal domain synonym groups
  21. SYNONYM_MAP = {
  22. "工资": ["薪资", "劳动报酬", "薪酬", "工钱"],
  23. "加班": ["超时工作", "延时工作", "延长工作时间"],
  24. "辞退": ["解雇", "开除", "解除劳动合同"],
  25. "经济补偿": ["经济赔偿金", "补偿款", "经济补助"],
  26. "仲裁": ["劳动仲裁", "争议仲裁", "调解仲裁"],
  27. "申请人": ["申请方", "申请人方", "申诉人"],
  28. "被申请人": ["被申请方", "被诉人", "答辩方"],
  29. "劳动合同": ["劳动协议", "用工合同", "聘用合同"],
  30. "拖欠": ["欠付", "未支付", "拖欠支付", "未付"],
  31. "社保": ["社会保险", "五险", "社会保险费"],
  32. "工伤": ["因工受伤", "工作伤害", "职业伤害"],
  33. "劳动者": ["员工", "职工", "工人", "雇员"],
  34. "用人单位": ["雇主", "公司", "企业", "用工单位"],
  35. "月薪": ["月工资", "月收入", "月均工资"],
  36. "入职": ["到岗", "入职工", "开始工作", "参加工作"],
  37. "离职": ["离开公司", "辞职", "解除劳动关系"],
  38. }
  39. # Company name templates
  40. COMPANY_TEMPLATES = [
  41. "XX市{name}有限公司",
  42. "XX省{name}{industry}有限公司",
  43. "{name}科技(XX)有限公司",
  44. "XX市{name}{industry}厂",
  45. "XX新区{name}实业有限公司",
  46. "{name}(中国)有限公司XX分公司",
  47. ]
  48. # Person name pool
  49. SURNAMES = ["张", "李", "王", "刘", "陈", "杨", "赵", "黄", "周", "吴", "徐", "孙", "马", "朱", "胡"]
  50. GIVEN_NAMES = ["明", "华", "强", "伟", "芳", "丽", "敏", "静", "勇", "军", "磊", "洋", "涛", "鑫", "宇"]
  51. # Case cause templates for Qwen generation
  52. CAUSE_PROMPT_TEMPLATES = {
  53. "劳动关系纠纷类": "确认劳动关系的存在,涉及未签订书面劳动合同,请求二倍工资差额",
  54. "工伤保险待遇纠纷": "在工作中受伤,涉及工伤认定、劳动能力鉴定及一次性伤残补助金等工伤保险待遇",
  55. "追索劳动报酬": "用人单位拖欠工资、加班费、高温津贴等劳动报酬,要求支付拖欠款项",
  56. "经济补偿金纠纷": "因用人单位未依法缴纳社保或未及时足额支付工资,劳动者提出解除劳动合同并要求经济补偿金",
  57. "赔偿金纠纷": "用人单位违法解除或终止劳动合同,要求支付违法解除劳动合同赔偿金(2N)",
  58. "生育保险待遇纠纷": "女职工因生育未能享受生育津贴和生育医疗费用,要求用人单位支付生育保险待遇",
  59. }
  60. def synonym_replace(text: str, probability: float = 0.3) -> str:
  61. """Randomly replace legal terms with synonyms."""
  62. result = list(text)
  63. for match in re.finditer(r"[一-鿿]{2,6}", text):
  64. word = match.group(0)
  65. if word in SYNONYM_MAP and random.random() < probability:
  66. replacement = random.choice(SYNONYM_MAP[word])
  67. result[match.start():match.end()] = list(replacement)
  68. return "".join(result)
  69. def mask_entity_variations(text: str) -> list[str]:
  70. """
  71. Generate 2 entity-masked variants of the same text.
  72. Replaces names/dates/amounts with different but realistic values.
  73. """
  74. variants = []
  75. for _ in range(2):
  76. variant = text
  77. # Replace company names
  78. variant = re.sub(
  79. r"XX[市省]?\S{0,8}(?:有限|责任|实业|科技|贸易|工程|装饰)?(?:公司|厂|企业|集团)",
  80. lambda m: random.choice(COMPANY_TEMPLATES).format(
  81. name=random.choice(["恒达", "鑫源", "瑞丰", "永盛", "华泰", "新锐", "博远"]),
  82. industry=random.choice(["电子", "机械", "纺织", "餐饮", "物流", "建筑", "软件"]),
  83. ),
  84. variant,
  85. )
  86. # Replace XX placeholders with realistic names
  87. while "XX" in variant:
  88. name = random.choice(SURNAMES) + random.choice(GIVEN_NAMES)
  89. variant = variant.replace("XX", name, 1)
  90. # Replace dates within reasonable range
  91. def date_shifter(m: re.Match) -> str:
  92. year = int(m.group(1))
  93. month = int(m.group(2))
  94. day = int(m.group(3))
  95. year += random.choice([-1, 0, 1])
  96. month = max(1, min(12, month + random.choice([-1, 0, 1])))
  97. day = max(1, min(28, day + random.choice([-2, -1, 0, 1, 2])))
  98. return f"{year}年{month}月{day}日"
  99. variant = re.sub(
  100. r"([12][0-9]{3})[年./-]([01]?[0-9])[月./-]([0-3]?[0-9])[日]?",
  101. date_shifter,
  102. variant,
  103. )
  104. variants.append(variant)
  105. return variants
  106. def generate_synthetic_cases_with_qwen(
  107. num_per_cause: int = 5,
  108. ollama_host: str = "http://localhost:11434",
  109. ollama_model: str = "qwen2.5:3b",
  110. ) -> list[dict]:
  111. """
  112. Use Ollama Qwen to generate synthetic labor arbitration case texts.
  113. Generates num_per_cause cases for each of the 6 cause types.
  114. """
  115. try:
  116. import ollama
  117. except ImportError:
  118. print(" [SKIP] ollama package not installed, skipping synthetic generation")
  119. return []
  120. client = ollama.Client(host=ollama_host)
  121. synthetic_cases = []
  122. for cause_type, description in CAUSE_PROMPT_TEMPLATES.items():
  123. for i in range(num_per_cause):
  124. prompt = f"""请生成一个虚构的中国劳动仲裁案件申请书文本,要求如下:
  125. 案由类型:{cause_type}
  126. 案件特征:{description}
  127. 请按以下格式输出完整的仲裁申请书:
  128. 申请人:[虚构姓名],[性别],[出生年份]年出生,住址[虚构地址]。
  129. 被申请人:[虚构公司名称],住所[虚构地址]。
  130. 请求事项:
  131. 1、[仲裁请求1]
  132. 2、[仲裁请求2]
  133. 3、[仲裁请求3,可选]
  134. 事实与理由:
  135. [包含入职时间、工作岗位、工资标准、争议发生经过的事实描述,不少于200字]
  136. 注意:所有信息必须虚构,不能出现真实人名地名。金额使用合理范围(月薪3000-15000元,总请求金额在5000-500000元之间)。日期使用2018-2024年之间的日期。"""
  137. try:
  138. response = client.chat(
  139. model=ollama_model,
  140. messages=[
  141. {"role": "system", "content": "你是一个法律文书生成助手,只输出仲裁申请书正文,不输出其他内容。"},
  142. {"role": "user", "content": prompt},
  143. ],
  144. options={"temperature": 0.8, "num_predict": 2048},
  145. )
  146. generated_text = response["message"]["content"].strip()
  147. # Remove markdown code fences if present
  148. generated_text = re.sub(r"^```[^\n]*\n?", "", generated_text)
  149. generated_text = re.sub(r"\n```$", "", generated_text)
  150. if len(generated_text) >= 100:
  151. synthetic_cases.append({
  152. "text": generated_text,
  153. "cause_type": cause_type,
  154. "source": "qwen_synthetic",
  155. })
  156. print(f" Generated: {cause_type} #{i+1} ({len(generated_text)} chars)")
  157. except Exception as e:
  158. print(f" [ERROR] Failed to generate {cause_type} #{i+1}: {e}")
  159. continue
  160. print(f" Total synthetic cases generated: {len(synthetic_cases)}")
  161. return synthetic_cases
  162. def augment_dataset(
  163. include_synthetic: bool = True,
  164. synthetic_per_cause: int = 5,
  165. ) -> dict:
  166. """Main augmentation function."""
  167. if not TRAINING_DATASET.exists():
  168. sys.exit(f"Training dataset not found: {TRAINING_DATASET}")
  169. data = json.loads(TRAINING_DATASET.read_text(encoding="utf-8"))
  170. dataset = data["dataset"]
  171. augmented = []
  172. orig_ids: set[int] = set()
  173. for case in dataset:
  174. orig_ids.add(case["case_id"])
  175. augmented.append(case) # Keep original
  176. text = case["text"]
  177. # Strategy 1: Synonym replacement (3x per case)
  178. for _ in range(3):
  179. variant_text = synonym_replace(text, probability=0.25)
  180. if variant_text != text and len(variant_text) >= 100:
  181. augmented.append({
  182. **{k: v for k, v in case.items() if k not in ("text", "tokens", "bio_labels", "bio_label_names", "case_id")},
  183. "case_id": case["case_id"] * 1000 + len(augmented),
  184. "text": variant_text,
  185. "tokens": [], # will be re-tokenized during training
  186. "bio_labels": [],
  187. "bio_label_names": [],
  188. "source": "synonym_aug",
  189. "orig_case_id": case["case_id"],
  190. })
  191. # Strategy 2: Entity-masked variations (2x per case)
  192. for variant_text in mask_entity_variations(text):
  193. if variant_text != text and len(variant_text) >= 100:
  194. augmented.append({
  195. **{k: v for k, v in case.items() if k not in ("text", "tokens", "bio_labels", "bio_label_names", "case_id")},
  196. "case_id": case["case_id"] * 1000 + len(augmented),
  197. "text": variant_text,
  198. "tokens": [],
  199. "bio_labels": [],
  200. "bio_label_names": [],
  201. "source": "entity_mask_aug",
  202. "orig_case_id": case["case_id"],
  203. })
  204. print(f" Original: {len(orig_ids)} cases")
  205. print(f" After augmentation: {len(augmented)} cases")
  206. # Strategy 3: Qwen synthetic cases
  207. if include_synthetic:
  208. synthetic = generate_synthetic_cases_with_qwen(
  209. num_per_cause=synthetic_per_cause,
  210. )
  211. for i, syn in enumerate(synthetic):
  212. max_id = max(orig_ids) if orig_ids else 0
  213. synthetic_id = (max_id + 1) * 1000 + i
  214. augmented.append({
  215. "case_id": synthetic_id,
  216. "file": f"synthetic_{i}.txt",
  217. "text": syn["text"],
  218. "tokens": [],
  219. "bio_labels": [],
  220. "bio_label_names": [],
  221. "classification_labels": {
  222. "case_cause": [
  223. "劳动关系纠纷类", "工伤保险待遇纠纷",
  224. "追索劳动报酬", "经济补偿金纠纷",
  225. "赔偿金纠纷", "生育保险待遇纠纷",
  226. ].index(syn["cause_type"]),
  227. },
  228. "numeric_labels": {},
  229. "rule_elements": {},
  230. "source": "qwen_synthetic",
  231. "cause_type": syn["cause_type"],
  232. })
  233. # Save augmented dataset
  234. output = {
  235. **{k: v for k, v in data.items() if k != "dataset"},
  236. "augmented": True,
  237. "total_augmented_cases": len(augmented),
  238. "dataset": augmented,
  239. }
  240. OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
  241. print(f"\nAugmented dataset saved: {OUTPUT}")
  242. print(f"Total: {len(augmented)} cases")
  243. return output
  244. if __name__ == "__main__":
  245. augment_dataset(include_synthetic=True, synthetic_per_cause=5)