bert_multi_task_model.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. """
  2. Chinese RoBERTa Multi-Task Model for Labor Arbitration Element Extraction.
  3. Architecture:
  4. Chinese RoBERTa (hfl/chinese-roberta-wwm-ext)
  5. ├── Token Classification Head → BIO NER (31 labels)
  6. ├── Sequence Classification Heads → case cause, contract type, etc.
  7. ├── Span QA Head → text span extraction
  8. └── Numeric Regression Head → amount prediction
  9. Joint loss: L = λ₁×L_NER + λ₂×L_CLS + λ₃×L_QA + λ₄×L_REG
  10. """
  11. from __future__ import annotations
  12. from typing import Any, Optional
  13. # NOTE: transformers must be imported BEFORE torch on Windows to avoid segfault
  14. from transformers import AutoConfig, AutoModel, AutoTokenizer
  15. from transformers.modeling_outputs import TokenClassifierOutput
  16. import torch
  17. import torch.nn as nn
  18. class ChineseRobertaMultiTask(nn.Module):
  19. """Multi-task model based on hfl/chinese-roberta-wwm-ext for
  20. joint NER, classification, span extraction, and numeric regression."""
  21. def __init__(
  22. self,
  23. model_name: str = "hfl/chinese-roberta-wwm-ext",
  24. num_ner_labels: int = 31,
  25. num_cause_classes: int = 6,
  26. num_contract_classes: int = 4,
  27. num_employment_classes: int = 5,
  28. num_bool_fields: int = 9,
  29. hidden_dropout_prob: float = 0.1,
  30. task_weights: Optional[list[float]] = None,
  31. ):
  32. super().__init__()
  33. # Shared encoder
  34. self.config = AutoConfig.from_pretrained(model_name)
  35. self.bert = AutoModel.from_pretrained(model_name)
  36. self.hidden_size = self.config.hidden_size
  37. self.dropout = nn.Dropout(hidden_dropout_prob)
  38. # ---- NER Head (token-level BIO classification) ----
  39. self.ner_head = nn.Linear(self.hidden_size, num_ner_labels)
  40. self.num_ner_labels = num_ner_labels
  41. # ---- Classification Head: case cause (6 classes) ----
  42. self.cause_classifier = nn.Linear(self.hidden_size, num_cause_classes)
  43. self.num_cause_classes = num_cause_classes
  44. # ---- Classification Head: contract type (4 classes) ----
  45. self.contract_classifier = nn.Linear(self.hidden_size, num_contract_classes)
  46. self.num_contract_classes = num_contract_classes
  47. # ---- Classification Head: employment type (5 classes) ----
  48. self.employment_classifier = nn.Linear(self.hidden_size, num_employment_classes)
  49. self.num_employment_classes = num_employment_classes
  50. # ---- Classification Head: binary/ternary fields (9 fields × 3 classes) ----
  51. self.bool_classifiers = nn.ModuleList([
  52. nn.Linear(self.hidden_size, 3) for _ in range(num_bool_fields)
  53. ])
  54. self.num_bool_fields = num_bool_fields
  55. # ---- Span QA Head (start/end logits) ----
  56. self.qa_head = nn.Linear(self.hidden_size, 2) # start, end
  57. # ---- Numeric Regression Head ----
  58. self.reg_head = nn.Sequential(
  59. nn.Linear(self.hidden_size, 128),
  60. nn.GELU(),
  61. nn.Dropout(0.1),
  62. nn.Linear(128, 64),
  63. nn.GELU(),
  64. nn.Dropout(0.1),
  65. nn.Linear(64, 1), # single scalar output
  66. )
  67. # Task weights for joint training
  68. self.task_weights = task_weights or [0.40, 0.25, 0.15, 0.10, 0.10] # ner, cause, contract, employment, qa
  69. # Initialize weights
  70. self._init_weights()
  71. def _init_weights(self):
  72. """Initialize classifier heads with normal distribution."""
  73. for module in [self.ner_head, self.cause_classifier, self.contract_classifier,
  74. self.employment_classifier, self.qa_head]:
  75. module.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
  76. if module.bias is not None:
  77. module.bias.data.zero_()
  78. for classifier in self.bool_classifiers:
  79. classifier.weight.data.normal_(mean=0.0, std=self.config.initializer_range)
  80. classifier.bias.data.zero_()
  81. def forward(
  82. self,
  83. input_ids: torch.Tensor,
  84. attention_mask: torch.Tensor,
  85. token_type_ids: Optional[torch.Tensor] = None,
  86. ner_labels: Optional[torch.Tensor] = None,
  87. cause_label: Optional[torch.Tensor] = None,
  88. contract_label: Optional[torch.Tensor] = None,
  89. employment_label: Optional[torch.Tensor] = None,
  90. bool_labels: Optional[torch.Tensor] = None, # shape: [batch, 9]
  91. qa_start_labels: Optional[torch.Tensor] = None,
  92. qa_end_labels: Optional[torch.Tensor] = None,
  93. reg_values: Optional[torch.Tensor] = None,
  94. reg_mask: Optional[torch.Tensor] = None, # 1 where reg target is valid
  95. ) -> dict[str, Any]:
  96. """
  97. Forward pass with optional multi-task loss computation.
  98. When labels are provided, returns (loss, logits_dict).
  99. When labels are None, returns logits_dict only.
  100. """
  101. # Shared encoder
  102. outputs = self.bert(
  103. input_ids=input_ids,
  104. attention_mask=attention_mask,
  105. token_type_ids=token_type_ids,
  106. )
  107. sequence_output = outputs.last_hidden_state # [batch, seq_len, hidden]
  108. pooled_output = outputs.pooler_output # [batch, hidden]
  109. loss = torch.tensor(0.0, device=input_ids.device, dtype=sequence_output.dtype)
  110. num_active_tasks = 0
  111. # ---- NER logits ----
  112. ner_logits = self.ner_head(self.dropout(sequence_output)) # [batch, seq, 31]
  113. if ner_labels is not None:
  114. loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
  115. ner_loss = loss_fct(
  116. ner_logits.view(-1, self.num_ner_labels),
  117. ner_labels.view(-1),
  118. )
  119. loss = loss + self.task_weights[0] * ner_loss
  120. num_active_tasks += 1
  121. # ---- Case cause classification ----
  122. cause_logits = self.cause_classifier(self.dropout(pooled_output)) # [batch, 6]
  123. if cause_label is not None:
  124. cause_loss = nn.CrossEntropyLoss()(cause_logits, cause_label)
  125. loss = loss + self.task_weights[1] * cause_loss
  126. num_active_tasks += 1
  127. # ---- Contract type classification ----
  128. contract_logits = self.contract_classifier(self.dropout(pooled_output))
  129. if contract_label is not None:
  130. contract_loss = nn.CrossEntropyLoss()(contract_logits, contract_label)
  131. loss = loss + self.task_weights[2] * contract_loss
  132. num_active_tasks += 1
  133. # ---- Employment type classification ----
  134. employment_logits = self.employment_classifier(self.dropout(pooled_output))
  135. if employment_label is not None:
  136. employment_loss = nn.CrossEntropyLoss()(employment_logits, employment_label)
  137. loss = loss + self.task_weights[3] * employment_loss
  138. num_active_tasks += 1
  139. # ---- Boolean field classification ----
  140. bool_logits_list = []
  141. bool_loss_total = torch.tensor(0.0, device=input_ids.device, dtype=sequence_output.dtype)
  142. for i, classifier in enumerate(self.bool_classifiers):
  143. logits = classifier(self.dropout(pooled_output)) # [batch, 3]
  144. bool_logits_list.append(logits)
  145. if bool_labels is not None and i < bool_labels.size(1):
  146. labels_i = bool_labels[:, i].long()
  147. mask_i = (labels_i >= 0)
  148. if mask_i.any():
  149. bool_loss_total = bool_loss_total + nn.CrossEntropyLoss()(
  150. logits[mask_i], labels_i[mask_i]
  151. ) / self.num_bool_fields
  152. if bool_labels is not None:
  153. loss = loss + self.task_weights[4] * bool_loss_total
  154. num_active_tasks += 1
  155. # ---- Span QA ----
  156. qa_logits = self.qa_head(self.dropout(sequence_output)) # [batch, seq, 2]
  157. start_logits, end_logits = qa_logits[..., 0], qa_logits[..., 1]
  158. if qa_start_labels is not None and qa_end_labels is not None:
  159. qa_loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
  160. start_loss = qa_loss_fct(start_logits, qa_start_labels)
  161. end_loss = qa_loss_fct(end_logits, qa_end_labels)
  162. qa_loss = (start_loss + end_loss) / 2.0
  163. loss = loss + 0.10 * qa_loss # relatively small weight for QA
  164. # ---- Numeric regression ----
  165. reg_preds = self.reg_head(self.dropout(pooled_output)).squeeze(-1) # [batch]
  166. if reg_values is not None:
  167. if reg_mask is not None:
  168. mask = reg_mask.bool()
  169. if mask.any():
  170. reg_loss = nn.MSELoss()(reg_preds[mask], reg_values[mask])
  171. loss = loss + 0.05 * reg_loss # small weight for regression
  172. else:
  173. reg_loss = nn.MSELoss()(reg_preds, reg_values)
  174. loss = loss + 0.05 * reg_loss
  175. return {
  176. "loss": loss if num_active_tasks > 0 else None,
  177. "ner_logits": ner_logits,
  178. "cause_logits": cause_logits,
  179. "contract_logits": contract_logits,
  180. "employment_logits": employment_logits,
  181. "bool_logits": bool_logits_list,
  182. "qa_start_logits": start_logits,
  183. "qa_end_logits": end_logits,
  184. "reg_preds": reg_preds,
  185. }
  186. @classmethod
  187. def from_pretrained(cls, model_path: str) -> "ChineseRobertaMultiTask":
  188. """Load a trained model from disk."""
  189. state_dict = torch.load(
  190. f"{model_path}/pytorch_model.bin",
  191. map_location="cpu",
  192. weights_only=True,
  193. )
  194. config = json.loads(
  195. open(f"{model_path}/model_config.json", encoding="utf-8").read()
  196. )
  197. model = cls(
  198. model_name=config.get("model_name", "hfl/chinese-roberta-wwm-ext"),
  199. num_ner_labels=config.get("num_ner_labels", 31),
  200. num_cause_classes=config.get("num_cause_classes", 6),
  201. num_contract_classes=config.get("num_contract_classes", 4),
  202. num_employment_classes=config.get("num_employment_classes", 5),
  203. num_bool_fields=config.get("num_bool_fields", 9),
  204. )
  205. model.load_state_dict(state_dict, strict=False)
  206. return model
  207. def save_pretrained(self, save_path: str):
  208. """Save model weights and config."""
  209. import os, json
  210. os.makedirs(save_path, exist_ok=True)
  211. torch.save(self.state_dict(), f"{save_path}/pytorch_model.bin")
  212. config = {
  213. "model_name": "hfl/chinese-roberta-wwm-ext",
  214. "num_ner_labels": self.num_ner_labels,
  215. "num_cause_classes": self.num_cause_classes,
  216. "num_contract_classes": self.num_contract_classes,
  217. "num_employment_classes": self.num_employment_classes,
  218. "num_bool_fields": self.num_bool_fields,
  219. }
  220. with open(f"{save_path}/model_config.json", "w", encoding="utf-8") as f:
  221. json.dump(config, f, ensure_ascii=False, indent=2)
  222. @torch.no_grad()
  223. def predict(
  224. self,
  225. input_ids: torch.Tensor,
  226. attention_mask: torch.Tensor,
  227. tokenizer: Optional[Any] = None,
  228. ) -> dict[str, Any]:
  229. """Inference mode: extract elements from text."""
  230. self.eval()
  231. outputs = self.forward(input_ids=input_ids, attention_mask=attention_mask)
  232. # NER spans
  233. ner_preds = torch.argmax(outputs["ner_logits"], dim=-1) # [batch, seq]
  234. # Cause prediction
  235. cause_pred = torch.argmax(outputs["cause_logits"], dim=-1) # [batch]
  236. # Contract prediction
  237. contract_pred = torch.argmax(outputs["contract_logits"], dim=-1)
  238. # Employment prediction
  239. employment_pred = torch.argmax(outputs["employment_logits"], dim=-1)
  240. # Boolean predictions
  241. bool_preds = torch.stack([
  242. torch.argmax(logits, dim=-1) for logits in outputs["bool_logits"]
  243. ], dim=-1) # [batch, 9]
  244. # Regression predictions
  245. reg_vals = outputs["reg_preds"] # [batch]
  246. return {
  247. "ner_predictions": ner_preds.cpu().tolist(),
  248. "cause_predictions": cause_pred.cpu().tolist(),
  249. "contract_predictions": contract_pred.cpu().tolist(),
  250. "employment_predictions": employment_pred.cpu().tolist(),
  251. "bool_predictions": bool_preds.cpu().tolist(),
  252. "reg_predictions": reg_vals.cpu().tolist(),
  253. }
  254. import json