evaluate.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. #!/usr/bin/env python3
  2. """
  3. Unified evaluation framework for labor arbitration element extraction.
  4. Compares: rules, BERT model, Ollama zero-shot, hybrid.
  5. Produces thesis-ready metrics tables and per-field breakdowns.
  6. Usage:
  7. cd nlp-service/training
  8. PYTHONPATH="..;../../backend" python evaluate.py \
  9. --dataset ../../backend/data/augmented_dataset.json \
  10. --output ../../backend/data/eval_results.json
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import json
  15. import sys
  16. from collections import defaultdict
  17. from pathlib import Path
  18. from typing import Any
  19. BACKEND = Path(__file__).resolve().parent.parent.parent / "backend"
  20. sys.path.insert(0, str(BACKEND))
  21. from sklearn.metrics import (
  22. classification_report,
  23. confusion_matrix,
  24. mean_absolute_error,
  25. mean_squared_error,
  26. )
  27. def _as_set(value: Any) -> set:
  28. """Convert a value to a set for comparison."""
  29. if value is None:
  30. return set()
  31. if isinstance(value, list):
  32. return set(str(v).strip() for v in value if v)
  33. if isinstance(value, dict):
  34. # For claims dict, extract items and amount_total
  35. items = set()
  36. if "items" in value:
  37. items.update(str(v).strip() for v in value["items"] if v)
  38. if "amount_total" in value and value["amount_total"] is not None:
  39. items.add(str(value["amount_total"]))
  40. return items
  41. s = str(value).strip()
  42. return {s} if s else set()
  43. def _as_float(value: Any) -> float | None:
  44. """Convert a value to float for numeric comparison."""
  45. if value is None:
  46. return None
  47. try:
  48. return float(value)
  49. except (ValueError, TypeError):
  50. return None
  51. def compute_field_f1(gold: Any, pred: Any) -> dict[str, float]:
  52. """Compute P/R/F1 for a single field using set comparison."""
  53. gold_set = _as_set(gold)
  54. pred_set = _as_set(pred)
  55. tp = len(gold_set & pred_set)
  56. fp = len(pred_set - gold_set)
  57. fn = len(gold_set - pred_set)
  58. precision = tp / max(tp + fp, 1)
  59. recall = tp / max(tp + fn, 1)
  60. f1 = 2 * precision * recall / max(precision + recall, 1e-8)
  61. return {"precision": precision, "recall": recall, "f1": f1, "tp": tp, "fp": fp, "fn": fn}
  62. def evaluate_all_fields(
  63. golds: list[dict],
  64. preds: list[dict],
  65. field_names: list[str],
  66. ) -> dict[str, Any]:
  67. """Evaluate all fields across all cases. Returns per-field and micro-average metrics."""
  68. per_field: dict[str, dict] = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0})
  69. for gold, pred in zip(golds, preds):
  70. for field in field_names:
  71. g = gold.get(field)
  72. p = pred.get(field)
  73. scores = compute_field_f1(g, p)
  74. for k in ("tp", "fp", "fn"):
  75. per_field[field][k] += scores[k]
  76. # Compute per-field metrics
  77. results = {}
  78. total_tp = total_fp = total_fn = 0
  79. for field, counts in per_field.items():
  80. p = counts["tp"] / max(counts["tp"] + counts["fp"], 1)
  81. r = counts["tp"] / max(counts["tp"] + counts["fn"], 1)
  82. f1 = 2 * p * r / max(p + r, 1e-8)
  83. results[field] = {"precision": round(p, 4), "recall": round(r, 4), "f1": round(f1, 4)}
  84. total_tp += counts["tp"]
  85. total_fp += counts["fp"]
  86. total_fn += counts["fn"]
  87. # Micro average
  88. micro_p = total_tp / max(total_tp + total_fp, 1)
  89. micro_r = total_tp / max(total_tp + total_fn, 1)
  90. micro_f1 = 2 * micro_p * micro_r / max(micro_p + micro_r, 1e-8)
  91. return {
  92. "per_field": results,
  93. "micro_avg": {
  94. "precision": round(micro_p, 4),
  95. "recall": round(micro_r, 4),
  96. "f1": round(micro_f1, 4),
  97. },
  98. }
  99. def evaluate_numeric_fields(
  100. golds: list[dict],
  101. preds: list[dict],
  102. num_field_names: list[str],
  103. ) -> dict[str, dict[str, float]]:
  104. """Evaluate numeric fields with MAE and RMSE."""
  105. results = {}
  106. for field in num_field_names:
  107. y_true = []
  108. y_pred = []
  109. for gold, pred in zip(golds, preds):
  110. g = _as_float(gold.get(field))
  111. p = _as_float(pred.get(field))
  112. if g is not None and p is not None:
  113. y_true.append(g)
  114. y_pred.append(p)
  115. if y_true:
  116. results[field] = {
  117. "mae": round(mean_absolute_error(y_true, y_pred), 2),
  118. "rmse": round(mean_squared_error(y_true, y_pred) ** 0.5, 2),
  119. "count": len(y_true),
  120. }
  121. return results
  122. def evaluate_cause_accuracy(
  123. golds: list[dict],
  124. preds: list[dict],
  125. ) -> dict[str, Any]:
  126. """Evaluate case cause classification accuracy."""
  127. cause_keys = ["tmpl_primary_cause", "primary_cause_type", "case_cause"]
  128. y_true_strs = []
  129. y_pred_strs = []
  130. for gold, pred in zip(golds, preds):
  131. g = None
  132. p = None
  133. for k in cause_keys:
  134. g = g or gold.get(k)
  135. p = p or pred.get(k)
  136. if isinstance(g, dict):
  137. g = g.get("type")
  138. if isinstance(p, dict):
  139. p = p.get("type")
  140. y_true_strs.append(str(g) if g else "未知")
  141. y_pred_strs.append(str(p) if p else "未知")
  142. labels = sorted(set(y_true_strs + y_pred_strs))
  143. accuracy = sum(1 for t, p in zip(y_true_strs, y_pred_strs) if t == p) / max(len(y_true_strs), 1)
  144. return {
  145. "accuracy": round(accuracy, 4),
  146. "labels": labels,
  147. "per_label": classification_report(
  148. y_true_strs, y_pred_strs, labels=labels,
  149. output_dict=True, zero_division=0,
  150. ),
  151. }
  152. def run_full_evaluation(
  153. test_cases: list[dict],
  154. extraction_methods: dict[str, callable],
  155. field_names: list[str],
  156. numeric_field_names: list[str] | None = None,
  157. ) -> dict[str, Any]:
  158. """
  159. Run full evaluation comparing all extraction methods.
  160. Args:
  161. test_cases: List of dicts with 'text' and 'rule_elements' (gold labels)
  162. extraction_methods: Dict of method_name -> extractor callable
  163. field_names: Field names to evaluate
  164. numeric_field_names: Subset of fields that are numeric
  165. """
  166. if numeric_field_names is None:
  167. numeric_field_names = ["month_salary"]
  168. results = {}
  169. for method_name, extract_fn in extraction_methods.items():
  170. print(f"\n{'='*60}")
  171. print(f"Evaluating: {method_name}")
  172. print(f"{'='*60}")
  173. preds = []
  174. for case in test_cases:
  175. try:
  176. pred = extract_fn(case["text"])
  177. preds.append(pred)
  178. except Exception as e:
  179. print(f" ERROR on case {case.get('case_id', '?')}: {e}")
  180. preds.append({})
  181. golds = [case.get("rule_elements", {}) for case in test_cases]
  182. # Field-level F1
  183. field_results = evaluate_all_fields(golds, preds, field_names)
  184. print(f" Micro-F1: {field_results['micro_avg']['f1']:.4f}")
  185. # Numeric evaluation
  186. num_results = evaluate_numeric_fields(golds, preds, numeric_field_names)
  187. if num_results:
  188. print(f" Numeric fields evaluated: {list(num_results.keys())}")
  189. # Cause accuracy
  190. cause_results = evaluate_cause_accuracy(golds, preds)
  191. print(f" Cause accuracy: {cause_results['accuracy']:.4f}")
  192. results[method_name] = {
  193. "field_metrics": field_results,
  194. "numeric_metrics": num_results,
  195. "cause_accuracy": cause_results,
  196. }
  197. # Summary comparison table
  198. summary = {}
  199. for method, res in results.items():
  200. summary[method] = {
  201. "micro_f1": res["field_metrics"]["micro_avg"]["f1"],
  202. "micro_precision": res["field_metrics"]["micro_avg"]["precision"],
  203. "micro_recall": res["field_metrics"]["micro_avg"]["recall"],
  204. "cause_accuracy": res["cause_accuracy"]["accuracy"],
  205. }
  206. # Average MAE for numeric fields
  207. maes = [v["mae"] for v in res["numeric_metrics"].values() if "mae" in v]
  208. if maes:
  209. summary[method]["avg_mae"] = round(sum(maes) / len(maes), 2)
  210. results["_summary"] = summary
  211. print(f"\n{'='*60}")
  212. print("SUMMARY COMPARISON TABLE")
  213. print(f"{'='*60}")
  214. print(f"{'Method':<20} {'Micro-F1':>10} {'Precision':>10} {'Recall':>10} {'Cause Acc':>10} {'Avg MAE':>10}")
  215. print("-" * 65)
  216. for method, s in summary.items():
  217. print(f"{method:<20} {s['micro_f1']:>10.4f} {s['micro_precision']:>10.4f} "
  218. f"{s['micro_recall']:>10.4f} {s['cause_accuracy']:>10.4f} "
  219. f"{s.get('avg_mae', 0):>10.2f}")
  220. return results
  221. # Default key field names to evaluate
  222. DEFAULT_FIELDS = [
  223. "applicant_name", "respondent_name", "employer_nature",
  224. "worker_position", "entry_date", "leave_date",
  225. "month_salary", "case_cause", "contract_type",
  226. "termination_reason", "employment_type",
  227. "overtime_desc", "work_duration_text",
  228. "law_refs", "evidence_materials", "claims",
  229. ]
  230. DEFAULT_NUMERIC_FIELDS = [
  231. "month_salary", "lr1_pay_amount", "sr_claim_amount",
  232. "ec_claim_amount", "dm_claim_amount",
  233. ]
  234. def load_test_cases(dataset_path: str) -> list[dict]:
  235. """Load test cases from dataset (only original, non-augmented cases)."""
  236. with open(dataset_path, encoding="utf-8") as f:
  237. data = json.load(f)
  238. return [c for c in data["dataset"] if "source" not in c]
  239. def main():
  240. parser = argparse.ArgumentParser(description="Evaluate extraction methods")
  241. parser.add_argument("--dataset", required=True, help="Path to dataset JSON")
  242. parser.add_argument("--output", default="eval_results.json", help="Output path for results")
  243. parser.add_argument("--methods", default="rules", help="Comma-separated: rules,bert,ollama,hybrid")
  244. args = parser.parse_args()
  245. # Load test cases (originals only)
  246. test_cases = load_test_cases(args.dataset)
  247. print(f"Loaded {len(test_cases)} test cases")
  248. # Build extraction methods
  249. methods: dict[str, callable] = {}
  250. method_names = [m.strip() for m in args.methods.split(",")]
  251. if "rules" in method_names:
  252. from app.extractor import RuleBasedLaborExtractor
  253. rule_ext = RuleBasedLaborExtractor()
  254. methods["rules"] = rule_ext.extract
  255. if "bert" in method_names or "hybrid" in method_names:
  256. import requests
  257. def bert_extract(text: str) -> dict:
  258. try:
  259. resp = requests.post(
  260. "http://localhost:8001/extract",
  261. json={"text": text},
  262. timeout=60,
  263. )
  264. if resp.status_code == 200:
  265. return resp.json()
  266. except Exception:
  267. pass
  268. # Fallback to rules
  269. from app.extractor import RuleBasedLaborExtractor
  270. return RuleBasedLaborExtractor().extract(text)
  271. methods["bert"] = bert_extract
  272. if "ollama" in method_names or "hybrid" in method_names:
  273. try:
  274. from app.anj import OllamaClaimsExtractor
  275. from app.config import settings
  276. ollama_ext = OllamaClaimsExtractor(
  277. settings.ollama_base_url,
  278. settings.ollama_model_name,
  279. )
  280. from app.extractor import RuleBasedLaborExtractor
  281. rule = RuleBasedLaborExtractor()
  282. def ollama_extract(text: str) -> dict:
  283. base = rule.extract(text)
  284. try:
  285. base["claims"] = ollama_ext.extract_claims(text)
  286. template = ollama_ext.extract_dispute_template_fields(text)
  287. for k, v in template.items():
  288. if v is not None and v != "":
  289. base[k] = v
  290. except Exception:
  291. pass
  292. return base
  293. methods["ollama"] = ollama_extract
  294. except Exception as e:
  295. print(f" [SKIP] Ollama not available: {e}")
  296. # Run evaluation
  297. results = run_full_evaluation(
  298. test_cases,
  299. methods,
  300. DEFAULT_FIELDS,
  301. DEFAULT_NUMERIC_FIELDS,
  302. )
  303. # Save results
  304. output_path = Path(args.output)
  305. if not output_path.is_absolute():
  306. output_path = Path(__file__).resolve().parent / args.output
  307. output_path.parent.mkdir(parents=True, exist_ok=True)
  308. output_path.write_text(
  309. json.dumps(results, ensure_ascii=False, indent=2, default=str),
  310. encoding="utf-8",
  311. )
  312. print(f"\nResults saved to: {output_path}")
  313. if __name__ == "__main__":
  314. main()