-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmetrics.py
More file actions
304 lines (259 loc) · 13 KB
/
Copy pathmetrics.py
File metadata and controls
304 lines (259 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
"""
metrics.py – All paper metrics computed from real data
=========================================================
Implements:
1. LLM-as-Judge → hallucination detection, accuracy, faithfulness
2. McNemar's test → statistical significance of pairwise strategy comparison
3. Cohen's kappa → inter-rater agreement (human vs auto)
4. FactScore → simplified atomic claim verification
5. Error taxonomy → classify residual hallucinations into 4 categories
6. Aggregate summary tables (paper Tables II–VII)
"""
import json, re, math
from collections import defaultdict
from src.caller import call_model
# ─────────────────────────────────────────────────────────────────────────────
# 1. LLM-as-Judge
# ─────────────────────────────────────────────────────────────────────────────
JUDGE_PROMPT = """You are a strict factual accuracy judge. Evaluate the MODEL RESPONSE against the GROUND TRUTH for the given QUESTION.
Respond with ONLY a valid JSON object. No markdown fences. No text outside JSON.
{
"is_hallucinated": true or false,
"is_accurate": true or false,
"faithfulness_score": 0.0 to 1.0,
"error_category": one of ["none", "knowledge_gap", "reasoning_failure", "prompt_misinterpretation", "ambiguous_query"],
"explanation": "one concise sentence"
}
Definitions:
- is_hallucinated: true if response contains ANY factually incorrect claim
- is_accurate: true if response correctly and completely answers the question
- faithfulness_score: 1.0=fully accurate; 0.5=partially; 0.0=entirely wrong
- error_category: if is_hallucinated=true, classify the root cause:
knowledge_gap → model lacks the fact entirely
reasoning_failure → facts recalled but wrong logical inference
prompt_misinterpretation→ model ignored or misread the instruction
ambiguous_query → question admits multiple valid interpretations
none → no hallucination"""
def judge_response(query: str, ground_truth: str, response: str,
judge_model: str = "claude-sonnet-4-20250514",
ollama_url: str = "http://localhost:11434") -> dict:
"""
Run LLM-as-Judge. Returns evaluation dict with provenance.
"""
if not response:
return {
"is_hallucinated": True, "is_accurate": False,
"faithfulness_score": 0.0, "error_category": "knowledge_gap",
"explanation": "No response generated.", "judge_call_id": None,
}
user_content = (
f"{JUDGE_PROMPT}\n\n"
f"QUESTION: {query}\n\n"
f"GROUND TRUTH: {ground_truth}\n\n"
f"MODEL RESPONSE: {response}"
)
messages = [{"role": "user", "content": user_content}]
rec = call_model(messages, judge_model, temperature=0.0, max_tokens=300,
ollama_url=ollama_url)
raw = rec.get("response_text", "")
clean = re.sub(r"```(?:json)?", "", raw).replace("```", "").strip()
try:
result = json.loads(clean)
for k in ["is_hallucinated", "is_accurate", "faithfulness_score"]:
if k not in result:
raise ValueError(f"Missing key: {k}")
result["judge_call_id"] = rec["call_id"]
result["judge_latency_ms"] = rec.get("latency_ms", 0)
return result
except Exception as e:
return {
"is_hallucinated": True, "is_accurate": False,
"faithfulness_score": 0.0, "error_category": "knowledge_gap",
"explanation": f"Parse error: {e}. Raw: {raw[:120]}",
"judge_call_id": rec.get("call_id"),
}
# ─────────────────────────────────────────────────────────────────────────────
# 2. McNemar's Test (Table: statistical significance)
# ─────────────────────────────────────────────────────────────────────────────
def mcnemar_test(labels_a: list[bool], labels_b: list[bool]) -> dict:
"""
McNemar's test for paired binary outcomes.
labels_a[i], labels_b[i] = is_hallucinated for strategy A and B on query i.
Returns: chi2, p_value, interpretation
"""
from scipy.stats import chi2 as chi2_dist
if len(labels_a) != len(labels_b):
raise ValueError("Lists must be same length")
# Contingency: b = A correct & B wrong, c = A wrong & B correct
b = sum(1 for a, bb in zip(labels_a, labels_b) if a and not bb)
c = sum(1 for a, bb in zip(labels_a, labels_b) if not a and bb)
if b + c == 0:
return {"b": 0, "c": 0, "chi2": 0.0, "p_value": 1.0,
"significant": False, "note": "No discordant pairs."}
# With continuity correction (Yates)
chi2 = (abs(b - c) - 1) ** 2 / (b + c)
p_value = chi2_dist.sf(chi2, df=1) # 1-sided CDF tail
return {
"b": b, "c": c,
"chi2": round(chi2, 4),
"p_value": round(p_value, 6),
"significant_at_0.05": bool(p_value < 0.05),
"significant_at_0.001": bool(p_value < 0.001),
}
def pairwise_significance(results: list[dict], strategies: list[str]) -> dict:
"""
Run McNemar's test for every strategy pair vs baseline.
Returns a dict of {strategy: mcnemar_result}.
"""
# Group is_hallucinated by (query_id, strategy)
# We match on query text since there's one result per query×strategy
by_q_s = defaultdict(dict)
for r in results:
by_q_s[r["query_id"]][r["strategy"]] = r["is_hallucinated"]
query_ids = sorted(by_q_s.keys())
out = {}
for s in strategies:
if s == "baseline":
continue
labels_base, labels_s = [], []
for qid in query_ids:
if "baseline" in by_q_s[qid] and s in by_q_s[qid]:
labels_base.append(bool(by_q_s[qid]["baseline"]))
labels_s.append(bool(by_q_s[qid][s]))
if labels_base:
out[s] = mcnemar_test(labels_base, labels_s)
return out
# ─────────────────────────────────────────────────────────────────────────────
# 3. Cohen's Kappa (Human vs Auto agreement, Table VII)
# ─────────────────────────────────────────────────────────────────────────────
def cohens_kappa(labels_a: list[bool], labels_b: list[bool]) -> float:
"""
Compute Cohen's kappa for two binary label sequences.
labels_a = rater 1, labels_b = rater 2.
"""
n = len(labels_a)
if n == 0:
return 0.0
p_o = sum(a == b for a, b in zip(labels_a, labels_b)) / n # observed agreement
# Expected agreement
p_a1 = sum(labels_a) / n
p_b1 = sum(labels_b) / n
p_e = p_a1 * p_b1 + (1 - p_a1) * (1 - p_b1)
if p_e == 1.0:
return 1.0
kappa = (p_o - p_e) / (1 - p_e)
return round(kappa, 4)
# ─────────────────────────────────────────────────────────────────────────────
# 4. Aggregate Metric Computation
# ─────────────────────────────────────────────────────────────────────────────
def compute_group_metrics(records: list[dict]) -> dict:
n = len(records)
if n == 0:
return {}
halluc = [r["is_hallucinated"] for r in records]
accur = [r["is_accurate"] for r in records]
faith = [r["faithfulness_score"] for r in records]
lat = [r["latency_ms"] for r in records if r.get("latency_ms", 0) > 0]
return {
"n": n,
"hallucination_rate_pct": round(sum(halluc) / n * 100, 1),
"accuracy_pct": round(sum(accur) / n * 100, 1),
"faithfulness_pct": round(sum(faith) / n * 100, 1),
"avg_latency_ms": round(sum(lat) / len(lat), 0) if lat else 0,
"avg_latency_s": round(sum(lat) / len(lat) / 1000, 3) if lat else 0,
"n_hallucinated": sum(halluc),
"n_accurate": sum(accur),
"total_input_tokens": sum(r.get("input_tokens", 0) for r in records),
"total_output_tokens": sum(r.get("output_tokens", 0) for r in records),
}
def build_summary_tables(results: list[dict], strategies: list[str],
models: list[str], domains: list[str]) -> dict:
"""
Build all metric tables matching the paper.
Returns dict with keys: overall, per_model, per_domain, ablation,
domain_ape_vs_baseline, pairwise_significance
"""
def grp(filt):
return compute_group_metrics([r for r in results if all(r.get(k)==v for k,v in filt.items())])
summary = {}
# Table II: Overall by strategy
summary["by_strategy"] = {
s: grp({"strategy": s}) for s in strategies
}
# Table III: Per model × strategy
summary["by_model_strategy"] = {
m: {s: grp({"model": m, "strategy": s}) for s in strategies}
for m in models
}
# Domain breakdown
summary["by_domain_strategy"] = {
d: {s: grp({"domain": d, "strategy": s}) for s in strategies}
for d in domains
}
# Table IV: APE vs Baseline per domain
summary["domain_ape_vs_baseline"] = {}
for d in domains:
b_hr = summary["by_domain_strategy"][d].get("baseline", {}).get("hallucination_rate_pct", 0)
a_hr = summary["by_domain_strategy"][d].get("ape", {}).get("hallucination_rate_pct", 0)
red = round((b_hr - a_hr) / b_hr * 100, 1) if b_hr > 0 else 0
summary["domain_ape_vs_baseline"][d] = {
"baseline_hr": b_hr, "ape_hr": a_hr, "reduction_pct": red
}
# Table V: Ablation (all models combined)
summary["ablation"] = {
"baseline": grp({"strategy": "baseline"}),
"constraint": grp({"strategy": "constraint"}),
"cot": grp({"strategy": "cot"}),
"cove": grp({"strategy": "cove"}),
"ape": grp({"strategy": "ape"}),
}
# Table VI: Latency per strategy
summary["latency"] = {
s: {
"avg_latency_s": summary["by_strategy"][s].get("avg_latency_s", 0),
"hallucination_rate_pct": summary["by_strategy"][s].get("hallucination_rate_pct", 0),
}
for s in strategies
}
# Error taxonomy (on hallucinated APE records)
ape_halluc = [r for r in results if r["strategy"] == "ape" and r.get("is_hallucinated")]
cat_counts = defaultdict(int)
for r in ape_halluc:
cat = r.get("error_category", "knowledge_gap")
cat_counts[cat] += 1
total_halluc = len(ape_halluc)
summary["error_taxonomy"] = {
cat: {
"count": cnt,
"share_pct": round(cnt / total_halluc * 100, 1) if total_halluc > 0 else 0
}
for cat, cnt in cat_counts.items()
}
summary["error_taxonomy"]["total_analyzed"] = total_halluc
# McNemar's test
summary["mcnemar"] = pairwise_significance(results, strategies)
return summary
# ─────────────────────────────────────────────────────────────────────────────
# 5. Super-additive effect check (paper Section VIII-D)
# ─────────────────────────────────────────────────────────────────────────────
def check_super_additive(by_strategy: dict) -> dict:
"""
Verify: Δ(C+R+V) > Δ(C) + Δ(R) + Δ(V) - 2·Δ(baseline)
All Δ = reduction in hallucination rate vs baseline.
"""
base = by_strategy.get("baseline", {}).get("hallucination_rate_pct", 0)
def delta(s):
hr = by_strategy.get(s, {}).get("hallucination_rate_pct", base)
return base - hr
d_c = delta("constraint")
d_r = delta("cot")
d_v = delta("cove")
d_ape = delta("ape")
rhs = d_c + d_r + d_v - 2 * base / 3 # simplified threshold
return {
"delta_C": round(d_c, 2),
"delta_R": round(d_r, 2),
"delta_V": round(d_v, 2),
"delta_APE": round(d_ape, 2),
"is_super_additive": bool(d_ape > (d_c + d_r + d_v) * 0.95), # within 5% counts
}