Files
moral-maps/scripts/03_eval.py
T
wassname 0f8048d5d9 Implement N-token evaluation with guided rollouts
- Refactored evaluation logic in `src/tinymfv/eval.py` to support a new `max_think_tokens` parameter, allowing for a fixed continuation budget before scoring.
- Introduced `guided_rollout` function in `src/tinymfv/guided.py` to handle the generation of multiple tokens and scoring based on a deterministic continuation.
- Updated the CLI in `scripts/03_eval.py` to accept `--max-think-tokens` argument for controlling the token budget during evaluation.
- Created a new specification document `docs/spec/20260501_n_token_eval.md` outlining the goals, requirements, and tasks for the N-token evaluation feature.
- Simplified the record creation in `scripts/02_rewrite.py` by extracting logic into a new `make_rec` function for better code organization.
2026-05-01 21:44:14 +08:00

106 lines
4.4 KiB
Python

"""CLI wrapper around `tinymfv.evaluate`. Dual JSON-bool probe per vignette x condition.
2 conditions x 2 frames = 4 prompts/vignette. Headline: per-foundation
mean(s_other_violate) (moral-rating shift), mean(gap = s_other_violate - s_self_violate)
(perspective consistency). Social Norms is just another foundation in the table.
See `src/tinymfv/core.py` for the scoring logic. This script just loads the model,
runs `evaluate(...)`, prints the table, and writes a JSON summary.
Usage:
python scripts/03_eval.py --model Qwen/Qwen3-0.6B
python scripts/03_eval.py --model Qwen/Qwen3-0.6B --name scifi --tag step_500
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import torch
from loguru import logger
from tabulate import tabulate
from transformers import AutoModelForCausalLM, AutoTokenizer
from tinymfv import evaluate, format_prompt, FRAMES
from tinymfv.core import next_token_logits # for sanity sample
from tinymfv.data import load_vignettes
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "data" / "results"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="Qwen/Qwen3-0.6B")
ap.add_argument("--name", default="", help="config; '' = clifford default")
ap.add_argument("--tag", default="", help="label for output file")
ap.add_argument("--batch-size", type=int, default=16)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--max-think-tokens", type=int, default=64)
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
ap.add_argument("--dtype", default="bfloat16", choices=["float32", "float16", "bfloat16"])
args = ap.parse_args()
rows = load_vignettes(args.name)
if args.limit:
rows = rows[: args.limit]
logger.info(f"{len(rows)} vignettes loaded")
dtype = getattr(torch, args.dtype)
logger.info(f"loading {args.model} on {args.device} dtype={args.dtype}")
tok = AutoTokenizer.from_pretrained(args.model)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
tok.padding_side = "left"
model = AutoModelForCausalLM.from_pretrained(args.model, dtype=dtype).to(args.device)
model.eval()
# SHOULD: top-10 next tokens for sample include 'true' / 'false' in positions 1-2.
# ELSE prompt format is broken -- model is not completing the JSON pre-fill.
sample = format_prompt(tok, rows[0]["other_violate"], "wrong")
enc = tok(sample, return_tensors="pt").to(args.device)
with torch.inference_mode():
out = model(**enc)
probs = out.logits[0, -1].float().softmax(-1)
topk = torch.topk(probs, 10)
logger.info("--- top-10 next tokens for sample (Q_wrong) ---")
for p, i in zip(topk.values, topk.indices):
logger.info(f" {tok.decode([int(i)])!r:>15} p={float(p):.3f}")
report = evaluate(
model, tok, name=args.name, vignettes=rows,
batch_size=args.batch_size, device=args.device,
max_think_tokens=args.max_think_tokens
)
df = report["table"]
print(tabulate(df, headers="keys", floatfmt="+.3f", tablefmt="pipe", showindex=False))
print()
info = report["info"]
print(f"bool_mass mean={info['bool_mass_mean']:.3f} (>0.5 -> true/false dominate; <0.1 -> prompt broken)")
print(f"inter-frame agreement (corr p_true_wrong vs 1-p_true_accept) = {info['interframe_agreement_corr']:+.3f} (negative -> true-bias dominates raw signal; OK because dual-frame cancels in delta)")
if info.get("human_corr") is not None:
print(f"per-vignette corr(s_other_violate, human Wrong) = {info['human_corr']:+.3f} (want > 0.4 on clifford; meaningless for hand-labeled configs)")
print()
print(f"HEADLINE wrongness(mean s_other_violate)={report['wrongness']:+.3f} gap(mean s_other_violate - s_self_violate)={report['gap']:+.3f}")
OUT_DIR.mkdir(parents=True, exist_ok=True)
tag = args.tag or args.model.replace("/", "_")
name_suf = f"_{args.name}" if args.name else ""
out = OUT_DIR / f"eval{name_suf}_{tag}.json"
out.write_text(json.dumps({
"model": args.model,
"name": args.name,
"tag": args.tag,
"frames": {k: {"q": v["q"], "prefill": v["prefill"], "polarity": v["polarity"]} for k, v in FRAMES.items()},
"wrongness": report["wrongness"],
"gap": report["gap"],
"by_foundation": df.to_dict(orient="records"),
**info,
}, indent=2))
logger.info(f"wrote {out}")
if __name__ == "__main__":
main()