api: clarify eval readout names

This commit is contained in:
wassname
2026-06-25 20:22:04 +08:00
parent 4b38de685e
commit 6594cc212d
9 changed files with 152 additions and 104 deletions
+14 -10
View File
@@ -38,12 +38,13 @@ label distribution). `evaluate(model, tok, ...)` runs a forced-choice probe per
condition) and returns a dict with:
- `profile`: mean `p[foundation]` across vignettes, on the same 7-way simplex as the human profile.
- `top1_acc`, `mean_js`, `mean_nll_T`: agreement vs the human label (None if the config is unlabeled).
- `mean_pmass_allowed`: the coherence canary -- mean probability mass on valid answer tokens at the
answer slot. It drops when the model refuses, rambles, or format-collapses, so a degenerate
intervention is visible independent of which answer it picks.
- `top1_acc`, `informedness`, `mean_nll_T`: agreement vs the human label (None if the config is unlabeled).
- `mean_pmass_allowed`: mean full-vocab probability mass on the allowed answer tokens at the answer
slot. This is answer-slot format coherence: it drops when the model wants to emit prose, refusal,
punctuation, or another out-of-space token, independent of which valid answer is top.
- `mean_nll_prefill`: mean NLL/token of the forced assistant prefill that leads into the answer slot.
- `per_row` (with `return_per_row=True`): the per-row 7-vec `p`, raw `score` (nats), `pmass_allowed`,
`top1`, `margin`. This is what the steering metrics below consume.
`nll_prefill`, `top1`, `margin`. This is what the steering metrics below consume.
To measure a steering intervention, run `evaluate` twice (base vs steered, same vignettes) and diff
the reports. The steering-lite package wraps this as `evaluate_with_vector(model, tok, vector=v)`,
@@ -83,7 +84,7 @@ more robust: use dlogit for effect size, SI for "did the steer do the intended s
interventions register in nats before changing an argmax.
- Position-bias control. Each row is scored twice (options forward and reversed) and the logprob
vectors averaged, cancelling option-order effects ([Pezeshkpour & Hruschka 2023](https://arxiv.org/abs/2308.11483)).
- A sliding think budget. `max_think_tokens` (0 / 64 dev default / 4096 / unbounded) is a knob you
- A sliding think budget. `max_think_tokens` (0 / 64 dev default / 4096 / unbounded) is a setting you
sweep: steering accrues over the think trace, so the same vector moves the profile more with more
think, up to the point (~512) where the model closes `</think>` on its own and the readout collapses.
- Two modes. dev (N=1, greedy, 64 think) is fast and granular, the default. full (N=4 sampled traces
@@ -91,10 +92,13 @@ more robust: use dlogit for effect size, SI for "did the steer do the intended s
## Instruments
The reader is answer-space-agnostic: it gathers logprobs over a set of answer tokens at a prefilled
slot (`src/tinymfv/instrument.py`). Forced-choice (nominal, the MFV default) reads a foundation
choice; Likert (ordinal) reads a 1..M scale point for MFQ-2 / Big-Five / 16PF / humor-styles (spec
and reducers landed; wiring through `evaluate()` is in progress).
The reader is answer-space-agnostic: it gathers logprobs over answer tokens at a prefilled slot
(`src/tinymfv/instrument.py`).
- Nominal instruments, the MFV vignettes, read a foundation category and reduce to mean category
probability.
- Ordinal instruments, MFQ-2 / Big-Five / 16PF / humor-styles, read a 1..M scale point and reduce to
keyed expected score `E`, logit contrast `C`, `logodds_agree`, entropy, and `pmass_allowed`.
## Scope
+9 -8
View File
@@ -4,7 +4,6 @@ Wraps `tinymfv.evaluate()`. Reports the AI-vs-label distribution match:
top1_acc argmax model == argmax label
mean_nll soft cross-entropy vs human distribution, nats
mean_nll_T same metric after one fitted temperature
mean_js legacy Jensen-Shannon (model || label), nats; max = ln 2
pearson[f] cross-vignette Pearson(model_p[f], label_p[f]) on
labeled rows (other_violate condition).
@@ -80,10 +79,13 @@ def main() -> None:
"condition": r["condition"],
"foundation_coarse": r["foundation_coarse"],
"p": {f: float(r["p"][i]) for i, f in enumerate(_DEFAULT_FORCED_FOUNDATIONS)},
"score": {f: float(r["score"][i]) for i, f in enumerate(_DEFAULT_FORCED_FOUNDATIONS)},
"label": (None if r["label"] is None
else {f: float(r["label"][i]) for i, f in enumerate(_DEFAULT_FORCED_FOUNDATIONS)}),
"top1": r["top1"],
"margin": float(r["margin"])
"margin": float(r["margin"]),
"pmass_allowed": float(r["pmass_allowed"]),
"nll_prefill": float(r["nll_prefill"]),
}
f.write(json.dumps(rec) + "\n")
logger.info(f"wrote {len(out['per_row'])} rows to {out_path}")
@@ -102,9 +104,8 @@ def main() -> None:
print(f" mean_nll_T = {out['mean_nll_T']} (temperature-scaled, nats)")
print(f" median_nll_T = {out['median_nll_T']} (temperature-scaled, nats)")
print(f" T = {out['T']}")
print(f" mean_js = {out['mean_js']} (max possible = ln 2 = 0.693)")
print(f" mean_pmass_allowed = {out['mean_pmass_allowed']} (valid-token mass)")
print(f" mean_nll_json = {out['mean_nll_json']} (assistant prefill, nats/tok)")
print(f" mean_nll_prefill = {out['mean_nll_prefill']} (assistant prefill, nats/tok)")
if out["profile"] is not None:
print("\n=== mean profile (human vs model) ===")
@@ -116,13 +117,13 @@ def main() -> None:
f"{np.median(p_top1):.3f} / {p_top1.mean():.3f} / {p_top1.max():.3f}")
print(" SHOULD: median > 0.4 (clear winner per row); <0.2 -> probe broken")
# JSON-prefill NLL degradation probe (teacher-forced on assistant prefill).
nll = np.array([float(r["nll_json"]) for r in out["per_row"]])
# Prefill NLL degradation probe (teacher-forced on assistant prefill).
nll = np.array([float(r["nll_prefill"]) for r in out["per_row"]])
nll = nll[np.isfinite(nll)]
if len(nll):
print(f"\n nll_json (nats/tok) min/median/mean/max: "
print(f"\n nll_prefill (nats/tok) min/median/mean/max: "
f"{nll.min():.3f} / {np.median(nll):.3f} / {nll.mean():.3f} / {nll.max():.3f}")
print(" SHOULD: stable across runs at fixed model; rises under steering/ablation -> JSON-prefill degradation")
print(" SHOULD: stable across runs at fixed model; rises under steering/ablation -> prefill degradation")
if __name__ == "__main__":
+14 -14
View File
@@ -1,10 +1,11 @@
"""tinymfv: tiny moral-foundations vignettes eval.
"""tinymfv: tiny moral/value instruments for local LLMs.
Forced-choice 7-way scoring on Clifford 2015 vignettes (classic) +
paraphrase configs (scifi, ai-actor). Default condition is
`other_violate` (the canonical Clifford framing); `self_violate` is
available as an opt-in ablation. Each row internally does a fwd + rev
enum-order pass for position-bias debias (inside guided_rollout).
One answer-token reader, two reducer families:
- nominal MFV vignettes: answer = foundation category; `evaluate` reports a
7-way profile plus label-match metrics.
- ordinal Likert questionnaires: answer = scale point; `administer` reports
E, C, agree-vs-disagree log-odds, entropy, and pmass diagnostics.
High-level usage:
@@ -16,17 +17,17 @@ High-level usage:
rep = evaluate(model, tok, name="classic")
print(rep["table"]) # per-foundation
print(rep["top1_acc"]) # argmax accuracy vs label
print(rep["mean_js"]) # JS divergence vs label dist (in nats)
print(rep["mean_nll_T"]) # temperature-scaled soft NLL vs label dist
Lower-level: see `guided_rollout_forced_choice` in `tinymfv.guided`.
"""
from .data import load_vignettes, load_all_vignettes, CONFIGS, ConfigName
from .eval import evaluate, CONDITIONS
from .eval import evaluate, CONDITIONS, EvalResult, EvalRow, EvalInfo
from .guided import guided_rollout_forced_choice, _DEFAULT_FORCED_FOUNDATIONS
from .instrument import Instrument, InstrItem, per_item_categorical
from .instrument import Instrument, InstrItem, per_item_categorical, reduce_nominal, reduce_ordinal
from .instruments import get as get_instrument, INSTRUMENTS, build_instrument
from .read import read_items, resolve_answer_ids, build_user_content
from .readouts import expected_score, logit_contrast, agree_logodds, entropy
from .readouts import expected_score, logit_contrast, logodds_agree, entropy
from .administer import administer
@@ -41,17 +42,16 @@ def __getattr__(name: str):
# The front door. Other symbols above stay importable (e.g. read_items for item subsets,
# per_item_categorical, build_instrument) but are plumbing, kept out of `import *`.
# per_item_categorical, build_instrument) but are helper internals, kept out of `import *`.
__all__ = [
# entrypoints
"evaluate", "administer", "get_instrument", "read_items",
# ordinal readouts (pure functions of the raw answer-token logprobs)
"expected_score", "logit_contrast", "agree_logodds", "entropy",
"expected_score", "logit_contrast", "logodds_agree", "entropy",
# types consumers build / subset
"Instrument", "InstrItem",
"Instrument", "InstrItem", "EvalResult", "EvalRow", "EvalInfo", "reduce_nominal", "reduce_ordinal",
# data API
"load_vignettes", "load_all_vignettes", "CONFIGS", "ConfigName", "CONDITIONS",
# lower-level rollout + lazy plotting
"guided_rollout_forced_choice", "maps",
]
+10 -10
View File
@@ -6,7 +6,7 @@ This is the survey counterpart to `tinymfv.evaluate` (the vignette forced-choice
per_item_categorical (canonicalize each frame to forward, average -> one dist per item)
reduce_ordinal (E[scale point] per item, reverse-key, pool to a per-factor profile)
The profile vector (per `instr.dimensions`) is the load-bearing output; `per_item_categorical`'s
The profile vector (per `instr.dimensions`) is the main output; `per_item_categorical`'s
canonicalization makes it algebraically identical to the experiment's `admin.administer` per-factor
means (verified by the reducer parity check), so this is a drop-in for the maps.
@@ -21,7 +21,7 @@ import numpy as np
from .instrument import Instrument, per_item_categorical, reduce_ordinal, canonicalize_to_forward
from .read import read_items, resolve_answer_ids
from .readouts import expected_score, logit_contrast, agree_logodds, entropy
from .readouts import expected_score, logit_contrast, logodds_agree, entropy
class ItemRow(TypedDict):
@@ -31,8 +31,8 @@ class ItemRow(TypedDict):
keyed_E: float # E reverse-keyed for sign<0 items (== old keyed_agreement)
C: float # rank-centered logit contrast (primary steer signal)
keyed_C: float # C reverse-keyed (negated) for sign<0 items
logodds: float # agree-vs-disagree log-odds (readable direction summary)
keyed_logodds: float
logodds_agree: float # agree-vs-disagree log-odds (readable direction summary)
keyed_logodds_agree: float
entropy: float # within-allowed entropy, nats (coherence the pmass gate misses)
pmass_allowed: float
frame_spread: float
@@ -60,7 +60,7 @@ class AdministerResult(TypedDict):
profile_C: np.ndarray # [len(dimensions)] per-factor keyed contrast (the steer map input)
profile: np.ndarray # alias of profile_E (kept so existing E maps keep working)
dimensions: list[str] # factor order, matches the profiles
foundations: list[dict] # one per factor: foundation, mean(E), C, logodds, sd, ci95*, f_<frame>
foundations: list[dict] # one per factor: foundation, mean(E), C, logodds_agree, sd, ci95*, f_<frame>
per_item: list[ItemRow] # one per item, frame-averaged readouts
per_item_frame: list[ItemFrameRow] # one per (item, frame): raw lp + think + readouts
mean_pmass_allowed: float # coherence check (mass on valid answer tokens)
@@ -88,16 +88,16 @@ def administer(model, tok, instr: Instrument, *, batch_size: int = 36,
# per-item frame-averaged readouts. E and entropy come from the averaged probability vector (so the
# NaN-at-collapse signal survives); C and log-odds come from the averaged logprobs (the sensitive
# log-space readouts). Reverse-keying (sign<0): E reflects to M+1-E, while the midpoint-centered
# contrast C and the log-odds simply negate (reflecting the scale negates a centered weight).
# contrast C and the agree-vs-disagree log-odds negate.
per_item_rows = []
for iid, it in items.items():
lp, p, sign = it["lp"], it["p"], it["sign"]
E, Cval, LO = expected_score(p, M), logit_contrast(lp, M), agree_logodds(lp, M)
E, Cval, LO = expected_score(p, M), logit_contrast(lp, M), logodds_agree(lp, M)
per_item_rows.append({
"id": iid, "foundation": it["dimension"],
"E": E, "keyed_E": (M + 1 - E) if sign < 0 else E,
"C": Cval, "keyed_C": -Cval if sign < 0 else Cval,
"logodds": LO, "keyed_logodds": -LO if sign < 0 else LO,
"logodds_agree": LO, "keyed_logodds_agree": -LO if sign < 0 else LO,
"entropy": entropy(p, M), "pmass_allowed": it["pmass"], "frame_spread": it["frame_spread"],
})
@@ -134,7 +134,7 @@ def administer(model, tok, instr: Instrument, *, batch_size: int = 36,
for j, d in enumerate(instr.dimensions):
e_vals = np.array([row["keyed_E"] for row in per_item_rows if row["foundation"] == d])
c_vals = np.array([row["keyed_C"] for row in per_item_rows if row["foundation"] == d])
lo_vals = np.array([row["keyed_logodds"] for row in per_item_rows if row["foundation"] == d])
lo_vals = np.array([row["keyed_logodds_agree"] for row in per_item_rows if row["foundation"] == d])
profile_C[j] = float(np.mean(c_vals))
e_lo, e_hi = _ci(e_vals); c_lo, c_hi = _ci(c_vals)
per_fr = {fr: float(np.mean(by_dim_frame[(d, fr)])) for fr in frames}
@@ -144,7 +144,7 @@ def administer(model, tok, instr: Instrument, *, batch_size: int = 36,
"ci95_lo": e_lo, "ci95_hi": e_hi,
"C": float(profile_C[j]), "C_sd": float(c_vals.std(ddof=1)),
"C_ci95_lo": c_lo, "C_ci95_hi": c_hi,
"logodds": float(np.mean(lo_vals)),
"logodds_agree": float(np.mean(lo_vals)),
"framing_spread": float(max(per_fr.values()) - min(per_fr.values())),
**{f"f_{fr}": v for fr, v in per_fr.items()},
})
+68 -38
View File
@@ -22,16 +22,13 @@ Headline metrics:
steering-lite's surgical informedness.
- mean_nll: mean soft cross-entropy -sum_f p_human[f] log p_model[f], in nats.
- mean_nll_T: same metric after fitting one temperature on the scored set.
- mean_js: legacy mean Jensen-Shannon divergence between model and label dist
(in nats, max = ln 2 ≈ 0.693).
- gap[f]: per-foundation perspective gap = mean p[f] (other_violate)
- mean p[f] (self_violate). Detects perspective bias.
"""
from __future__ import annotations
import json
import math
import time
from typing import Any
from typing import Any, NotRequired, TypedDict
import numpy as np
import pandas as pd
@@ -58,6 +55,56 @@ _PROBE_TO_COARSE: dict[str, str] = {
_COARSE_NORM = {"Social Norms": "SocialNorms"}
class EvalRow(TypedDict):
id: str
condition: str
foundation_coarse: str
p: np.ndarray # answer distribution, renormalized over the allowed answer space
score: np.ndarray # debiased pre-softmax answer evidence, nats
label: np.ndarray | None # human answer distribution in the same order as p
top1: str
margin: float # score[top1] - score[top2], nats
pmass_allowed: float # full-vocab mass on the allowed answer tokens at the answer slot
nll_prefill: float # NLL/token of the forced assistant prefill before the answer slot
think_tokens: list[int]
think_tokens_rev: list[int]
emitted_close: list[bool]
emitted_close_rev: list[bool]
gen_text: list[str]
gen_text_rev: list[str]
lp_fwd_samples: list[list[float]]
lp_rev_samples: list[list[float]]
class EvalInfo(TypedDict):
name: str
n_rows: int
n_labeled: int
elapsed_s: float
mean_nll: float | None
median_nll: float | None
median_nll_T: float | None
informedness: float | None
mean_pmass_allowed: float | None
mean_nll_prefill: float | None
class EvalResult(TypedDict):
table: pd.DataFrame
profile: pd.DataFrame | None
mean_nll: float | None
mean_nll_T: float | None
median_nll_T: float | None
T: float | None
top1_acc: float | None
informedness: float | None
mean_pmass_allowed: float | None
mean_nll_prefill: float | None
info: EvalInfo
demos: dict[str, Any] | None
per_row: NotRequired[list[EvalRow]]
def _label_dist(row: dict, foundations: list[str]) -> np.ndarray | None:
"""Build the 7-vec human label distribution for a vignette.
@@ -78,16 +125,6 @@ def _label_dist(row: dict, foundations: list[str]) -> np.ndarray | None:
return arr / s
def _js_divergence(p: np.ndarray, q: np.ndarray) -> float:
"""Jensen-Shannon divergence in nats. Symmetric, bounded by ln 2."""
p = p + 1e-12; q = q + 1e-12
p = p / p.sum(); q = q / q.sum()
m = 0.5 * (p + q)
kl_pm = float((p * np.log(p / m)).sum())
kl_qm = float((q * np.log(q / m)).sum())
return 0.5 * kl_pm + 0.5 * kl_qm
def _soft_nll(p_human: np.ndarray, p_model: np.ndarray) -> float:
"""Soft cross-entropy: -sum_f p_human[f] log p_model[f], in nats.
@@ -178,7 +215,7 @@ def evaluate(
device: str | None = None,
return_per_row: bool = False,
verbose: int = 1,
) -> dict[str, Any]:
) -> EvalResult:
"""Run forced-choice 7-way probe per (vignette, condition).
Args:
@@ -212,11 +249,11 @@ def evaluate(
table, and the complete DEMO B (prompt + generation + SHOULD note).
Returns:
Dict with `table`, `profile`, `mean_js`, `mean_nll`, `mean_nll_T`,
`median_nll_T`, `T`, `top1_acc`, `mean_pmass_allowed`, `mean_nll_json`, and `info`.
Dict with `table`, `profile`, `mean_nll`, `mean_nll_T`,
`median_nll_T`, `T`, `top1_acc`, `mean_pmass_allowed`, `mean_nll_prefill`, and `info`.
With `return_per_row=True`, also includes `per_row` with per-row
`p`, `score` (debiased logp per foundation), `pmass_allowed`,
`nll_json`, `gen_text` / `gen_text_rev` (full decoded gen, no stripping),
`nll_prefill`, `gen_text` / `gen_text_rev` (full decoded gen, no stripping),
and `top1` / `margin`.
"""
if vignettes is None:
@@ -233,7 +270,7 @@ def evaluate(
foundations = list(_DEFAULT_FORCED_FOUNDATIONS)
t0 = time.time()
per_row: list[dict] = []
per_row: list[EvalRow] = []
total_calls = len(vignettes) * len(conditions)
with tqdm(total=total_calls, desc=f"forced-choice {name}", mininterval=60, maxinterval=120) as pbar:
for cond in conditions:
@@ -265,7 +302,7 @@ def evaluate(
"top1": res.top1,
"margin": res.margin,
"pmass_allowed": res.pmass_allowed,
"nll_json": res.nll_json,
"nll_prefill": res.nll_prefill,
"think_tokens": res.think_tokens, # list[int], length N
"think_tokens_rev": res.think_tokens_rev, # list[int], length N
"emitted_close": res.emitted_close, # list[bool], length N
@@ -332,9 +369,6 @@ def evaluate(
# === headline scalars (need labels) ===
labeled_rows = [r for r in per_row if r["label"] is not None]
if labeled_rows:
js_vals = np.array([_js_divergence(r["p"], r["label"]) for r in labeled_rows])
mean_js = float(js_vals.mean())
median_js = float(np.median(js_vals))
y_pred = np.array([np.argmax(r["p"]) for r in labeled_rows])
y_true = np.array([np.argmax(r["label"]) for r in labeled_rows])
top1_acc = float(np.mean(y_pred == y_true))
@@ -371,7 +405,7 @@ def evaluate(
"model_T": p_scaled.mean(axis=0),
})
else:
mean_js = median_js = top1_acc = informedness = None
top1_acc = informedness = None
mean_nll = median_nll = mean_nll_T = median_nll_T = None
T = None
profile = None
@@ -380,8 +414,8 @@ def evaluate(
float(np.mean([r["pmass_allowed"] for r in per_row]))
if per_row else None
)
mean_nll_json = (
float(np.mean([r["nll_json"] for r in per_row]))
mean_nll_prefill = (
float(np.mean([r["nll_prefill"] for r in per_row]))
if per_row else None
)
@@ -394,7 +428,7 @@ def evaluate(
r0 = per_row[0]
# one-line quantitative readout, kept at every verbose level (it IS the signal)
aux = {k: (round(v, 4) if isinstance(v, float) else v) for k, v in {
"top1_acc": top1_acc, "mean_js": mean_js, "mean_nll_T": mean_nll_T,
"top1_acc": top1_acc, "mean_nll_T": mean_nll_T,
"T": T, "informedness": informedness, "mean_pmass_allowed": mean_pmass_allowed,
}.items() if v is not None}
logger.debug("aux stats: " + json.dumps(aux))
@@ -407,7 +441,7 @@ def evaluate(
"SHOULD: mass concentrates on the violated foundation; if it is flat or "
"pmass_allowed~0 the model did not answer in-format and the row is noise.\n"
+ " ".join(f"{f}={p:.3f}" for f, p in zip(foundations, r0["p"]))
+ f"\n top1={r0['top1']} pmass_allowed={r0['pmass_allowed']:.3f} nll_json={r0['nll_json']:.3f}"
+ f"\n top1={r0['top1']} pmass_allowed={r0['pmass_allowed']:.3f} nll_prefill={r0['nll_prefill']:.3f}"
)
if profile is not None:
logger.debug(
@@ -432,8 +466,8 @@ def evaluate(
f"{demo_prompt}{demo_gen}\n"
"SHOULD: a real chain-of-thought that ends in a moral-foundation choice. "
"If it is empty or degenerate the model is not reasoning at this budget; "
"if it answers a different foundation than DEMO A's top1, the readout and "
"free reasoning disagree (worth noting).\n--- end DEMO B ---\n"
"if it answers a different foundation than DEMO A's top1, inspect that row.\n"
"--- end DEMO B ---\n"
)
else: # terse default: generation only, whitespace-collapsed to 64 chars, bracketed
gen64 = " ".join(demo_gen.split())[:64]
@@ -451,8 +485,6 @@ def evaluate(
"n_rows": n_rows,
"n_labeled": n_labeled,
"elapsed_s": elapsed,
"median_js": median_js,
"max_js": math.log(2),
"mean_nll": mean_nll,
"median_nll": median_nll,
"median_nll_T": median_nll_T,
@@ -468,14 +500,13 @@ def evaluate(
# "in-format"; a sharp drop after steering signals coherence loss.
"mean_pmass_allowed": mean_pmass_allowed,
# Mean NLL in nats/token over the assistant prefill content. Perplexity
# is exp(mean_nll_json).
"mean_nll_json": mean_nll_json,
# is exp(mean_nll_prefill).
"mean_nll_prefill": mean_nll_prefill,
}
out: dict[str, Any] = {
out: EvalResult = {
"table": table,
"profile": profile, # 7-row DataFrame: foundation, human, model, model_T
"mean_js": mean_js,
"mean_nll": mean_nll,
"mean_nll_T": mean_nll_T, # temperature-scaled soft cross-entropy in nats
"median_nll_T": median_nll_T,
@@ -483,11 +514,10 @@ def evaluate(
"top1_acc": top1_acc,
"informedness": informedness, # macro Youden's J, model vs human argmax, in [-1, 1]
"mean_pmass_allowed": mean_pmass_allowed,
"mean_nll_json": mean_nll_json,
"mean_nll_prefill": mean_nll_prefill,
"info": info,
"demos": demos, # DEMO A (forced think + top1) + DEMO B (free reasoning); None if not verbose
}
if return_per_row:
out["per_row"] = per_row
return out
+16 -17
View File
@@ -108,7 +108,7 @@ def _rollout_natural_or_forced(
callers reshape via `[i*N + n]`.
thinks[j] = (gen_text, n_think_tokens, emitted_close).
slots[j][k] = {pmass_allowed, nll_json, top5_str, lp_gather}.
slots[j][k] = {pmass_allowed, nll_prefill, top5_str, lp_gather}.
Phase 1: batched generate, `min_new_tokens=max_new_tokens=max_think_tokens`
→ uniform-length cache. Capture `scores` (per-step logits) and `pkv`.
@@ -236,12 +236,12 @@ def _rollout_natural_or_forced(
first_logp = F.log_softmax(prefix_out.logits[:, -1].float(), dim=-1) # [B, V]
first_nll = -first_logp.gather(1, prefill_t[:, :1]).squeeze(-1) # [B]
if J == 1:
forced_nll_json = first_nll
forced_nll_prefill = first_nll
else:
next_logp = F.log_softmax(prefill_out.logits[:, :-1].float(), dim=-1) # [B, J-1, V]
next_ids = prefill_t[:, 1:].unsqueeze(-1) # [B, J-1, 1]
tail_nll = -next_logp.gather(2, next_ids).squeeze(-1).sum(dim=1) # [B]
forced_nll_json = (first_nll + tail_nll) / J
forced_nll_prefill = (first_nll + tail_nll) / J
if verbose:
real0 = phase1_ids[0][phase1_ids[0] != pad_id]
@@ -282,7 +282,7 @@ def _rollout_natural_or_forced(
if not torch.isfinite(raw).all():
slots[i].append({
"pmass_allowed": 0.0,
"nll_json": float("nan"),
"nll_prefill": float("nan"),
"top5_str": "",
"lp_gather": [float("nan")] * len(gather_token_ids),
})
@@ -297,18 +297,18 @@ def _rollout_natural_or_forced(
elif not emitted_close_i:
# Case (b) interrupted: forced
lp_vec = forced_lp_last[i]
nll_val = float(forced_nll_json[i].item())
nll_val = float(forced_nll_prefill[i].item())
else:
# Case (c) emitted </think> but no natural answer slot found.
# Model "finished thinking" without producing JSON — coherence
# collapse at the answer slot. pmass=0.0 is the honest measurement
# (no probability mass on allowed tokens at a non-existent slot)
# and lets c_scan see the failure as a real signal rather than
# crashing on NaN. nll_json stays NaN (genuinely undefined: no
# JSON tokens were emitted to score).
# crashing on NaN. nll_prefill stays NaN (genuinely undefined:
# no prefill tokens were emitted to score).
slots[i].append({
"pmass_allowed": 0.0,
"nll_json": float("nan"),
"nll_prefill": float("nan"),
"top5_str": "",
"lp_gather": [float("nan")] * len(gather_token_ids),
})
@@ -321,7 +321,7 @@ def _rollout_natural_or_forced(
)
slots[i].append({
"pmass_allowed": float(lp_vec[gid_t].exp().sum().item()),
"nll_json": nll_val,
"nll_prefill": nll_val,
"top5_str": top5_str,
"lp_gather": lp_vec[gid_t].cpu().tolist(),
})
@@ -442,8 +442,8 @@ class ForcedChoiceResult:
pmass_allowed: float
# Mean negative log-likelihood in nats/token over the assistant prefill
# content, averaged across samples and fwd + rev framings. Perplexity is
# `exp(nll_json)`.
nll_json: float
# `exp(nll_prefill)`.
nll_prefill: float
def _resolve_first_token_ids(tok, words: list[str]) -> tuple[list[int], dict[str, int]]:
@@ -599,14 +599,14 @@ def guided_rollout_forced_choice(
order_sorted = sorted(range(K), key=lambda k: -score[k])
top1 = foundations[order_sorted[0]]
margin = score[order_sorted[0]] - score[order_sorted[1]]
# Average pmass_allowed and nll_json across N samples per direction, then across
# Average pmass_allowed and nll_prefill across N samples per direction, then across
# fwd + rev framings.
pm_f = sum(slots_fwd[j][0]["pmass_allowed"] for j in idx) / N
pm_r = sum(slots_rev[j][0]["pmass_allowed"] for j in idx) / N
pm = 0.5 * (pm_f + pm_r)
nll_f = sum(slots_fwd[j][0]["nll_json"] for j in idx) / N
nll_r = sum(slots_rev[j][0]["nll_json"] for j in idx) / N
nll_json = 0.5 * (nll_f + nll_r)
nll_f = sum(slots_fwd[j][0]["nll_prefill"] for j in idx) / N
nll_r = sum(slots_rev[j][0]["nll_prefill"] for j in idx) / N
nll_prefill = 0.5 * (nll_f + nll_r)
results.append(ForcedChoiceResult(
user_prompt=user_prompts[i],
gen_text=gens_fwd,
@@ -624,7 +624,7 @@ def guided_rollout_forced_choice(
emitted_close=close_fwd_list,
emitted_close_rev=close_rev_list,
pmass_allowed=float(pm),
nll_json=float(nll_json),
nll_prefill=float(nll_prefill),
))
return results
@@ -665,4 +665,3 @@ def free_generation_demo(
out = model.generate(**enc, **gen_kwargs)
gen_text = tok.decode(out[0, enc.input_ids.shape[1]:], skip_special_tokens=False)
return prompt_text, gen_text
+17 -3
View File
@@ -22,7 +22,7 @@ asymmetric and risks double-flipping. Both kinds unify on one rule:
ordinal `inverted`/`negated` frames reverse the probability vector (the distribution-level
analog of agreement = M+1 - E). The per-item object is then the mean of these canonical
distributions over frames -- a single forward-orientation categorical, used IDENTICALLY for:
- metrics: soft-NLL / JS / temperature T vs the forward human histogram (both kinds), plus
- metrics: soft-NLL / temperature T vs the forward human histogram (both kinds), plus
an ORDINAL metric (mean |E_model - E_human|) so the certifying metric is sensitive to the
expectation the profile actually uses; top1 / informedness only for nominal (an argmax
flip metric is meaningless on an ordered scale: 4-vs-5 != 1-vs-5).
@@ -79,7 +79,7 @@ class Instrument:
f"got {self.answer_space}")
if self.kind == "nominal" and self.answer_to_dim is None:
self.answer_to_dim = {a: a for a in self.answer_space}
# Cross-scale caveat: a 1-7 human histogram (HSQ) cannot share a 5-way soft-NLL/JS
# Cross-scale caveat: a 1-7 human histogram (HSQ) cannot share a 5-way soft-NLL
# with a 1-5 model directly. Calibration for such
# instruments must project both to a common support (or report 0-1 endorsement only).
# Enforced loudly rather than silently mis-comparing:
@@ -155,7 +155,21 @@ def per_item_categorical(per_row: list[dict], kind: Kind) -> dict[str, dict]:
return out
# --- profile reducer: the ordinal kind-specific summary (nominal evaluate folds its profile inline) ---
# --- profile reducers: same per-item categorical, different profile summary ---
def reduce_nominal(items: dict[str, dict], instr: Instrument) -> np.ndarray:
"""Nominal profile = mean category probability per dimension.
For MFV, the answer is the foundation. The reducer maps answer tokens into profile dimensions
and averages the canonical per-item categorical distributions.
"""
assert instr.kind == "nominal", "reduce_nominal expects a nominal instrument"
assert instr.answer_to_dim is not None, f"{instr.name}: nominal instrument needs answer_to_dim"
by_dim: dict[str, list[float]] = {d: [] for d in instr.dimensions}
for it in items.values():
for answer, p in zip(instr.answer_space, it["p"]):
by_dim[instr.answer_to_dim[answer]].append(float(p))
return np.array([float(np.mean(by_dim[d])) for d in instr.dimensions])
def reduce_ordinal(items: dict[str, dict], instr: Instrument) -> np.ndarray:
"""Likert profile = mean keyed agreement per dimension; agreement = E[scale point].
+2 -2
View File
@@ -42,10 +42,10 @@ _SPECS = {
}
def _load_keying(survey_dir: Path, fallback_ids: list[str]) -> dict[str, int]:
def _load_keying(survey_dir: Path, default_forward_ids: list[str]) -> dict[str, int]:
p = survey_dir / "keying.json"
if not p.exists():
return {i: 1 for i in fallback_ids} # MFQ-2: no file -> every item +1
return {i: 1 for i in default_forward_ids} # MFQ-2: every item is +1
return {str(k): int(v) for k, v in json.loads(p.read_text()).items()}
+2 -2
View File
@@ -5,7 +5,7 @@ matters because the summaries answer different questions and have very different
expected_score E = sum_k k * p_k, in [1, M] human-comparable scale score
logit_contrast C = sum_k (k - mid) * lp_k primary steer signal (sensitive, signed)
agree_logodds LO = lse(top) - lse(bottom) readable 2-bin direction summary
logodds_agree LO = lse(top) - lse(bottom) readable 2-bin direction summary
entropy H = -sum_k p_k log p_k within-allowed coherence (uniform = ln M)
Why E hides steering and C/LO do not (the whole reason this module exists):
@@ -51,7 +51,7 @@ def logit_contrast(lp: np.ndarray, scale_max: int) -> float:
return float((np.asarray(lp, dtype=float) * w).sum())
def agree_logodds(lp: np.ndarray, scale_max: int) -> float:
def logodds_agree(lp: np.ndarray, scale_max: int) -> float:
"""LO = logsumexp(top n_side) - logsumexp(bottom n_side), n_side = scale_max // 2.
The readable 2-bin direction summary: nats in favor of agreeing over disagreeing among the