From 061ce8a4f602d46b7e852604504b826ca08fda4d Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:24:13 +0800 Subject: [PATCH] Export MFQ2 sample logprobs for N bootstrap --- .../spec/20260630_authority_steer_pipeline.md | 7 + scripts/bootstrap_mfq2_sample_n.py | 125 ++++++++++++++++++ src/tinymfv/administer.py | 8 ++ src/tinymfv/read.py | 3 + 4 files changed, 143 insertions(+) create mode 100644 scripts/bootstrap_mfq2_sample_n.py diff --git a/docs/spec/20260630_authority_steer_pipeline.md b/docs/spec/20260630_authority_steer_pipeline.md index 6ebe8be..302c0d0 100644 --- a/docs/spec/20260630_authority_steer_pipeline.md +++ b/docs/spec/20260630_authority_steer_pipeline.md @@ -106,6 +106,13 @@ Out: - likely_fail: MFQ-2 noisy or opposite sign. - sneaky_fail: profile shift comes from loss of answer structure; catch with answer mass, survey contrast, and MFV margin. - UAT: one table lets the user decide whether the steer is good enough to show. +- [ ] T12 (R6): Estimate the minimum MFQ-2 `N` needed for stable steering plots. + - steps: measure bootstrapped MFQ-2 Authority path variability at subset sizes `N=1,2,4,8` from per-sample readouts, or add a per-sample export if the current aggregate output is insufficient. + - verify: table reports mean and bootstrap std/CI for Authority `C` deltas at each subset size and c row. + - success: choose the smallest `N` whose bootstrapped direction and effect size are stable enough for README plots. + - likely_fail: current `mfq2_profiles.csv` only has aggregate `C_sd`/`C_sem`, not the sample-level trajectories needed for subset bootstrapping. + - sneaky_fail: bootstrapping across items instead of think trajectories answers the wrong question; catch by inspecting the exported unit of resampling. + - UAT: committed table or CSV shows MFQ-2 subset stability, with enough detail to choose default `N`. - [x] T8 (R1-R5): Repair the pure-Authority data selection. - steps: inspect rendered strict25 examples; remove scenarios whose Authority affordance is actually Social Norms, legality, institutional controversy, or welfare/autonomy; rerun scenario validation with stricter source balance and example audit. - verify: selected examples file has direct respect/disregard for authority without Social Norms or dignity/care wording, and source counts are not dominated by ValueBench. diff --git a/scripts/bootstrap_mfq2_sample_n.py b/scripts/bootstrap_mfq2_sample_n.py new file mode 100644 index 0000000..4ad7b05 --- /dev/null +++ b/scripts/bootstrap_mfq2_sample_n.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import argparse +import csv +import json +import math +from collections import defaultdict +from pathlib import Path + +import numpy as np +from tabulate import tabulate + +from tinymfv.instrument import canonicalize_to_forward +from tinymfv.readouts import logit_contrast + + +def _rows(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def _logmeanexp(x: np.ndarray, axis: int) -> np.ndarray: + m = np.max(x, axis=axis, keepdims=True) + return np.squeeze(m, axis=axis) + np.log(np.mean(np.exp(x - m), axis=axis)) + + +def _profile_c(rows: list[dict], sample_idx_by_row: list[np.ndarray]) -> dict[str, float]: + by_id: dict[str, list[dict]] = defaultdict(list) + for row, sample_idx in zip(rows, sample_idx_by_row, strict=True): + sample_lp = np.asarray(row["sample_lp"], dtype=float) + lp = _logmeanexp(sample_lp[sample_idx], axis=0) + lp_fwd = canonicalize_to_forward(lp, row["framing"], "ordinal") + by_id[row["id"]].append({ + "foundation": row["foundation"], + "sign": int(row["sign"]), + "lp": lp_fwd, + "scale_max": int(row["scale_max"]), + }) + + by_foundation: dict[str, list[float]] = defaultdict(list) + for item_id, item_rows in by_id.items(): + assert len({r["foundation"] for r in item_rows}) == 1, item_id + assert len({r["sign"] for r in item_rows}) == 1, item_id + assert len({r["scale_max"] for r in item_rows}) == 1, item_id + lp_item = np.stack([r["lp"] for r in item_rows]).mean(axis=0) + sign = item_rows[0]["sign"] + c = logit_contrast(lp_item, item_rows[0]["scale_max"]) + by_foundation[item_rows[0]["foundation"]].append(-c if sign < 0 else c) + + return {foundation: float(np.mean(vals)) for foundation, vals in by_foundation.items()} + + +def _sample_idx(rows: list[dict], n: int, rng: np.random.Generator) -> list[np.ndarray]: + out = [] + for row in rows: + sample_count = len(row["sample_lp"]) + out.append(rng.choice(sample_count, size=n, replace=True)) + return out + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--run-dir", type=Path, required=True) + ap.add_argument("--instrument", default="mfq2") + ap.add_argument("--foundation", default="authority") + ap.add_argument("--subset-n", default="1,2,4,8") + ap.add_argument("--boot", type=int, default=1000) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--out", type=Path, required=True) + args = ap.parse_args() + + files = sorted(args.run_dir.glob(f"{args.instrument}_*_samples.jsonl")) + assert files, f"no {args.instrument}_*_samples.jsonl files in {args.run_dir}" + by_c = {float(_rows(path)[0]["c"]): _rows(path) for path in files} + assert 0.0 in by_c, sorted(by_c) + + sample_counts = { + len(row["sample_lp"]) + for rows in by_c.values() + for row in rows + } + assert len(sample_counts) == 1, sample_counts + max_n = next(iter(sample_counts)) + + subset_ns = [int(x) for x in args.subset_n.split(",")] + assert all(1 <= n <= max_n for n in subset_ns), (subset_ns, max_n) + + rng = np.random.default_rng(args.seed) + out_rows = [] + for n in subset_ns: + deltas_by_c: dict[float, list[float]] = {c: [] for c in sorted(by_c) if c != 0.0} + for _ in range(args.boot): + base = _profile_c(by_c[0.0], _sample_idx(by_c[0.0], n, rng))[args.foundation] + for c, rows in by_c.items(): + if c == 0.0: + continue + prof = _profile_c(rows, _sample_idx(rows, n, rng))[args.foundation] + deltas_by_c[c].append(prof - base) + for c, vals in deltas_by_c.items(): + arr = np.asarray(vals, dtype=float) + expected_sign = 1.0 if c > 0 else -1.0 + out_rows.append({ + "instrument": args.instrument, + "foundation": args.foundation, + "subset_n": n, + "max_n": max_n, + "c": c, + "mean_delta_C": float(arr.mean()), + "std_delta_C": float(arr.std(ddof=1)), + "ci95_lo": float(np.percentile(arr, 2.5)), + "ci95_hi": float(np.percentile(arr, 97.5)), + "signed_rate": float(np.mean(np.sign(arr) == expected_sign)), + "boot": args.boot, + }) + + args.out.parent.mkdir(parents=True, exist_ok=True) + with args.out.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=list(out_rows[0])) + writer.writeheader() + writer.writerows(out_rows) + + print(tabulate(out_rows, headers="keys", tablefmt="pipe", floatfmt="+.3f")) + + +if __name__ == "__main__": + main() diff --git a/src/tinymfv/administer.py b/src/tinymfv/administer.py index 1be6967..68178e3 100644 --- a/src/tinymfv/administer.py +++ b/src/tinymfv/administer.py @@ -42,7 +42,11 @@ class ItemFrameRow(TypedDict): id: str framing: str # forward | inverted | negated foundation: str + sign: int lp: list[float] # raw logprobs at the M scale tokens (presented orientation) + sample_lp: list[list[float]] # [N, scale_max] raw logprobs before BMA + sample_pmass_allowed: list[float] + sample_nll_prefill: list[float] E: float # forward-canonicalized E toward the original statement C: float # forward-canonicalized logit contrast keyed_E: float @@ -120,7 +124,11 @@ def administer(model, tok, instr: Instrument, *, batch_size: int = 36, by_dim_frame.setdefault((r["dimension"], r["frame"]), []).append((M + 1 - E) if sign < 0 else E) per_item_frame.append({ "id": r["id"], "framing": r["frame"], "foundation": r["dimension"], + "sign": sign, "lp": list(map(float, r["lp"])), + "sample_lp": r["sample_lp"], + "sample_pmass_allowed": r["sample_pmass_allowed"], + "sample_nll_prefill": r["sample_nll_prefill"], "E": E, "C": Cval, "keyed_E": (M + 1 - E) if sign < 0 else E, "keyed_C": -Cval if sign < 0 else Cval, diff --git a/src/tinymfv/read.py b/src/tinymfv/read.py index 20f3d65..6652417 100644 --- a/src/tinymfv/read.py +++ b/src/tinymfv/read.py @@ -117,6 +117,9 @@ def read_items(model, tok, instr: Instrument, items: list[InstrItem], answer_ids out.append({ "id": it.id, "frame": it.frame, "lp": lp, # raw logprobs at the M scale tokens + "sample_lp": sample_lp.tolist(), # [N,A] raw logprobs before BMA + "sample_pmass_allowed": [float(s["pmass_allowed"]) for s in sample_slots], + "sample_nll_prefill": [float(s["nll_prefill"]) for s in sample_slots], "p": p_norm, "pmass_allowed": pmass, "dimension": it.dimension, "sign": it.sign,