mirror of
https://github.com/wassname/moral-maps.git
synced 2026-08-20 12:30:40 +08:00
api: clarify eval readout names
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user