train_bert.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. #!/usr/bin/env python3
  2. """
  3. Training script for Chinese RoBERTa Multi-Task Labor Arbitration Extractor.
  4. Usage:
  5. cd nlp-service/training
  6. PYTHONPATH="..;../../backend" python train_bert.py \
  7. --dataset ../../backend/data/augmented_dataset.json \
  8. --output ../models/chinese_roberta_labor_extractor \
  9. --epochs 10 --batch_size 8 --lr 2e-5
  10. """
  11. from __future__ import annotations
  12. import argparse
  13. import json
  14. import os
  15. import random
  16. import sys
  17. from pathlib import Path
  18. from typing import Any
  19. # NOTE: transformers before torch on Windows
  20. from transformers import (
  21. AutoTokenizer,
  22. get_linear_schedule_with_warmup,
  23. )
  24. import torch
  25. import torch.nn as nn
  26. from torch.utils.data import Dataset, DataLoader
  27. from tqdm import tqdm
  28. # Add parent dirs for imports
  29. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  30. from app.services.bert_multi_task_model import ChineseRobertaMultiTask
  31. from training.bio_schema import LABEL2ID, NUM_LABELS
  32. class LaborCaseDataset(Dataset):
  33. """PyTorch Dataset for labor arbitration case element extraction."""
  34. def __init__(
  35. self,
  36. cases: list[dict],
  37. tokenizer: AutoTokenizer,
  38. max_length: int = 512,
  39. for_training: bool = True,
  40. ):
  41. self.cases = cases
  42. self.tokenizer = tokenizer
  43. self.max_length = max_length
  44. self.for_training = for_training
  45. def __len__(self) -> int:
  46. return len(self.cases)
  47. def __getitem__(self, idx: int) -> dict[str, Any]:
  48. case = self.cases[idx]
  49. text = case["text"]
  50. cls_labels = case.get("classification_labels", {})
  51. num_labels = case.get("numeric_labels", {})
  52. # Tokenize
  53. encoding = self.tokenizer(
  54. text,
  55. truncation=True,
  56. padding="max_length",
  57. max_length=self.max_length,
  58. return_tensors="pt",
  59. return_offsets_mapping=True,
  60. )
  61. input_ids = encoding["input_ids"].squeeze(0) # [seq_len]
  62. attention_mask = encoding["attention_mask"].squeeze(0) # [seq_len]
  63. offset_mapping = encoding["offset_mapping"].squeeze(0) # [seq_len, 2]
  64. result = {
  65. "input_ids": input_ids,
  66. "attention_mask": attention_mask,
  67. "case_id": case.get("case_id", 0),
  68. }
  69. if self.for_training:
  70. # BIO labels: align character-level labels to subword tokens
  71. bio_labels = case.get("bio_labels", [])
  72. tokens = case.get("tokens", [])
  73. if bio_labels and tokens:
  74. token_labels = self._align_bio_to_tokens(
  75. bio_labels, tokens, encoding, offset_mapping
  76. )
  77. else:
  78. token_labels = [-100] * self.max_length
  79. result["ner_labels"] = torch.tensor(token_labels, dtype=torch.long)
  80. # Classification labels
  81. result["cause_label"] = torch.tensor(
  82. cls_labels.get("case_cause", 0), dtype=torch.long
  83. )
  84. result["contract_label"] = torch.tensor(
  85. cls_labels.get("contract_type", 3), dtype=torch.long
  86. )
  87. result["employment_label"] = torch.tensor(
  88. cls_labels.get("employment_type", 4), dtype=torch.long
  89. )
  90. # Boolean labels (9 fields)
  91. bool_field_keys = [
  92. "bool_lr1_contract_signed", "bool_lr1_open_ended_contract",
  93. "bool_lr1_double_wage_no_contract", "bool_lr1_si_joined",
  94. "bool_wi_si_joined", "bool_wi_recognize_ec",
  95. "bool_dm_contract_exists", "bool_dm_contract_continue",
  96. "bool_mi_contract_continue",
  97. ]
  98. bool_vals = [cls_labels.get(k, 2) for k in bool_field_keys]
  99. result["bool_labels"] = torch.tensor(bool_vals, dtype=torch.long)
  100. # Numeric labels: use first numeric field (month_salary) as primary target
  101. reg_val = num_labels.get("month_salary", None)
  102. result["reg_value"] = torch.tensor(
  103. float(reg_val) if reg_val is not None else -1.0,
  104. dtype=torch.float,
  105. )
  106. result["reg_mask"] = torch.tensor(
  107. 1.0 if reg_val is not None else 0.0, dtype=torch.float
  108. )
  109. return result
  110. def _align_bio_to_tokens(
  111. self,
  112. char_bio_labels: list[int],
  113. char_tokens: list[str],
  114. encoding: Any,
  115. offset_mapping: torch.Tensor,
  116. ) -> list[int]:
  117. """
  118. Align character-level BIO labels to subword token labels.
  119. For each subword token, find the corresponding character position
  120. and use the BIO label from that character.
  121. """
  122. # Build a mapping from char position → BIO label
  123. char_to_label: dict[int, int] = {}
  124. char_pos = 0
  125. for token, label in zip(char_tokens, char_bio_labels):
  126. for _ in token:
  127. char_to_label[char_pos] = label
  128. char_pos += 1
  129. # Map subword tokens to BIO labels based on their char offset
  130. token_labels = []
  131. for i, (start, end) in enumerate(offset_mapping.tolist()):
  132. if i >= self.max_length:
  133. break
  134. if start == 0 and end == 0:
  135. # Special tokens ([CLS], [SEP], [PAD])
  136. token_labels.append(-100)
  137. elif start >= char_pos:
  138. # Token is beyond our character annotations
  139. token_labels.append(-100)
  140. else:
  141. # Use label from the first character of this token
  142. label = char_to_label.get(start, 0) # 0 = O
  143. token_labels.append(label)
  144. # Pad to max_length
  145. while len(token_labels) < self.max_length:
  146. token_labels.append(-100)
  147. return token_labels[:self.max_length]
  148. def collate_batch(batch: list[dict]) -> dict[str, torch.Tensor]:
  149. """Custom collate function for multi-task training."""
  150. return {
  151. "input_ids": torch.stack([b["input_ids"] for b in batch]),
  152. "attention_mask": torch.stack([b["attention_mask"] for b in batch]),
  153. "ner_labels": torch.stack([b["ner_labels"] for b in batch]),
  154. "cause_label": torch.stack([b["cause_label"] for b in batch]),
  155. "contract_label": torch.stack([b["contract_label"] for b in batch]),
  156. "employment_label": torch.stack([b["employment_label"] for b in batch]),
  157. "bool_labels": torch.stack([b["bool_labels"] for b in batch]),
  158. "reg_values": torch.stack([b["reg_value"] for b in batch]),
  159. "reg_mask": torch.stack([b["reg_mask"] for b in batch]),
  160. }
  161. def train_epoch(
  162. model: ChineseRobertaMultiTask,
  163. dataloader: DataLoader,
  164. optimizer: torch.optim.Optimizer,
  165. scheduler: Any,
  166. device: torch.device,
  167. epoch: int,
  168. ) -> float:
  169. """Train one epoch. Returns average loss."""
  170. model.train()
  171. total_loss = 0.0
  172. num_batches = 0
  173. pbar = tqdm(dataloader, desc=f"Epoch {epoch}")
  174. for batch in pbar:
  175. batch = {k: v.to(device) for k, v in batch.items()}
  176. optimizer.zero_grad()
  177. outputs = model(**batch)
  178. loss = outputs["loss"]
  179. if loss is not None:
  180. loss.backward()
  181. torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
  182. optimizer.step()
  183. scheduler.step()
  184. total_loss += loss.item()
  185. num_batches += 1
  186. pbar.set_postfix({"loss": f"{loss.item():.4f}"})
  187. return total_loss / max(num_batches, 1)
  188. @torch.no_grad()
  189. def evaluate(
  190. model: ChineseRobertaMultiTask,
  191. dataloader: DataLoader,
  192. device: torch.device,
  193. ) -> dict[str, float]:
  194. """Evaluate on dev set. Returns per-task loss."""
  195. model.eval()
  196. total_loss = 0.0
  197. num_batches = 0
  198. cause_correct = 0
  199. cause_total = 0
  200. for batch in tqdm(dataloader, desc="Eval"):
  201. batch = {k: v.to(device) for k, v in batch.items()}
  202. outputs = model(**batch)
  203. loss = outputs["loss"]
  204. if loss is not None:
  205. total_loss += loss.item()
  206. num_batches += 1
  207. # Cause accuracy
  208. cause_preds = torch.argmax(outputs["cause_logits"], dim=-1)
  209. cause_correct += (cause_preds == batch["cause_label"]).sum().item()
  210. cause_total += batch["cause_label"].size(0)
  211. return {
  212. "loss": total_loss / max(num_batches, 1),
  213. "cause_accuracy": cause_correct / max(cause_total, 1),
  214. }
  215. def create_data_splits(dataset_path: str, train_ratio: float = 0.7, dev_ratio: float = 0.15):
  216. """Split dataset into train/dev/test."""
  217. with open(dataset_path, encoding="utf-8") as f:
  218. data = json.load(f)
  219. cases = data["dataset"]
  220. # Separate original vs augmented
  221. originals = [c for c in cases if "source" not in c]
  222. augmenteds = [c for c in cases if "source" in c]
  223. random.shuffle(originals)
  224. random.shuffle(augmenteds)
  225. n_orig = len(originals)
  226. n_train_orig = max(1, int(n_orig * train_ratio))
  227. n_dev_orig = max(1, int(n_orig * dev_ratio))
  228. train_cases = originals[:n_train_orig] + augmenteds
  229. dev_cases = originals[n_train_orig:n_train_orig + n_dev_orig]
  230. test_cases = originals[n_train_orig + n_dev_orig:]
  231. print(f"Split: train={len(train_cases)} ({n_train_orig} orig + {len(augmenteds)} aug), "
  232. f"dev={len(dev_cases)}, test={len(test_cases)}")
  233. return train_cases, dev_cases, test_cases
  234. def main():
  235. parser = argparse.ArgumentParser(description="Train BERT multi-task extractor")
  236. parser.add_argument("--dataset", required=True, help="Path to augmented_dataset.json")
  237. parser.add_argument("--output", required=True, help="Output directory for trained model")
  238. parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
  239. parser.add_argument("--batch_size", type=int, default=8, help="Training batch size")
  240. parser.add_argument("--lr", type=float, default=2e-5, help="Learning rate")
  241. parser.add_argument("--max_length", type=int, default=512, help="Max sequence length")
  242. parser.add_argument("--model_name", default="hfl/chinese-roberta-wwm-ext",
  243. help="Pretrained model name or path")
  244. parser.add_argument("--patience", type=int, default=3, help="Early stopping patience")
  245. parser.add_argument("--gradient_accumulation", type=int, default=2,
  246. help="Gradient accumulation steps")
  247. parser.add_argument("--warmup_ratio", type=float, default=0.1, help="Warmup ratio")
  248. parser.add_argument("--no_cuda", action="store_true", help="Disable CUDA")
  249. args = parser.parse_args()
  250. # Device
  251. device = torch.device("cuda" if torch.cuda.is_available() and not args.no_cuda else "cpu")
  252. print(f"Using device: {device}")
  253. # Load and split data
  254. train_cases, dev_cases, test_cases = create_data_splits(args.dataset)
  255. # Tokenizer
  256. tokenizer = AutoTokenizer.from_pretrained(args.model_name)
  257. print(f"Loaded tokenizer: {args.model_name}")
  258. # Datasets
  259. train_dataset = LaborCaseDataset(train_cases, tokenizer, args.max_length, for_training=True)
  260. dev_dataset = LaborCaseDataset(dev_cases, tokenizer, args.max_length, for_training=True)
  261. test_dataset = LaborCaseDataset(test_cases, tokenizer, args.max_length, for_training=True)
  262. train_loader = DataLoader(
  263. train_dataset, batch_size=args.batch_size, shuffle=True,
  264. collate_fn=collate_batch, num_workers=0,
  265. )
  266. dev_loader = DataLoader(
  267. dev_dataset, batch_size=args.batch_size, shuffle=False,
  268. collate_fn=collate_batch, num_workers=0,
  269. )
  270. # Model
  271. model = ChineseRobertaMultiTask(model_name=args.model_name)
  272. model.to(device)
  273. print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
  274. # Optimizer
  275. no_decay = ["bias", "LayerNorm.weight"]
  276. optimizer_grouped = [
  277. {
  278. "params": [p for n, p in model.named_parameters()
  279. if not any(nd in n for nd in no_decay)],
  280. "weight_decay": 0.01,
  281. },
  282. {
  283. "params": [p for n, p in model.named_parameters()
  284. if any(nd in n for nd in no_decay)],
  285. "weight_decay": 0.0,
  286. },
  287. ]
  288. optimizer = torch.optim.AdamW(optimizer_grouped, lr=args.lr)
  289. # Scheduler
  290. total_steps = len(train_loader) * args.epochs // args.gradient_accumulation
  291. warmup_steps = int(total_steps * args.warmup_ratio)
  292. scheduler = get_linear_schedule_with_warmup(
  293. optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps,
  294. )
  295. # Training loop
  296. best_dev_loss = float("inf")
  297. patience_counter = 0
  298. for epoch in range(1, args.epochs + 1):
  299. train_loss = train_epoch(model, train_loader, optimizer, scheduler, device, epoch)
  300. dev_metrics = evaluate(model, dev_loader, device)
  301. print(f"Epoch {epoch:>2}: train_loss={train_loss:.4f}, "
  302. f"dev_loss={dev_metrics['loss']:.4f}, "
  303. f"dev_cause_acc={dev_metrics['cause_accuracy']:.3f}")
  304. # Early stopping
  305. if dev_metrics["loss"] < best_dev_loss:
  306. best_dev_loss = dev_metrics["loss"]
  307. patience_counter = 0
  308. os.makedirs(args.output, exist_ok=True)
  309. model.save_pretrained(args.output)
  310. tokenizer.save_pretrained(args.output)
  311. print(f" -> Saved best model to {args.output}")
  312. else:
  313. patience_counter += 1
  314. if patience_counter >= args.patience:
  315. print(f"Early stopping at epoch {epoch}")
  316. break
  317. print(f"\nTraining complete. Best dev loss: {best_dev_loss:.4f}")
  318. print(f"Model saved to: {args.output}")
  319. if __name__ == "__main__":
  320. main()