input-ambiguity-detection 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. input_ambiguity/__init__.py +10 -0
  2. input_ambiguity/__main__.py +3 -0
  3. input_ambiguity/blackbox/__init__.py +0 -0
  4. input_ambiguity/blackbox/analyze_uq.py +669 -0
  5. input_ambiguity/blackbox/compute_uq.py +400 -0
  6. input_ambiguity/blackbox/llm_utils.py +277 -0
  7. input_ambiguity/blackbox/perturbation_rules.py +848 -0
  8. input_ambiguity/blackbox/run_llm_perturbation.py +655 -0
  9. input_ambiguity/blackbox/run_paraphrase.py +351 -0
  10. input_ambiguity/blackbox/run_perturbation.py +353 -0
  11. input_ambiguity/cli.py +74 -0
  12. input_ambiguity/quickstart.py +53 -0
  13. input_ambiguity/quickstart_data/pipeline_b_baseline_n10.json +16569 -0
  14. input_ambiguity/quickstart_data/pipeline_b_logit_lens.json +14224 -0
  15. input_ambiguity/whitebox/__init__.py +0 -0
  16. input_ambiguity/whitebox/pipeline/__init__.py +0 -0
  17. input_ambiguity/whitebox/pipeline/compute_auroc.py +95 -0
  18. input_ambiguity/whitebox/pipeline/compute_uq_from_checkpoint.py +268 -0
  19. input_ambiguity/whitebox/pipeline/pipeline_b_perturb_and_uq.py +1258 -0
  20. input_ambiguity/whitebox/pipeline/pipeline_b_perturb_and_uq_vl.py +766 -0
  21. input_ambiguity/whitebox/text/__init__.py +0 -0
  22. input_ambiguity/whitebox/text/attribution_utils.py +330 -0
  23. input_ambiguity/whitebox/text/diffmask_uncertainty.py +755 -0
  24. input_ambiguity/whitebox/text/gradient_attribution.py +440 -0
  25. input_ambiguity/whitebox/text/logit_lens_uncertainty.py +605 -0
  26. input_ambiguity/whitebox/text/normalize_attributions.py +106 -0
  27. input_ambiguity/whitebox/vision/__init__.py +0 -0
  28. input_ambiguity/whitebox/vision/diffmask_uncertainty.py +318 -0
  29. input_ambiguity/whitebox/vision/gradient_attribution.py +252 -0
  30. input_ambiguity/whitebox/vision/logit_lens_uncertainty.py +367 -0
  31. input_ambiguity/whitebox/vision/vl_attribution_utils.py +342 -0
  32. input_ambiguity_detection-0.1.0.dist-info/METADATA +239 -0
  33. input_ambiguity_detection-0.1.0.dist-info/RECORD +37 -0
  34. input_ambiguity_detection-0.1.0.dist-info/WHEEL +5 -0
  35. input_ambiguity_detection-0.1.0.dist-info/entry_points.txt +2 -0
  36. input_ambiguity_detection-0.1.0.dist-info/licenses/LICENSE +21 -0
  37. input_ambiguity_detection-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,10 @@
1
+ """Perturbation-based input ambiguity detection for LLMs (AACL 2026).
2
+
3
+ Subpackages:
4
+ input_ambiguity.whitebox importance-guided (Vanilla Gradient / Logit Lens / DiffMask) detector
5
+ input_ambiguity.blackbox rule-based (11 semantics-blind rules) detector
6
+
7
+ Command line: ``input-ambiguity --help``.
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
File without changes
@@ -0,0 +1,669 @@
1
+ """
2
+ Analyze UQ scores for ABG-CoQA: ambiguous vs unambiguous.
3
+
4
+ Two axes of analysis:
5
+ A. Per-variant UQ by rule_id: which perturbation type best separates amb/unamb?
6
+ B. Cross-variant UQ: does pooling all variants improve detection?
7
+
8
+ Generates:
9
+ 1. AUROC bar chart — per rule_id + cross-variant + ORIGINAL (all methods)
10
+ 2. AUROC ROC curves — per method, one curve per rule_id
11
+ 3. Amb vs Unamb mean comparison — grouped bar chart per rule_id
12
+ 4. Violin / box plots per rule_id
13
+ 5. Effect-size heatmap (Cohen's d) methods × rule_ids
14
+ 6. Statistical summary table
15
+
16
+ Usage:
17
+ python blackbox/analyze_uq.py \
18
+ --uq results/blackbox_qwen3_abg/uq.json \
19
+ --results results/blackbox_qwen3_abg/results.json \
20
+ --output_dir results/blackbox_qwen3_abg/analysis
21
+
22
+ The "CROSS" column is the paper's black-box detector: EigV(Agree)
23
+ (EigValLaplacian_NLI_entail) over the answers pooled from the original
24
+ question and all of its rule variants.
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import numpy as np
31
+ import matplotlib
32
+ matplotlib.use("Agg")
33
+ import matplotlib.pyplot as plt
34
+ import matplotlib.patches as mpatches
35
+ from collections import OrderedDict, defaultdict
36
+ from scipy import stats as scipy_stats
37
+ from sklearn.metrics import roc_auc_score, roc_curve
38
+
39
+
40
+ # ── Display names ───────────────────────────────────────────────────
41
+ RULE_DISPLAY = {
42
+ "ORIGINAL": "Original",
43
+ "A1": "Verb Synonym",
44
+ "A2": "Noun Synonym",
45
+ "A3": "Adjective Synonym",
46
+ "A4": "Adverb Synonym",
47
+ "A5": "Abbreviation Expand",
48
+ "A6": "Abbreviation Contract",
49
+ "B1": "Test-like Prefix",
50
+ "B2": "Conversation-like Prefix",
51
+ "B3": "Instruction Prefix",
52
+ "B4": "WH Expansion",
53
+ "B5": "WH Rephrase",
54
+ "CROSS": "Cross-Variant (all)",
55
+ "GAP": "Gap (cross−mean_pv)",
56
+ "RULE_DISAGREE": "Rule Disagreement",
57
+ }
58
+
59
+ METHOD_SHORT = {
60
+ "LexicalSimilarity_rougeL": "LexSim_rL",
61
+ "LexicalSimilarity_rouge1": "LexSim_r1",
62
+ "LexicalSimilarity_rouge2": "LexSim_r2",
63
+ "EigValLaplacian_NLI_entail": "EigV_entail",
64
+ "EigValLaplacian_NLI_contra": "EigV_contra",
65
+ "EigValLaplacian_Jaccard": "EigV_Jacc",
66
+ "DegMat_NLI_entail": "Deg_entail",
67
+ "DegMat_NLI_contra": "Deg_contra",
68
+ "DegMat_Jaccard": "Deg_Jacc",
69
+ "Eccentricity_NLI_entail": "Ecc_entail",
70
+ "Eccentricity_NLI_contra": "Ecc_contra",
71
+ "Eccentricity_Jaccard": "Ecc_Jacc",
72
+ "NumSemSets": "NumSemSets",
73
+ "SemanticEntropy_freq": "SE_freq",
74
+ }
75
+
76
+ # Canonical display order: ORIGINAL, A-rules, B-rules, then CROSS always last
77
+ RULE_ORDER = ["ORIGINAL",
78
+ "A1", "A2", "A3", "A4", "A5", "A6",
79
+ "B1", "B2", "B3", "B4", "B5",
80
+ "CROSS", "GAP", "RULE_DISAGREE"]
81
+
82
+ COLORS_RULE = {} # populated at runtime
83
+
84
+
85
+ # ── Load & merge ────────────────────────────────────────────────────
86
+ def _build_label_map(results_data):
87
+ label_map = {}
88
+ for ex in results_data["results"]:
89
+ label_map[ex["example_id"]] = ex["ambiguity_label"] # "ambiguous" / "non_ambiguous"
90
+ return label_map
91
+
92
+
93
+ def _convert_score_groups_to_numpy(score_groups):
94
+ for m in score_groups:
95
+ score_groups[m]["amb"] = np.array(score_groups[m]["amb"])
96
+ score_groups[m]["unamb"] = np.array(score_groups[m]["unamb"])
97
+
98
+
99
+ def _convert_rule_scores_to_numpy(rule_scores):
100
+ for rid in rule_scores:
101
+ _convert_score_groups_to_numpy(rule_scores[rid])
102
+
103
+
104
+ def _finalize_rule_scores(rule_scores, method_names, label_map, n_examples):
105
+ if not method_names:
106
+ raise ValueError("No valid UQ methods found in the input JSON.")
107
+
108
+ # Filter rule_ids with enough data (>=10 amb + >=10 unamb)
109
+ valid_rules = []
110
+ for rid in sorted(rule_scores.keys()):
111
+ first_m = method_names[0]
112
+ if first_m not in rule_scores[rid]:
113
+ continue
114
+ n_a = len(rule_scores[rid][first_m]["amb"])
115
+ n_u = len(rule_scores[rid][first_m]["unamb"])
116
+ if n_a >= 10 and n_u >= 10:
117
+ valid_rules.append(rid)
118
+
119
+ # Reorder by RULE_ORDER; any unexpected rule_ids go before CROSS
120
+ ordered = [r for r in RULE_ORDER if r in valid_rules]
121
+ ordered_set = set(ordered)
122
+ extras = [r for r in valid_rules if r not in ordered_set]
123
+ if "CROSS" in ordered:
124
+ idx = ordered.index("CROSS")
125
+ ordered = ordered[:idx] + extras + ordered[idx:]
126
+ else:
127
+ ordered += extras
128
+
129
+ if not ordered:
130
+ raise ValueError("No rule groups have enough data (need >=10 ambiguous and >=10 non-ambiguous examples).")
131
+
132
+ n_amb = sum(1 for v in label_map.values() if v == "ambiguous")
133
+ n_unamb = len(label_map) - n_amb
134
+ print(f"Loaded {n_examples} examples: {n_amb} ambiguous, {n_unamb} non-ambiguous")
135
+ print(f"Valid rule_ids (≥10 amb & unamb): {ordered}")
136
+ print(f"Methods: {len(method_names)}")
137
+
138
+ COLORS_RULE.clear()
139
+ cmap = matplotlib.colormaps["tab20"].resampled(len(ordered))
140
+ for i, r in enumerate(ordered):
141
+ COLORS_RULE[r] = cmap(i)
142
+
143
+ return rule_scores, method_names, ordered, label_map
144
+
145
+
146
+ def load(uq_path, results_path, rule_disagree_path=None):
147
+ uq = json.load(open(uq_path, encoding="utf-8"))
148
+ results_data = json.load(open(results_path, encoding="utf-8"))
149
+ label_map = _build_label_map(results_data)
150
+
151
+ if not uq:
152
+ raise ValueError(f"Empty UQ JSON: {uq_path}")
153
+
154
+ first_entry = next(iter(uq.values()))
155
+ standalone_rule_disagree = (
156
+ isinstance(first_entry, dict)
157
+ and "per_variant" not in first_entry
158
+ and "cross_variant" not in first_entry
159
+ )
160
+
161
+ # Standalone rule_disagree JSON:
162
+ # {example_id: {n_representatives, method, metric1, metric2, ...}}
163
+ if standalone_rule_disagree:
164
+ if rule_disagree_path:
165
+ print("Warning: --rule_disagree is ignored because --uq is already a standalone rule_disagree JSON.")
166
+
167
+ metric_keys = [k for k in first_entry.keys() if k not in ("n_representatives", "method")]
168
+ method_names = []
169
+ for m in metric_keys:
170
+ has_finite = any(
171
+ (val.get(m) is not None) and np.isfinite(val.get(m))
172
+ for val in uq.values()
173
+ )
174
+ if has_finite:
175
+ method_names.append(m)
176
+
177
+ rd_scores = defaultdict(lambda: {"amb": [], "unamb": []})
178
+ for eid, val in uq.items():
179
+ lab = label_map.get(eid, "non_ambiguous")
180
+ key = "amb" if lab == "ambiguous" else "unamb"
181
+ for m in method_names:
182
+ v = val.get(m)
183
+ if v is not None and np.isfinite(v):
184
+ rd_scores[m][key].append(v)
185
+
186
+ _convert_score_groups_to_numpy(rd_scores)
187
+
188
+ rule_scores = defaultdict(lambda: defaultdict(lambda: {"amb": [], "unamb": []}))
189
+ rule_scores["RULE_DISAGREE"] = dict(rd_scores)
190
+ return _finalize_rule_scores(rule_scores, method_names, label_map, n_examples=len(uq))
191
+
192
+ # ---- Per-variant scores grouped by (rule_id, method) ----
193
+ # rule_scores[rule_id][method] = {"amb": [...], "unamb": [...]}
194
+ rule_scores = defaultdict(lambda: defaultdict(lambda: {"amb": [], "unamb": []}))
195
+
196
+ # ---- Cross-variant scores ----
197
+ cross_scores = defaultdict(lambda: {"amb": [], "unamb": []})
198
+
199
+ method_names = None
200
+
201
+ for eid, val in uq.items():
202
+ lab = label_map.get(eid, "non_ambiguous")
203
+ key = "amb" if lab == "ambiguous" else "unamb"
204
+
205
+ # per-variant
206
+ for pv in val["per_variant"]:
207
+ rid = pv["rule_id"]
208
+ if method_names is None:
209
+ method_names = [k for k in pv if k not in ("variant_id", "rule_id", "rule_name")]
210
+ for m in method_names:
211
+ v = pv.get(m)
212
+ if v is not None and np.isfinite(v):
213
+ rule_scores[rid][m][key].append(v)
214
+
215
+ # cross-variant
216
+ cv = val.get("cross_variant", {})
217
+ for m in method_names:
218
+ v = cv.get(m)
219
+ if v is not None and np.isfinite(v):
220
+ cross_scores[m][key].append(v)
221
+
222
+ # ---- Gap scores (cross - mean_per_variant) ----
223
+ gap_scores = defaultdict(lambda: {"amb": [], "unamb": []})
224
+
225
+ for eid, val in uq.items():
226
+ lab = label_map.get(eid, "non_ambiguous")
227
+ key = "amb" if lab == "ambiguous" else "unamb"
228
+ gap = val.get("gap", {})
229
+ for m in method_names:
230
+ v = gap.get(m)
231
+ if v is not None and np.isfinite(v):
232
+ gap_scores[m][key].append(v)
233
+
234
+ # Convert lists to numpy
235
+ _convert_rule_scores_to_numpy(rule_scores)
236
+ _convert_score_groups_to_numpy(cross_scores)
237
+ _convert_score_groups_to_numpy(gap_scores)
238
+
239
+ # ---- Rule-level disagreement scores ----
240
+ rd_scores = defaultdict(lambda: {"amb": [], "unamb": []})
241
+
242
+ if rule_disagree_path:
243
+ rd_data = json.load(open(rule_disagree_path, encoding="utf-8"))
244
+ for eid, val in rd_data.items():
245
+ lab = label_map.get(eid, "non_ambiguous")
246
+ key = "amb" if lab == "ambiguous" else "unamb"
247
+ for m in method_names:
248
+ v = val.get(m)
249
+ if v is not None and np.isfinite(v):
250
+ rd_scores[m][key].append(v)
251
+ _convert_score_groups_to_numpy(rd_scores)
252
+
253
+ # Add pseudo rule_ids only when they have data
254
+ rule_scores["CROSS"] = dict(cross_scores)
255
+ if gap_scores:
256
+ rule_scores["GAP"] = dict(gap_scores)
257
+ if rule_disagree_path and rd_scores:
258
+ rule_scores["RULE_DISAGREE"] = dict(rd_scores)
259
+
260
+ return _finalize_rule_scores(rule_scores, method_names, label_map, n_examples=len(uq))
261
+
262
+
263
+ # ── Stats helpers ───────────────────────────────────────────────────
264
+ def compute_auroc(amb, unamb):
265
+ if len(amb) < 2 or len(unamb) < 2:
266
+ return np.nan, np.nan
267
+ labels = np.concatenate([np.ones(len(amb)), np.zeros(len(unamb))])
268
+ values = np.concatenate([amb, unamb])
269
+ try:
270
+ auc = roc_auc_score(labels, values)
271
+ except:
272
+ auc = np.nan
273
+ _, p = scipy_stats.mannwhitneyu(amb, unamb, alternative="two-sided")
274
+ return auc, p
275
+
276
+
277
+ def cohens_d(a, b):
278
+ s = np.sqrt((np.std(a, ddof=1) ** 2 + np.std(b, ddof=1) ** 2) / 2)
279
+ return (np.mean(a) - np.mean(b)) / s if s > 0 else 0.0
280
+
281
+
282
+ # ── Plot 1: AUROC bar chart (one bar per rule_id, one subplot per method) ──
283
+ def plot_auroc_bars(rule_scores, method_names, ordered_rules, output_dir):
284
+ n_methods = len(method_names)
285
+ ncols = min(4, n_methods)
286
+ nrows = (n_methods + ncols - 1) // ncols
287
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4.5 * nrows))
288
+ axes = np.atleast_2d(axes).reshape(-1)
289
+
290
+ for mi, m in enumerate(method_names):
291
+ ax = axes[mi]
292
+ aurocs, pvals = [], []
293
+ for rid in ordered_rules:
294
+ d = rule_scores[rid][m]
295
+ auc, p = compute_auroc(d["amb"], d["unamb"])
296
+ aurocs.append(auc)
297
+ pvals.append(p)
298
+
299
+ colors = [COLORS_RULE[r] for r in ordered_rules]
300
+ display_labels = [RULE_DISPLAY.get(r, r) for r in ordered_rules]
301
+
302
+ bars = ax.bar(range(len(ordered_rules)), aurocs, color=colors, alpha=0.85, edgecolor="white")
303
+ for i, (auc, p) in enumerate(zip(aurocs, pvals)):
304
+ if np.isnan(auc):
305
+ continue
306
+ star = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else ""
307
+ ax.text(i, auc + 0.005, f"{auc:.3f}{star}", ha="center", va="bottom",
308
+ fontsize=7, fontweight="bold")
309
+
310
+ ax.set_xticks(range(len(ordered_rules)))
311
+ ax.set_xticklabels(display_labels, rotation=45, ha="right", fontsize=7)
312
+ ax.axhline(y=0.5, color="black", linewidth=0.8, linestyle="--")
313
+ ax.set_ylabel("AUROC")
314
+ ax.set_title(METHOD_SHORT.get(m, m), fontsize=10, fontweight="bold")
315
+ ax.set_ylim(0.35, max(0.75, np.nanmax(aurocs) + 0.08))
316
+ ax.grid(axis="y", alpha=0.3)
317
+
318
+ for i in range(n_methods, len(axes)):
319
+ axes[i].set_visible(False)
320
+
321
+ fig.suptitle("AUROC (Amb vs Unamb) per Perturbation Type", fontsize=15, fontweight="bold")
322
+ plt.tight_layout()
323
+ plt.savefig(os.path.join(output_dir, "auroc_per_rule.png"), dpi=150, bbox_inches="tight")
324
+ plt.close()
325
+ print(" Saved auroc_per_rule.png")
326
+
327
+
328
+ # ── Plot 2: ROC curves — one plot per method, one curve per rule_id ──
329
+ def plot_roc_curves(rule_scores, method_names, ordered_rules, output_dir):
330
+ n_methods = len(method_names)
331
+ ncols = min(4, n_methods)
332
+ nrows = (n_methods + ncols - 1) // ncols
333
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 5 * nrows))
334
+ axes = np.atleast_2d(axes).reshape(-1)
335
+
336
+ for mi, m in enumerate(method_names):
337
+ ax = axes[mi]
338
+ for rid in ordered_rules:
339
+ d = rule_scores[rid][m]
340
+ amb, unamb = d["amb"], d["unamb"]
341
+ if len(amb) < 2 or len(unamb) < 2:
342
+ continue
343
+ labels = np.concatenate([np.ones(len(amb)), np.zeros(len(unamb))])
344
+ values = np.concatenate([amb, unamb])
345
+ try:
346
+ fpr, tpr, _ = roc_curve(labels, values)
347
+ auc = roc_auc_score(labels, values)
348
+ except:
349
+ continue
350
+ lbl = RULE_DISPLAY.get(rid, rid)
351
+ ax.plot(fpr, tpr, label=f"{lbl} ({auc:.3f})", color=COLORS_RULE[rid], linewidth=1.2)
352
+
353
+ ax.plot([0, 1], [0, 1], "k--", linewidth=0.8)
354
+ ax.set_xlabel("FPR", fontsize=9)
355
+ ax.set_ylabel("TPR", fontsize=9)
356
+ ax.set_title(METHOD_SHORT.get(m, m), fontsize=10, fontweight="bold")
357
+ ax.legend(fontsize=5, loc="lower right")
358
+ ax.grid(alpha=0.3)
359
+
360
+ for i in range(n_methods, len(axes)):
361
+ axes[i].set_visible(False)
362
+
363
+ fig.suptitle("ROC Curves (Amb vs Unamb) per Perturbation Type", fontsize=15, fontweight="bold")
364
+ plt.tight_layout()
365
+ plt.savefig(os.path.join(output_dir, "roc_curves_per_rule.png"), dpi=150, bbox_inches="tight")
366
+ plt.close()
367
+ print(" Saved roc_curves_per_rule.png")
368
+
369
+
370
+ # ── Plot 3: Amb vs Unamb mean comparison (grouped bars) ────────────
371
+ def plot_mean_comparison(rule_scores, method_names, ordered_rules, output_dir):
372
+ # One figure per method
373
+ for m in method_names:
374
+ display_labels = [RULE_DISPLAY.get(r, r) for r in ordered_rules]
375
+ x = np.arange(len(ordered_rules))
376
+ width = 0.35
377
+
378
+ means_a, stds_a, means_u, stds_u = [], [], [], []
379
+ for rid in ordered_rules:
380
+ d = rule_scores[rid][m]
381
+ means_a.append(np.mean(d["amb"]) if len(d["amb"]) else 0)
382
+ stds_a.append(np.std(d["amb"]) if len(d["amb"]) else 0)
383
+ means_u.append(np.mean(d["unamb"]) if len(d["unamb"]) else 0)
384
+ stds_u.append(np.std(d["unamb"]) if len(d["unamb"]) else 0)
385
+
386
+ fig, ax = plt.subplots(figsize=(max(10, len(ordered_rules) * 1.2), 6))
387
+ ax.bar(x - width / 2, means_a, width, yerr=stds_a,
388
+ color="#F44336", alpha=0.8, capsize=3, label="Ambiguous", edgecolor="white")
389
+ ax.bar(x + width / 2, means_u, width, yerr=stds_u,
390
+ color="#4CAF50", alpha=0.8, capsize=3, label="Non-ambiguous", edgecolor="white")
391
+
392
+ for i in range(len(ordered_rules)):
393
+ if means_a[i] != 0:
394
+ ax.text(x[i] - width / 2, means_a[i] + stds_a[i] + 0.002,
395
+ f"{means_a[i]:.4f}", ha="center", va="bottom", fontsize=6)
396
+ if means_u[i] != 0:
397
+ ax.text(x[i] + width / 2, means_u[i] + stds_u[i] + 0.002,
398
+ f"{means_u[i]:.4f}", ha="center", va="bottom", fontsize=6)
399
+
400
+ ax.set_xticks(x)
401
+ ax.set_xticklabels(display_labels, rotation=40, ha="right", fontsize=9)
402
+ ax.set_ylabel(f"Mean {METHOD_SHORT.get(m, m)}")
403
+ ax.set_title(f"{METHOD_SHORT.get(m, m)}: Amb vs Unamb per Perturbation", fontsize=12, fontweight="bold")
404
+ ax.legend(fontsize=10)
405
+ ax.grid(axis="y", alpha=0.3)
406
+ # dynamic y range
407
+ all_tops = [a + s for a, s in zip(means_a + means_u, stds_a + stds_u)]
408
+ all_bots = [a - s for a, s in zip(means_a + means_u, stds_a + stds_u)]
409
+ ymin = max(0, min(all_bots) - 0.05 * (max(all_tops) - min(all_bots)))
410
+ ymax = max(all_tops) + 0.1 * (max(all_tops) - min(all_bots))
411
+ if ymax > ymin:
412
+ ax.set_ylim(ymin, ymax)
413
+
414
+ plt.tight_layout()
415
+ short = METHOD_SHORT.get(m, m).replace("/", "_")
416
+ plt.savefig(os.path.join(output_dir, f"mean_amb_unamb_{short}.png"), dpi=150, bbox_inches="tight")
417
+ plt.close()
418
+
419
+ print(f" Saved mean_amb_unamb_*.png ({len(method_names)} files)")
420
+
421
+
422
+ # ── Plot 4: Violin plots ──────────────────────────────────────────
423
+ def plot_violins(rule_scores, method_names, ordered_rules, output_dir):
424
+ # Pick a few representative methods
425
+ highlight = ["EigValLaplacian_NLI_entail", "DegMat_NLI_entail",
426
+ "Eccentricity_NLI_entail", "SemanticEntropy_freq", "NumSemSets"]
427
+ methods = [m for m in highlight if m in method_names]
428
+ if not methods:
429
+ methods = method_names[:4]
430
+
431
+ for m in methods:
432
+ n_rules = len(ordered_rules)
433
+ fig, ax = plt.subplots(figsize=(max(14, n_rules * 2), 7))
434
+ positions_a, positions_u = [], []
435
+ data_a, data_u = [], []
436
+
437
+ for vi, rid in enumerate(ordered_rules):
438
+ center = vi * 3
439
+ d = rule_scores[rid][m]
440
+ a = d["amb"] if len(d["amb"]) > 0 else np.array([0])
441
+ u = d["unamb"] if len(d["unamb"]) > 0 else np.array([0])
442
+ data_a.append(a)
443
+ data_u.append(u)
444
+ positions_a.append(center - 0.4)
445
+ positions_u.append(center + 0.4)
446
+
447
+ vp_a = ax.violinplot(data_a, positions=positions_a, widths=0.7,
448
+ showmeans=True, showmedians=True)
449
+ for pc in vp_a["bodies"]:
450
+ pc.set_facecolor("#F44336")
451
+ pc.set_alpha(0.6)
452
+
453
+ vp_u = ax.violinplot(data_u, positions=positions_u, widths=0.7,
454
+ showmeans=True, showmedians=True)
455
+ for pc in vp_u["bodies"]:
456
+ pc.set_facecolor("#4CAF50")
457
+ pc.set_alpha(0.6)
458
+
459
+ centers = [vi * 3 for vi in range(n_rules)]
460
+ display_labels = [RULE_DISPLAY.get(r, r) for r in ordered_rules]
461
+ ax.set_xticks(centers)
462
+ ax.set_xticklabels(display_labels, rotation=35, ha="right", fontsize=9)
463
+ ax.set_ylabel(METHOD_SHORT.get(m, m), fontsize=11)
464
+ ax.set_title(f"{METHOD_SHORT.get(m, m)} Distribution: Amb vs Unamb per Perturbation",
465
+ fontsize=12, fontweight="bold")
466
+ ax.grid(axis="y", alpha=0.3)
467
+ ax.legend(handles=[
468
+ mpatches.Patch(color="#F44336", alpha=0.6, label="Ambiguous"),
469
+ mpatches.Patch(color="#4CAF50", alpha=0.6, label="Non-ambiguous"),
470
+ ], fontsize=10)
471
+
472
+ plt.tight_layout()
473
+ short = METHOD_SHORT.get(m, m).replace("/", "_")
474
+ plt.savefig(os.path.join(output_dir, f"violin_{short}.png"), dpi=150, bbox_inches="tight")
475
+ plt.close()
476
+
477
+ print(f" Saved violin_*.png ({len(methods)} files)")
478
+
479
+
480
+ # ── Plot 5: Effect-size heatmap (Cohen's d) — methods × rule_ids ──
481
+ def plot_effect_heatmap(rule_scores, method_names, ordered_rules, output_dir):
482
+ n_m = len(method_names)
483
+ n_r = len(ordered_rules)
484
+ d_matrix = np.zeros((n_m, n_r))
485
+ p_matrix = np.ones((n_m, n_r))
486
+
487
+ for mi, m in enumerate(method_names):
488
+ for ri, rid in enumerate(ordered_rules):
489
+ d = rule_scores[rid][m]
490
+ if len(d["amb"]) >= 2 and len(d["unamb"]) >= 2:
491
+ d_matrix[mi, ri] = cohens_d(d["amb"], d["unamb"])
492
+ _, p_matrix[mi, ri] = scipy_stats.mannwhitneyu(
493
+ d["amb"], d["unamb"], alternative="two-sided"
494
+ )
495
+
496
+ short_methods = [METHOD_SHORT.get(m, m) for m in method_names]
497
+ short_rules = [RULE_DISPLAY.get(r, r) for r in ordered_rules]
498
+
499
+ fig, ax = plt.subplots(figsize=(max(10, n_r * 1.5), max(8, n_m * 0.6)))
500
+ vmax = max(abs(d_matrix.min()), abs(d_matrix.max()))
501
+ im = ax.imshow(d_matrix, cmap="RdBu_r", aspect="auto", vmin=-vmax, vmax=vmax)
502
+
503
+ ax.set_xticks(range(n_r))
504
+ ax.set_yticks(range(n_m))
505
+ ax.set_xticklabels(short_rules, rotation=40, ha="right", fontsize=9)
506
+ ax.set_yticklabels(short_methods, fontsize=9)
507
+
508
+ for i in range(n_m):
509
+ for j in range(n_r):
510
+ p = p_matrix[i, j]
511
+ star = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else ""
512
+ val = d_matrix[i, j]
513
+ color = "white" if abs(val) > 0.3 else "black"
514
+ ax.text(j, i, f"{val:.2f}{star}", ha="center", va="center", fontsize=7, color=color)
515
+
516
+ plt.colorbar(im, ax=ax, label="Cohen's d (positive = higher UQ for ambiguous)")
517
+ ax.set_title("Cohen's d: Amb vs Unamb, Methods × Perturbation Types",
518
+ fontsize=13, fontweight="bold")
519
+ plt.tight_layout()
520
+ plt.savefig(os.path.join(output_dir, "effect_heatmap.png"), dpi=150, bbox_inches="tight")
521
+ plt.close()
522
+ print(" Saved effect_heatmap.png")
523
+
524
+
525
+ # ── Plot 6: AUROC heatmap — methods × rule_ids ────────────────────
526
+ def plot_auroc_heatmap(rule_scores, method_names, ordered_rules, output_dir):
527
+ n_m = len(method_names)
528
+ n_r = len(ordered_rules)
529
+ auc_matrix = np.full((n_m, n_r), 0.5)
530
+ p_matrix = np.ones((n_m, n_r))
531
+
532
+ for mi, m in enumerate(method_names):
533
+ for ri, rid in enumerate(ordered_rules):
534
+ d = rule_scores[rid][m]
535
+ auc, p = compute_auroc(d["amb"], d["unamb"])
536
+ if not np.isnan(auc):
537
+ auc_matrix[mi, ri] = auc
538
+ p_matrix[mi, ri] = p
539
+
540
+ short_methods = [METHOD_SHORT.get(m, m) for m in method_names]
541
+ short_rules = [RULE_DISPLAY.get(r, r) for r in ordered_rules]
542
+
543
+ fig, ax = plt.subplots(figsize=(max(10, n_r * 1.5), max(8, n_m * 0.6)))
544
+ im = ax.imshow(auc_matrix, cmap="YlOrRd", aspect="auto", vmin=0.4, vmax=0.75)
545
+
546
+ ax.set_xticks(range(n_r))
547
+ ax.set_yticks(range(n_m))
548
+ ax.set_xticklabels(short_rules, rotation=40, ha="right", fontsize=9)
549
+ ax.set_yticklabels(short_methods, fontsize=9)
550
+
551
+ for i in range(n_m):
552
+ for j in range(n_r):
553
+ p = p_matrix[i, j]
554
+ star = "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else ""
555
+ val = auc_matrix[i, j]
556
+ color = "white" if val > 0.6 else "black"
557
+ ax.text(j, i, f"{val:.3f}{star}", ha="center", va="center", fontsize=7, color=color)
558
+
559
+ plt.colorbar(im, ax=ax, label="AUROC")
560
+ ax.set_title("AUROC Heatmap: Amb vs Unamb, Methods × Perturbation Types",
561
+ fontsize=13, fontweight="bold")
562
+ plt.tight_layout()
563
+ plt.savefig(os.path.join(output_dir, "auroc_heatmap.png"), dpi=150, bbox_inches="tight")
564
+ plt.close()
565
+ print(" Saved auroc_heatmap.png")
566
+
567
+
568
+ # ── Summary table (printed + saved) ───────────────────────────────
569
+ def print_and_save_summary(rule_scores, method_names, ordered_rules, output_dir):
570
+ all_stats = OrderedDict()
571
+
572
+ for m in method_names:
573
+ all_stats[m] = OrderedDict()
574
+ for rid in ordered_rules:
575
+ d = rule_scores[rid][m]
576
+ amb, unamb = d["amb"], d["unamb"]
577
+ auc, mw_p = compute_auroc(amb, unamb)
578
+ cd = cohens_d(amb, unamb) if len(amb) >= 2 and len(unamb) >= 2 else 0
579
+ all_stats[m][rid] = {
580
+ "n_amb": len(amb),
581
+ "n_unamb": len(unamb),
582
+ "amb_mean": float(np.mean(amb)) if len(amb) else None,
583
+ "unamb_mean": float(np.mean(unamb)) if len(unamb) else None,
584
+ "mean_diff": float(np.mean(amb) - np.mean(unamb)) if len(amb) and len(unamb) else None,
585
+ "cohens_d": float(cd),
586
+ "auroc": float(auc) if not np.isnan(auc) else None,
587
+ "mannwhitney_p": float(mw_p) if not np.isnan(mw_p) else None,
588
+ }
589
+
590
+ # Print compact table
591
+ print(f"\n{'=' * 140}")
592
+ print(f" Summary: AUROC & Cohen's d (Amb vs Unamb)")
593
+ print(f"{'=' * 140}")
594
+
595
+ header = f" {'Method':<25}"
596
+ for rid in ordered_rules:
597
+ header += f" {RULE_DISPLAY.get(rid, rid):>14}"
598
+ print(header)
599
+ print(f" {'-' * (25 + 15 * len(ordered_rules))}")
600
+
601
+ for m in method_names:
602
+ line_auc = f" {METHOD_SHORT.get(m, m):<25}"
603
+ for rid in ordered_rules:
604
+ s = all_stats[m][rid]
605
+ auc = s["auroc"]
606
+ p = s["mannwhitney_p"]
607
+ if auc is not None:
608
+ # p is None when every score ties (Mann-Whitney undefined)
609
+ star = ("" if p is None else
610
+ "***" if p < 0.001 else "**" if p < 0.01 else "*" if p < 0.05 else "")
611
+ line_auc += f" {auc:>8.3f}{star:>5s}"
612
+ else:
613
+ line_auc += f" {'N/A':>13}"
614
+ print(line_auc)
615
+
616
+ # Also print which rule_id has highest AUROC per method
617
+ print(f"\n Best perturbation per method:")
618
+ for m in method_names:
619
+ best_rid, best_auc = None, 0
620
+ for rid in ordered_rules:
621
+ auc = all_stats[m][rid].get("auroc")
622
+ if auc is not None and auc > best_auc:
623
+ best_auc = auc
624
+ best_rid = rid
625
+ print(f" {METHOD_SHORT.get(m, m):<25} {RULE_DISPLAY.get(best_rid, best_rid):<20} AUROC={best_auc:.4f}")
626
+
627
+ # Save
628
+ stats_path = os.path.join(output_dir, "uq_statistics.json")
629
+ with open(stats_path, "w", encoding="utf-8") as f:
630
+ json.dump(all_stats, f, indent=2)
631
+ print(f"\n Saved {stats_path}")
632
+
633
+ return all_stats
634
+
635
+
636
+ # ── Main ────────────────────────────────────────────────────────────
637
+ def main():
638
+ parser = argparse.ArgumentParser(description="Analyze UQ scores for ABG-CoQA")
639
+ parser.add_argument("--uq", type=str, required=True,
640
+ help="UQ scores written by compute_uq.py")
641
+ parser.add_argument("--results", type=str, required=True,
642
+ help="Results file written by run_perturbation.py (for the labels)")
643
+ parser.add_argument("--rule_disagree", type=str, default=None,
644
+ help="Optional extra per-example scores to overlay: "
645
+ "JSON {example_id: {metric: score}}")
646
+ parser.add_argument("--output_dir", type=str, default="./uq_llm_analysis")
647
+ args = parser.parse_args()
648
+
649
+ os.makedirs(args.output_dir, exist_ok=True)
650
+
651
+ rule_scores, method_names, ordered_rules, label_map = load(
652
+ args.uq, args.results, rule_disagree_path=args.rule_disagree)
653
+
654
+ print("\nGenerating plots...")
655
+ plot_auroc_bars(rule_scores, method_names, ordered_rules, args.output_dir)
656
+ plot_roc_curves(rule_scores, method_names, ordered_rules, args.output_dir)
657
+ plot_mean_comparison(rule_scores, method_names, ordered_rules, args.output_dir)
658
+ plot_violins(rule_scores, method_names, ordered_rules, args.output_dir)
659
+ plot_effect_heatmap(rule_scores, method_names, ordered_rules, args.output_dir)
660
+ plot_auroc_heatmap(rule_scores, method_names, ordered_rules, args.output_dir)
661
+
662
+ print("\nComputing statistics...")
663
+ print_and_save_summary(rule_scores, method_names, ordered_rules, args.output_dir)
664
+
665
+ print("\nDone!")
666
+
667
+
668
+ if __name__ == "__main__":
669
+ main()