AQIT 0.1.0
Loading...
Searching...
No Matches
confidence_analysis.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""Standalone confidence pattern analysis over probe datasets (LLM)."""
3
4from __future__ import annotations
5
6from typing import Any
7
8import torch
9import torch.nn.functional as F
10
11from aquin.compute.calibration import ece_proxy
12from aquin.compute.sae_stats import _llm_probe_acts, load_probes
13
14_META_KEYS = ("id", "stressor", "lang", "quant_run_id", "condition", "label", "group")
15DEFAULT_THRESHOLD = 0.40
16_BASELINE_TAG = "baseline"
17
19def _prompt_confidence_metrics(model: Any, text: str) -> dict[str, Any]:
20 tokens = model.to_tokens(text[:512])
21 with torch.no_grad():
22 logits = model(tokens)
23 probs = F.softmax(logits[0].float(), dim=-1)
24 log_probs = F.log_softmax(logits[0].float(), dim=-1)
25 greedy = logits[0].argmax(dim=-1)
26 idx = torch.arange(len(greedy), device=greedy.device)
27 token_confs = probs[idx, greedy]
28 entropies = -(probs * log_probs).sum(dim=-1)
29 mean_conf = float(token_confs.mean().item())
30 last_dist = probs[-1]
31 return {
32 "mean_confidence": round(mean_conf, 4),
33 "max_prob": round(float(max(token_confs.max().item(), last_dist.max().item())), 4),
34 "entropy": round(float(entropies.mean().item()), 4),
35 "ece_proxy": round(abs(mean_conf - 0.5), 4),
36 "last_token_entropy": round(float(-(last_dist * last_dist.clamp(min=1e-10).log()).sum().item()), 4),
37 }
38
39
40def _llm_sae_join_row(model: Any, model_id: str, layer: int, text: str) -> dict[str, Any]:
41 from aquin.compute.feature_analysis import load_sae
42
43 try:
44 sae = load_sae(model_id, layer)
45 except Exception as exc:
46 return {"sae_available": False, "sae_error": str(exc)}
47 acts = _llm_probe_acts(model, sae, model_id, layer, text)
48 l0 = float((acts > 1e-6).sum().item())
49 top_idx = int(acts.argmax().item())
50 return {
51 "sae_available": True,
52 "sae_layer": layer,
53 "mean_l0": round(l0, 4),
54 "top_feature_idx": top_idx,
55 "top_feature_act": round(float(acts[top_idx].item()), 6),
56 }
57
58
59def _stressor_summary(rows: list[dict[str, Any]], *, baseline: str = _BASELINE_TAG) -> list[dict[str, Any]]:
60 groups: dict[str, list[dict[str, Any]]] = {}
61 for row in rows:
62 key = str(row.get("stressor") or row.get("label") or "unknown")
63 groups.setdefault(key, []).append(row)
64
65 base_rows = groups.get(baseline, [])
66 base_conf = (
67 sum(r["mean_confidence"] for r in base_rows) / len(base_rows)
68 if base_rows else None
69 )
70 base_ent = (
71 sum(r["entropy"] for r in base_rows) / len(base_rows)
72 if base_rows else None
73 )
74
75 out: list[dict[str, Any]] = []
76 for stressor, items in sorted(groups.items()):
77 confs = [r["mean_confidence"] for r in items]
78 ents = [r["entropy"] for r in items]
79 entry = {
80 "stressor": stressor,
81 "n_probes": len(items),
82 "mean_confidence": round(sum(confs) / len(confs), 4),
83 "mean_entropy": round(sum(ents) / len(ents), 4),
84 "ece_proxy": ece_proxy(confs),
85 "low_confidence_count": sum(1 for r in items if r.get("low_confidence")),
86 }
87 if base_conf is not None and stressor != baseline:
88 entry["confidence_delta"] = round(entry["mean_confidence"] - base_conf, 4)
89 if base_ent is not None and stressor != baseline:
90 entry["entropy_delta"] = round(entry["mean_entropy"] - base_ent, 4)
91 out.append(entry)
92 return out
93
94
96 *,
97 mode: str,
98 model_id: str,
99 probes: list[dict[str, Any]],
100 per_probe: list[dict[str, Any]],
101 threshold: float,
102 join_sae: bool,
103 sae_layer: int | None,
104) -> dict[str, Any]:
105 confs = [r["mean_confidence"] for r in per_probe]
106 stressor_summary = _stressor_summary(per_probe)
107 heatmap_rows = [s["stressor"] for s in stressor_summary]
108 heatmap_values = [
109 [s["mean_confidence"], s["mean_entropy"], s["ece_proxy"]]
110 for s in stressor_summary
111 ]
112
113 return {
114 "schema_version": 1,
115 "model_id": model_id,
116 "mode": mode,
117 "n_probes": len(per_probe),
118 "threshold": threshold,
119 "aggregate_ece_proxy": ece_proxy(confs),
120 "mean_confidence": round(sum(confs) / len(confs), 4) if confs else 0.0,
121 "low_confidence_count": sum(1 for r in per_probe if r["low_confidence"]),
122 "join_sae": join_sae,
123 "sae_layer": sae_layer if join_sae else None,
124 "probes": per_probe,
125 "stressor_summary": stressor_summary,
126 "heatmap": {
127 "metric": "confidence_patterns",
128 "rows": heatmap_rows,
129 "cols": ["mean_confidence", "mean_entropy", "ece_proxy"],
130 "values": heatmap_values,
131 },
132 }
133
134
136 model: Any,
137 model_id: str,
138 probes: list[dict[str, Any]],
139 *,
140 threshold: float = DEFAULT_THRESHOLD,
141 join_sae: bool = False,
142 sae_layer: int | None = None,
143) -> dict[str, Any]:
144 from aquin.compute.model_loader import get_sae_layer
145
146 resolved_layer = int(sae_layer if sae_layer is not None else get_sae_layer(model_id))
147
148 per_probe: list[dict[str, Any]] = []
149 for probe in probes:
150 text = probe["text"]
151 metrics = _prompt_confidence_metrics(model, text)
152 row: dict[str, Any] = {
153 "id": probe.get("id"),
154 "text": text,
155 **{k: probe[k] for k in _META_KEYS if k in probe and k != "id"},
156 **metrics,
157 "low_confidence": metrics["mean_confidence"] < threshold,
158 }
159 if join_sae:
160 row.update(_llm_sae_join_row(model, model_id, resolved_layer, text))
161 per_probe.append(row)
162
163 return _finalize_payload(
164 mode="llm",
165 model_id=model_id,
166 probes=probes,
167 per_probe=per_probe,
168 threshold=threshold,
169 join_sae=join_sae,
170 sae_layer=resolved_layer,
171 )
172
173
174def run_confidence_analysis_from_args(args: dict[str, Any]) -> dict[str, Any]:
175 from aquin.compute.model_loader import (
176 get_loaded_model,
177 load_model,
178 resolve_model_id,
179 )
180
181 model_id = args.get("model_id") or "llama-3.2-1b"
182 prompts_raw = args.get("prompts")
183 if prompts_raw is None or prompts_raw == "":
184 return {
185 "error": (
186 "Missing prompts. Pass a file path to JSON/JSONL, a JSON array of "
187 'strings, e.g. \'["The capital of France is", "2+2 equals"]\', or an '
188 "array of {text: ...} objects."
189 )
190 }
191
192 try:
193 probes = _coerce_probes(prompts_raw)
194 except (OSError, ValueError, TypeError) as exc:
195 return {"error": str(exc)}
196
197 join_sae = bool(args.get("join_sae") or args.get("join-sae"))
198 layer_raw = args.get("layer") or args.get("sae_layer")
199 sae_layer = int(layer_raw) if layer_raw is not None else None
200 threshold = float(args.get("threshold") or DEFAULT_THRESHOLD)
201
202 try:
203 model_id = resolve_model_id(str(model_id))
204 except ValueError as exc:
205 return {"error": str(exc)}
206
207 model = get_loaded_model()
208 if model is None:
209 try:
210 model = load_model(model_id)
211 except Exception as exc:
212 return {"error": str(exc)}
213
215 model,
216 model_id,
217 probes,
218 threshold=threshold,
219 join_sae=join_sae,
220 sae_layer=sae_layer,
221 )
222
223
224def _coerce_probes(prompts_raw: Any) -> list[dict[str, Any]]:
225 """Accept file path, JSON array string, list of strings, or list of probe objects."""
226 if isinstance(prompts_raw, list):
227 return _rows_to_probes(prompts_raw)
229 if isinstance(prompts_raw, dict):
230 if isinstance(prompts_raw.get("probes"), list):
231 return _rows_to_probes(prompts_raw["probes"])
232 text = prompts_raw.get("text") or prompts_raw.get("prompt")
233 if text:
234 return [{"id": "probe_0", "text": str(text)}]
235 raise ValueError("prompts object needs probes[] or text/prompt")
236
237 s = str(prompts_raw).strip()
238 if not s:
239 raise ValueError("prompts is empty")
240
241 # Inline JSON list / object
242 if s[0] in "[{":
243 import json
244
245 try:
246 parsed = json.loads(s)
247 except json.JSONDecodeError as exc:
248 raise ValueError(f"prompts looks like JSON but failed to parse: {exc}") from exc
249 return _coerce_probes(parsed)
250
251 # Single bare string that is not an existing path → treat as one probe
252 # Prefer file when path exists (legacy CLI).
253 from pathlib import Path
254
255 try:
256 return load_probes(s)
257 except (OSError, ValueError, FileNotFoundError):
258 p = Path(s).expanduser()
259 if p.is_file():
260 raise
261 # newline-separated mini list
262 lines = [ln.strip() for ln in s.splitlines() if ln.strip()]
263 if len(lines) > 1:
264 return _rows_to_probes(lines)
265 return [{"id": "probe_0", "text": s}]
266
267
268def _rows_to_probes(rows: list[Any]) -> list[dict[str, Any]]:
269 probes: list[dict[str, Any]] = []
270 for i, row in enumerate(rows):
271 if isinstance(row, str):
272 text = row.strip()
273 if text:
274 probes.append({"id": f"probe_{i}", "text": text})
275 continue
276 if not isinstance(row, dict):
277 raise ValueError(f"Probe row {i} must be string or object")
278 text = (
279 row.get("text")
280 or row.get("prompt")
281 or row.get("input")
282 or row.get("sentence")
283 )
284 if not text:
285 raise ValueError(f"Probe row {i} missing text/prompt")
286 out: dict[str, Any] = {"id": row.get("id") or f"probe_{i}", "text": str(text)}
287 for key in _META_KEYS:
288 if row.get(key) is not None:
289 out[key] = row[key]
290 probes.append(out)
291 if not probes:
292 raise ValueError("No probes after parsing prompts")
293 return probes
dict[str, Any] run_confidence_analysis(Any model, str model_id, list[dict[str, Any]] probes, *, float threshold=DEFAULT_THRESHOLD, bool join_sae=False, int|None sae_layer=None)
dict[str, Any] run_confidence_analysis_from_args(dict[str, Any] args)
list[dict[str, Any]] _coerce_probes(Any prompts_raw)
dict[str, Any] _prompt_confidence_metrics(Any model, str text)
list[dict[str, Any]] _stressor_summary(list[dict[str, Any]] rows, *, str baseline=_BASELINE_TAG)
dict[str, Any] _finalize_payload(*, str mode, str model_id, list[dict[str, Any]] probes, list[dict[str, Any]] per_probe, float threshold, bool join_sae, int|None sae_layer)
list[dict[str, Any]] _rows_to_probes(list[Any] rows)
dict[str, Any] _llm_sae_join_row(Any model, str model_id, int layer, str text)