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.
- input_ambiguity/__init__.py +10 -0
- input_ambiguity/__main__.py +3 -0
- input_ambiguity/blackbox/__init__.py +0 -0
- input_ambiguity/blackbox/analyze_uq.py +669 -0
- input_ambiguity/blackbox/compute_uq.py +400 -0
- input_ambiguity/blackbox/llm_utils.py +277 -0
- input_ambiguity/blackbox/perturbation_rules.py +848 -0
- input_ambiguity/blackbox/run_llm_perturbation.py +655 -0
- input_ambiguity/blackbox/run_paraphrase.py +351 -0
- input_ambiguity/blackbox/run_perturbation.py +353 -0
- input_ambiguity/cli.py +74 -0
- input_ambiguity/quickstart.py +53 -0
- input_ambiguity/quickstart_data/pipeline_b_baseline_n10.json +16569 -0
- input_ambiguity/quickstart_data/pipeline_b_logit_lens.json +14224 -0
- input_ambiguity/whitebox/__init__.py +0 -0
- input_ambiguity/whitebox/pipeline/__init__.py +0 -0
- input_ambiguity/whitebox/pipeline/compute_auroc.py +95 -0
- input_ambiguity/whitebox/pipeline/compute_uq_from_checkpoint.py +268 -0
- input_ambiguity/whitebox/pipeline/pipeline_b_perturb_and_uq.py +1258 -0
- input_ambiguity/whitebox/pipeline/pipeline_b_perturb_and_uq_vl.py +766 -0
- input_ambiguity/whitebox/text/__init__.py +0 -0
- input_ambiguity/whitebox/text/attribution_utils.py +330 -0
- input_ambiguity/whitebox/text/diffmask_uncertainty.py +755 -0
- input_ambiguity/whitebox/text/gradient_attribution.py +440 -0
- input_ambiguity/whitebox/text/logit_lens_uncertainty.py +605 -0
- input_ambiguity/whitebox/text/normalize_attributions.py +106 -0
- input_ambiguity/whitebox/vision/__init__.py +0 -0
- input_ambiguity/whitebox/vision/diffmask_uncertainty.py +318 -0
- input_ambiguity/whitebox/vision/gradient_attribution.py +252 -0
- input_ambiguity/whitebox/vision/logit_lens_uncertainty.py +367 -0
- input_ambiguity/whitebox/vision/vl_attribution_utils.py +342 -0
- input_ambiguity_detection-0.1.0.dist-info/METADATA +239 -0
- input_ambiguity_detection-0.1.0.dist-info/RECORD +37 -0
- input_ambiguity_detection-0.1.0.dist-info/WHEEL +5 -0
- input_ambiguity_detection-0.1.0.dist-info/entry_points.txt +2 -0
- input_ambiguity_detection-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"
|
|
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()
|