File size: 15,211 Bytes
e2c9d42 | 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 | # Implementation spec: SAPLMA, HaMI, LLM-Check, ICR Probe
All four are **implemented**. Every architecture detail
below was checked against the source paper or the authors' released code, not
recalled. Fidelity argumentation lives in `docs/baseline_fidelity_audit.md`
(Β§4 SAPLMA, Β§5 HaMI); this file is the build record and the remaining work.
## Decisions already made (do not relitigate)
1. **Pipeline:** all three go into the baseline runner
(`scripts/train_faithful_baselines_dgx.py`) as new `--detector` values, under
the same suite/protocol contract as EPR/Lookback/MVA. NOT scored ad-hoc from
the LatticeSpike signal cache. Protocol consistency is what makes the
fidelity argument defensible, and the same hidden-state extraction pass also
unblocks WEPR's missing causal number.
2. **HaMI keeps sequence-level supervision.** Learning token scores from
bag-level labels IS its contribution. This belongs in the "preserved" column
of the adaptation table, not "adapted".
3. **What we equalise is the evaluation** β same token set, same causal horizon,
same metrics β never the methods themselves.
4. **The default detector set is frozen.** `STRICT_DETECTORS` still resolves to
EPR/WEPR/Lookback/MVA-CRF, so a stored command that reproduced a published
run keeps reproducing it. Both new methods are opt-in via `--detector`.
## 1. SAPLMA β implemented
`baselines/saplma/`, detector name `saplma`.
Azaria & Mitchell, *"The Internal State of an LLM Knows When It's Lying"*,
**EMNLP Findings 2023**, [aclanthology.org/2023.findings-emnlp.68](https://aclanthology.org/2023.findings-emnlp.68/),
arXiv:2304.13734.
Independently validated: PARALLAX (2026) found SAPLMA one of only **two**
reliable internal-state probes out of 22 evaluated (0.911 AUROC on HaluBench),
with MIND, HaloScope, ACT, SEPs and HalluShift all near-chance (0.49-0.62).
**Verified from paper section 4, quoted:** "three hidden layers with decreasing
numbers of hidden units (256, 128, 64), all utilizing ReLU activations. The
final layer is a sigmoid output. We use the Adam optimizer. We do not fine-tune
any of these hyper-parameters for this task. The classifier is trained for 5
epochs."
**Correction to an earlier draft of this file:** SAPLMA does **not** average
activations across tokens. It reads the activations at the **last token of the
statement** β one 4096-vector per statement. The earlier note here said
"averages across all tokens" and was wrong.
**Layer:** the paper builds five probes (last, 28th, 24th, 20th, middle of 32)
and finds 20/32 best for OPT-6.7b, the **middle layer** best for LLaMA2-7b. We
probe the middle layer of a 16-layer model, matching the LLaMA finding.
**Unstated in the paper:** learning rate and batch size. The reference is Keras,
so its defaults (1e-3, 32) are used β the only reading consistent with the
paper's claim that nothing was tuned.
**Supervision:** `--saplma-supervision response` (default, faithful) fits one
row per answer β last-token activations against the response label β and
localises per token at scoring time, exactly as EPR is handled here.
`--saplma-supervision token` fits every labelled token and reports under
`saplma_token_supervised`, so the more generous configuration is on the table
rather than assumed away.
**NOTE:** `baselines/hidden_state_probe/` is **NOT** SAPLMA β it is logistic
regression on the last layer with `class_weight="balanced"`, correctly classed a
control in `fidelity_matrix.md`. Do not relabel it.
## 2. HaMI β implemented
`baselines/hami/`, detector name `hami`.
*"Robust Hallucination Detection in LLMs via Adaptive Token Selection"*,
**NeurIPS 2025**, [arXiv:2504.07863](https://arxiv.org/abs/2504.07863),
code [github.com/mala-lab/HaMI](https://github.com/mala-lab/HaMI).
**Architecture (`hami.py`):**
```
Linear(n_features, 256) -> BatchNorm1d -> ReLU -> Dropout(0.3) -> Linear(256, 1) -> Sigmoid
```
**Loss (`hami.py:MIL_loss`), transcribed exactly:**
```python
k = int(length * k_ratio) + 1 # k_ratio = 0.1
loss += (1. - s_topk_mean**2 + nor_max[i]**2) # index-matched bag pairs
smooth += torch.sum((s[:-1] - s[1:])**2) * 0.0008 # bags longer than 8 only
loss = loss / len(n_len_list) + smooth / len(n_len_list)
```
The abnormal term is index-matched against the normal term, which is why the
reference draws equal-sized batches from two loaders. Note `1. - m**2 + n**2`,
not `(1 - m)**2`.
**Training (`train.py`, `main.py`):** Adam lr 1e-3, weight decay 5e-4;
`ReduceLROnPlateau(mode='min', factor=0.1, patience=20, min_lr=1e-6)` stepped on
the training loss; batch 128 bags per polarity with `drop_last=True`; **100
optimizer steps, not 100 passes**; validation after every step, keeping the best
model; validation metric is bag-level AUROC where the bag score is the mean of
its top-k token scores (`validate.py`).
**Layer selection:** `main.py` loops over every layer, trains a separate model
for each, and records validation results per layer β the layer is a
validation-selected hyperparameter. We run one layer per invocation
(`--hidden-layer`), so a layer sweep is a sweep of runs.
**Two things the reference does that we cannot:**
- Its default `--mode se` scales hidden states by a probability from clustering
several sampled responses per question. Our dataset has one response per
prompt, so we use the reference's `ori` (unweighted) mode. HaMI is therefore
evaluated below its published capability, and that must be stated.
- Bag labels come from answer correctness (`acc`), with ambiguous `acc == 2.0`
bags dropped under `is_refined`. Ours: abnormal if the response contains any
hallucinated token, normal if none, uncertain-only responses dropped.
**Step budget caution.** 100 steps is tied to their dataset size. On our larger
training split it sees a fraction of the data. `--hami-steps` exists for this;
any run that raises it must say so in the results, and the reference's own
best-on-validation rule is what makes a longer budget defensible.
## 3. LLM-Check β implemented
`baselines/llm_check/`, detector names `token_logprob`,
`llm_check_logit_entropy`, `llm_check_window_entropy`, `llm_check_hidden`,
`llm_check_attention`.
Sriramanan et al., **NeurIPS 2024**, code
[GaurangSriramanan/LLM_Check_Hallucination_Detection](https://github.com/GaurangSriramanan/LLM_Check_Hallucination_Detection).
Five training-free scores from one forward pass, all verified against
`common_utils.py`:
```python
perplexity: log_softmax(logits)[t-1, tok[t]] # mean then exp
logit_entropy: softmax(topk(logits, 50)); (-p*log p).mean() # own row
window_entropy: softmax(logits); (-p*log p).mean() # own row, w=1
hidden: log(svdvals(Z.T @ J @ Z + 1e-3*I)).mean() # J centres features
attention: sum_h log(diag(A_h)) # over the answer block
```
Note `.mean()` not `.sum()` in both entropies, and note that the reference reads
the realized-token log-probability at row `t-1` but both entropies at row `t`.
Extraction stores rows `prompt_len-1 .. prompt_len+T-1` so index `t` is the
predicting row and index `t+1` is the own row; both alignments are then served
from one array.
**Three things that are easy to get wrong:**
1. **Orientation is fitted, not fixed.** The authors' notebook negates
`perplexity` and the attention score, negates neither entropy, and ships no
orientation for the hidden score. An earlier draft here hard-coded the sign
from the paper's prose and put both eigen scores at ~0.20 AUROC; the fitted
bit puts them at ~0.80. One bit per score, fitted on **train**.
2. **`centered_svd_val` centres over the FEATURE axis.** The reference passes
`(dim, n_tokens)` and builds a `dim x dim` centering matrix, so `Sigma` comes
out `n_tokens x n_tokens`. Easy to transpose by mistake.
3. **The attention diagonal changes meaning with the row.** Own row = the
token's self-attention (post-emission). Predicting row = attention to the
most recent prefix token (causal). Not the same quantity; say so.
Hidden score is pooled per token over the **expanding causal prefix**
(`--llm-check-hidden-window 0`, the default), which is the direct causal
restriction of the reference's whole-response score and adds no hyperparameter.
`token_logprob` is LLM-Check's per-token perplexity term. It gets its own row
because it is the floor every method must clear, but it is computed once, not
double-counted under two names.
## 4. ICR Probe β implemented
`baselines/icr_probe/`, detector name `icr_probe`.
Zhang et al., **ACL 2025 Main**, code
[XavierZhang2002/ICR_Probe](https://github.com/XavierZhang2002/ICR_Probe)
(`src/icr_score.py`, `src/icr_probe.py`, `src/utils.py`, `src/config.py`).
An earlier draft of this file said there was no code release. There is.
**ICR Score, per layer `a` and scored row `r`** (`compute_icr`):
```python
dx = H[a + 1][r] - H[a][r] # consecutive layers
targets = H[a][topk_idx] # the LOWER layer, easy to get wrong
w = (dx * targets).sum(1) / (targets.norm(1) + 1e-8)
ICR = js_divergence(w, attn_row[topk_idx]) # standardise -> softmax -> JSD
```
**Five details that a paper-only implementation gets wrong:**
1. **Attention is the softmaxed probability, not the pre-softmax score.**
Equation 3 defines `(Q.K)/sqrt(d)`; the code uses HF `output_attentions`.
2. **`top_p` silently overrides `top_k`.** `top_k = top_k if top_p is None else
int(top_p * N)`, and the README passes both, so `top_k=20` is dead code and
k is a *fraction* of the sequence.
3. **Projection targets come from the lower layer**, `origin_hidden_states[a]`,
not the layer whose update is being measured.
4. **The block mask drops response-to-prompt attention entirely**, keeping only
prompt-to-prompt and response-to-response.
5. **The induction-head fallback fires on a count test**, not a threshold: if
fewer than `n_heads // 8` heads pass, the verdict is discarded and the top
`n_heads // 8` by skewness are taken instead.
**Probe** (`src/utils.py`, `src/config.py`): `L -> 128 -> 64 -> 32 -> 1`, each
hidden layer BatchNorm1d + LeakyReLU(0.01) + Dropout(0.3), sigmoid; Kaiming
uniform `a=0.01`; BCE, Adam(1e-3, wd 1e-5), ReduceLROnPlateau(min, 0.5, 5), 100
epochs, batch 16, best validation loss.
**The featurisation is ours, unavoidably.** `_load_data` is referenced but never
defined in the release, and the README says the training code must be adapted.
Default `--icr-supervision response` mean-pools ICR over the answer's tokens and
fits the response label; scoring feeds each token's own layer-vector to the
fitted probe. `--icr-supervision token` is the token-supervised variant.
**Cost β the planning error worth remembering.** ICR needs consecutive-layer
hidden states *and* per-layer attention, which looked like it demanded a
12-24 GB multi-layer cache. It does not: the score reduces to one scalar per
(token, layer) at extraction time, so the cached feature is `(T, L)` β about
**15 MB for the whole dataset**. Always check what the feature reduces to before
budgeting for what the computation reads.
**The AWS L4 signal cache cannot serve ICR**, for three independent reasons:
it samples layers 4/8/12/16 so no consecutive difference exists; it stores no
attention, only the collapsed `attn_ent` scalar; and it covers answer prediction
rows only, while the projection needs all N tokens. Checked before spending
instance time.
## What was wired into the runner
- `DETECTOR_SAPLMA` / `DETECTOR_HAMI`, plus `HIDDEN_STATE_DETECTORS` so all
three hidden-state methods share one extraction path and one feature array.
- `--hidden-alignment {generated_token_position, generation_prediction_position}`.
The causal suite sets the predicting row automatically; native suites keep the
token's own row. Both new detectors are in `CAUSAL_CAPABLE_DETECTORS`.
- `hidden_sequence_training_example()` carries the whole answer's hidden states
plus **both** label granularities, so each detector picks the one its paper
uses. U rows stay in the matrix and are excluded by the detector, because both
methods index into the sequence (last token, adjacent-token smoothing) and
deleting rows would silently change what "last" and "adjacent" mean.
- `--saplma-supervision/-epochs/-lr/-batch-size`,
`--hami-steps/-batch-size/-k-ratio/-lr/-weight-decay`.
- Summary JSON gains a `hidden_state_detectors` block recording layer,
alignment, and each method's fitted-run facts (bag counts, selected step,
validation AUROC, training positive rate).
## Cross-cutting cautions from this codebase
- **Never add a key to a cache spec unconditionally.** The fingerprint is a hash
of the whole spec, so a new key invalidates every cache built before it
existed. This broke the 5.1 GB LatticeSpike base cache in r6 and cost a night.
`hidden_alignment` is therefore added to `feature_cache_spec` **only when
non-default**, and there is a test asserting its absence from legacy specs.
- **Never bump `EXTRACTION_CODE_VERSION`** unless extraction genuinely changes
for existing configurations; it invalidates every cache on every box.
- **Note that `selected_features` is already part of the fingerprint.** Running
`--detector saplma hami` will not reuse a cache built for the four-detector
set. Extract the two hidden-state methods in their own pass β they need no
attention output, so it is the cheap pass, not the expensive one.
- **Probe before long runs.** A 5-record extraction probe caught two shape bugs
in r6 that would each have surfaced hours in.
- **Verify the training path, not just extraction.** r6's failures were both in
training/cache-validation, which the extraction probe did not cover.
`tests/test_train_faithful_baselines_dgx.py` now fits and scores both new
detectors end to end on synthetic rows for exactly this reason.
- **Compare single-seed arms against the same seed's control.** Per-seed
validation AP for the current best LatticeSpike config: seed 42 = 0.2785,
seed 43 = 0.2632, seed 44 = 0.2633; 3-seed mean 0.2683 +/- 0.0088. Comparing a
seed-42 arm to the mean inflates it by ~0.010 and turned three nulls into
apparent passes in r5.
## Where things stand
- Current best documented in `ablations/BEST_MODEL.md`
(TEST AP 0.2741 +/- 0.0238, AUROC 0.9283 +/- 0.0040, 3 seeds).
- Baseline audit in `docs/baseline_fidelity_audit.md`; SAPLMA is Β§4, HaMI Β§5.
- Adaptation table rows for both are in `scripts/gen_results_tables.py`
(`tab:adaptation`). The fair results table has **no rows for them yet** β
add rows only once runs exist.
- Rounds archived in `ablations/r0`, `r1`, `r2`, `r3_5`, `r4`, `r5_stage1`,
`multilayer`, each with `ANALYSIS.md`.
- **r6b results are still unfetched and unarchived** (4 arms: lookback s42/s43,
head128 s43/s44) in `outputs/sweep_r6` on the 5090.
- WEPR still has no causal-matched number; the same hidden-state extraction pass
should fix that.
- Planned external evaluation: RAGTruth and Mu-SHROOM (team already working on
both). FAVA parked but `hallucination_labelling/span_align.py` already
supports it.
|