Coverage for narrative_harm_classifier/classifier/validators/benchmark.py: 98%
107 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 13:25 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-20 13:25 +0000
1"""
2classifier/validators/benchmark.py — Templated functional-test benchmark.
4Generates a much larger, systematic test suite than the hand-picked
5DEHUMANIZATION_VALIDATION_SAMPLES set, modeled on the HateCheck methodology
6(https://arxiv.org/abs/2012.15606): concrete test cases are generated from
7templates tagged with a `test_type` (explicit positive, implicit positive,
8negation, counter-speech, obfuscated spelling, benign hard negatives), so a
9regression in one specific capability (e.g. negation handling) is visible
10even when the aggregate precision/recall looks fine.
12This benchmark is expected to expose real weaknesses in the current
13regex-based engine (negation, counter-speech, spelling obfuscation aren't
14handled) — that's the point: honest measurement rather than a vanity metric.
15"""
17import logging
18from datetime import datetime
19from functools import lru_cache
20from typing import Optional
22from pydantic import BaseModel
24from narrative_harm_classifier.core.models import ClassifyRequest
25from narrative_harm_classifier.core.yaml_loader import load_yaml_file
26from narrative_harm_classifier.classifier.rules.engine import ClassificationEngine
28logger = logging.getLogger(__name__)
31class BenchmarkCase(BaseModel):
32 case_id: str
33 harm_mechanism: str
34 category: str
35 test_type: str
36 text: str
37 expected_is_harmful: bool
38 group_used: Optional[str] = None
41class TestTypeReport(BaseModel):
42 test_type: str
43 sample_count: int
44 true_positives: int
45 false_positives: int
46 true_negatives: int
47 false_negatives: int
48 precision: float
49 recall: float
50 fpr: float
51 f1: float
54class GroupConsistencyEntry(BaseModel):
55 template_id: str
56 test_type: str
57 consistent: bool
58 verdicts: dict[str, bool] # group -> predicted is_harmful
61class BenchmarkReport(BaseModel):
62 taxonomy_version: str
63 sample_count: int
64 overall: TestTypeReport
65 by_test_type: list[TestTypeReport]
66 group_consistency: list[GroupConsistencyEntry]
67 generated_at: datetime
70@lru_cache(maxsize=4)
71def load_benchmark_templates(path: str) -> dict:
72 return load_yaml_file(path)
75def generate_benchmark_cases(templates_path: str) -> list[BenchmarkCase]:
76 """Expand templates x groups, plus standalone cases, into concrete BenchmarkCases."""
77 raw = load_benchmark_templates(templates_path)
78 groups = raw.get("groups", [])
79 cases: list[BenchmarkCase] = []
81 for template in raw.get("templates", []):
82 for group_entry in groups:
83 group = group_entry["group"]
84 text = template["pattern"].format(group=group)
85 cases.append(
86 BenchmarkCase(
87 case_id=f"{template['template_id']}::{group}",
88 harm_mechanism=template["harm_mechanism"],
89 category=template["category"],
90 test_type=template["test_type"],
91 text=text,
92 expected_is_harmful=template["expected_is_harmful"],
93 group_used=group,
94 )
95 )
97 for case in raw.get("standalone_cases", []):
98 cases.append(
99 BenchmarkCase(
100 case_id=case["case_id"],
101 harm_mechanism=case["harm_mechanism"],
102 category=case["category"],
103 test_type=case["test_type"],
104 text=case["text"],
105 expected_is_harmful=case["expected_is_harmful"],
106 group_used=None,
107 )
108 )
110 return cases
113def _score(tp: int, fp: int, tn: int, fn: int) -> tuple[float, float, float, float]:
114 precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
115 recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
116 fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0
117 f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0
118 return round(precision, 4), round(recall, 4), round(fpr, 4), round(f1, 4)
121class BenchmarkRunner:
122 def __init__(self, engine: ClassificationEngine, taxonomy_version: str, templates_path: str):
123 self.engine = engine
124 self.taxonomy_version = taxonomy_version
125 self.templates_path = templates_path
127 def run(self) -> BenchmarkReport:
128 cases = generate_benchmark_cases(self.templates_path)
130 # predictions[case_id] = (expected, predicted)
131 predictions: dict[str, tuple[bool, bool]] = {}
132 # template_id -> {group: predicted}
133 template_group_verdicts: dict[str, dict[str, bool]] = {}
134 template_test_types: dict[str, str] = {}
136 for case in cases:
137 result = self.engine.classify(ClassifyRequest(text=case.text))
138 predictions[case.case_id] = (case.expected_is_harmful, result.is_harmful)
140 if case.group_used is not None:
141 template_id = case.case_id.split("::", 1)[0]
142 template_group_verdicts.setdefault(template_id, {})[case.group_used] = result.is_harmful
143 template_test_types[template_id] = case.test_type
145 by_test_type_map: dict[str, list[BenchmarkCase]] = {}
146 for case in cases:
147 by_test_type_map.setdefault(case.test_type, []).append(case)
149 def confusion(case_list: list[BenchmarkCase]) -> tuple[int, int, int, int]:
150 tp = fp = tn = fn = 0
151 for c in case_list:
152 expected, predicted = predictions[c.case_id]
153 if expected and predicted:
154 tp += 1
155 elif not expected and predicted:
156 fp += 1
157 elif not expected and not predicted:
158 tn += 1
159 else:
160 fn += 1
161 return tp, fp, tn, fn
163 overall_tp, overall_fp, overall_tn, overall_fn = confusion(cases)
164 overall_p, overall_r, overall_fpr, overall_f1 = _score(overall_tp, overall_fp, overall_tn, overall_fn)
165 overall = TestTypeReport(
166 test_type="overall",
167 sample_count=len(cases),
168 true_positives=overall_tp,
169 false_positives=overall_fp,
170 true_negatives=overall_tn,
171 false_negatives=overall_fn,
172 precision=overall_p,
173 recall=overall_r,
174 fpr=overall_fpr,
175 f1=overall_f1,
176 )
178 by_test_type = []
179 for test_type, case_list in sorted(by_test_type_map.items()):
180 tp, fp, tn, fn = confusion(case_list)
181 p, r, fpr, f1 = _score(tp, fp, tn, fn)
182 by_test_type.append(
183 TestTypeReport(
184 test_type=test_type,
185 sample_count=len(case_list),
186 true_positives=tp,
187 false_positives=fp,
188 true_negatives=tn,
189 false_negatives=fn,
190 precision=p,
191 recall=r,
192 fpr=fpr,
193 f1=f1,
194 )
195 )
197 group_consistency = []
198 for template_id, verdicts in sorted(template_group_verdicts.items()):
199 consistent = len(set(verdicts.values())) == 1
200 group_consistency.append(
201 GroupConsistencyEntry(
202 template_id=template_id,
203 test_type=template_test_types[template_id],
204 consistent=consistent,
205 verdicts=verdicts,
206 )
207 )
209 logger.info(
210 f"Benchmark v{self.taxonomy_version}: {len(cases)} cases, "
211 f"overall P={overall_p:.3f} R={overall_r:.3f} FPR={overall_fpr:.3f}"
212 )
214 return BenchmarkReport(
215 taxonomy_version=self.taxonomy_version,
216 sample_count=len(cases),
217 overall=overall,
218 by_test_type=by_test_type,
219 group_consistency=group_consistency,
220 generated_at=datetime.utcnow(),
221 )