Files
moral-maps/src/moralmaps/instrument.py
T
wassnameandClaudypoo 469788c418 rename package tinymfv -> moralmaps (repo -> moral-maps)
Import name tinymfv -> moralmaps, pip name tiny-mfv -> moral-maps, GitHub
URLs wassname/tinymfv -> wassname/moral-maps. HuggingFace dataset id
wassname/tiny-mfv left as-is (separate namespace, published data artifact).
Historical docs/spec/* and RESEARCH_JOURNAL keep their dated paths.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-09 10:41:49 +08:00

189 lines
11 KiB
Python

"""Instrument spec: one answer-token reader, many questionnaires (Option 3).
Every instrument is the SAME measurement: a softmax over an answer-token set at a prefilled
slot, with a think budget, debias, BMA over sampled traces, a pmass coherence check, and
temperature calibration to human soft-labels. Per-instrument variation is small:
1. answer_space : tokens gathered. Nominal = foundation words (forced-choice MFV);
ordinal = scale points ['1'..'M'] (MFQ-2, Big5, 16PF, HSQ).
2. reducer : per-item canonical distribution -> profile over `dimensions`.
Nominal -> mean choice frequency (the answer IS the dimension).
Ordinal -> E[scale point] grouped by item dimension (expectation over the
integer distribution, not argmax), with reverse-keying applied HERE only.
3. scaffold : prompt + assistant prefill that forces the answer slot.
4. human_label : per-item forward-orientation distribution over answer_space.
The frame reflection must NOT live in the reducer: that makes nominal and ordinal debias
asymmetric and risks double-flipping. Both kinds unify on one rule:
CANONICALIZE-AT-READER. Every frame's gathered distribution is mapped to ONE forward
orientation before anything else (`canonicalize_to_forward`): nominal reorder frames reindex
to canonical answer order (the reader already gathers in canonical order, so this is identity);
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 / 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).
- profile: the reducer. Keying (reverse-keyed items) is applied ONLY in reduce_ordinal,
because it is a pooling-into-factor correction, orthogonal to and composable with the
framing canonicalization -- not a double-correction.
`p` everywhere is renormalized over the allowed answer tokens (sums to 1); `pmass_allowed` is
the separate coherence check, never mixed into the distribution.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Literal
import numpy as np
Kind = Literal["nominal", "ordinal"]
ORDINAL_REFLECT_FRAMES = {"inverted", "negated"} # frames whose meaning is flipped vs forward
@dataclass
class InstrItem:
id: str
prompt: str # user-turn content (vignette, or task + statement)
dimension: str | None = None # ordinal: the factor; nominal: None (read from answer)
sign: int = 1 # ordinal keying: -1 = reverse-keyed (pool-reflect, profile only)
frame: str = "forward" # forward | inverted | negated (canonicalized before use)
human_label: np.ndarray | None = None # forward-orientation dist over answer_space, sums to 1
meta: dict = field(default_factory=dict)
@dataclass
class Instrument:
name: str
construct: str # map-layer tag: "salience" | "endorsement" | "wrongness" ...
kind: Kind
answer_space: list[str] # tokens gathered at the answer slot
dimensions: list[str] # profile axes for the map
items: list[InstrItem]
prefill: str # assistant prefill that forces the answer slot
schema_hint: str | None = None # instruction appended to the user turn
answer_to_dim: dict[str, str] | None = None # nominal only: answer token -> dimension
scale_max: int = 5 # ordinal: top of the Likert scale (== len(answer_space))
human_scale_max: int = 5 # human reference scale; HSQ humans on 1-7 (see caveat below)
display: str = "" # human-readable name for plot titles ("Big Five")
human_csv: str | None = None # per-country subscale means CSV (the map's cross-cultural cloud)
def __post_init__(self):
if self.kind == "ordinal":
assert self.answer_space == [str(i) for i in range(1, self.scale_max + 1)], (
f"ordinal answer_space must be ['1'..'{self.scale_max}'] IN ORDER -- reduce_ordinal "
f"weights by position (w = 1..scale_max), so a reordered space silently inverts E; "
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
# 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:
if self.kind == "ordinal" and self.human_scale_max != self.scale_max:
assert all(it.human_label is None for it in self.items), (
f"{self.name}: human_scale_max={self.human_scale_max} != scale_max={self.scale_max}; "
"per-item categorical calibration needs a common support. Project the human "
"histogram to the model scale before attaching human_label, or leave it None.")
# --- canonicalization: every frame -> one forward orientation, BEFORE metrics or reducer ---
def canonicalize_to_forward(p: np.ndarray, frame: str, kind: Kind) -> np.ndarray:
"""Map a presented-orientation answer distribution to forward orientation.
nominal: reader gathers in canonical answer order already -> identity (reorder frames are
averaged as aligned probability vectors upstream).
ordinal: `inverted` (scale legend reversed) and `negated` (content negated) flip meaning, so
reverse the vector: p_forward[d] = p_presented[M+1-d]. This is the distribution-level
form of agreement = M+1 - E. NB negation is not a guaranteed-exact semantic
complement (panel caveat); the frame-disagreement diagnostic surfaces items where it
breaks. `forward` -> identity.
"""
p = np.asarray(p, dtype=float)
if kind == "ordinal" and frame in ORDINAL_REFLECT_FRAMES:
return p[::-1].copy()
return p
def per_item_categorical(per_row: list[dict], kind: Kind) -> dict[str, dict]:
"""Collapse (item, frame) rows to one forward-orientation categorical per item id.
Each row has: id, frame, lp (raw logprobs at the M tokens), p (renormalized over answer_space),
pmass_allowed, dimension, sign, human_label. Returns {id: {lp, p, pmass, dimension, sign,
human_label, n_frames, frame_spread}} where p is the mean of canonicalized frame probability
vectors (kept for E + the human-comparison maps, preserving the NaN-at-collapse signal), lp is
the mean of canonicalized frame logprobs (the log-space primitive for the contrast C + log-odds;
averaging in log space is exact for the linear contrast), and frame_spread is the max L1 gap
between any two canonical probability frames (the acquiescence/negation diagnostic).
"""
by_id: dict[str, list[dict]] = defaultdict(list)
for r in per_row:
by_id[r["id"]].append(r)
# Each item collapses to ONE averaged distribution, so every item contributes EQUALLY to the
# factor mean. That matches pooling all (item, frame) rows only if every item has the same frame
# count. Assert it loudly rather than silently reweighting a factor if a future instrument gives
# some items fewer frames.
frame_counts = {len(rows) for rows in by_id.values()}
assert len(frame_counts) == 1, f"heterogeneous frame counts per item: {frame_counts}"
out: dict[str, dict] = {}
for iid, rows in by_id.items():
# Per-item rows differ only by frame; dimension + sign must agree, frames must be distinct.
# Else we would silently average incompatible distributions under rows[0]'s metadata.
assert len({r.get("dimension") for r in rows}) == 1, f"{iid}: inconsistent dimension across frames"
assert len({r.get("sign", 1) for r in rows}) == 1, f"{iid}: inconsistent sign across frames"
assert len({r["frame"] for r in rows}) == len(rows), f"{iid}: duplicate frame in rows"
canon = [canonicalize_to_forward(r["p"], r["frame"], kind) for r in rows]
canon_lp = [canonicalize_to_forward(r["lp"], r["frame"], kind) for r in rows]
C = np.stack(canon)
spread = float(max((np.abs(C[i] - C[j]).sum()
for i in range(len(C)) for j in range(i + 1, len(C))), default=0.0))
r0 = rows[0]
out[iid] = {
"lp": np.stack(canon_lp).mean(axis=0),
"p": C.mean(axis=0),
"pmass": float(np.mean([r["pmass_allowed"] for r in rows])),
"dimension": r0.get("dimension"),
"sign": r0.get("sign", 1),
"human_label": r0.get("human_label"),
"n_frames": len(rows),
"frame_spread": spread,
}
return out
# --- 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].
Keying lives here and ONLY here: a reverse-keyed item (sign<0) means agreeing with it scores
LOW on the factor, so reflect agreement = M+1 - E. The frame canonicalization already happened
in per_item_categorical, so this is the single, orthogonal keying step (no double-flip).
"""
w = np.arange(1, instr.scale_max + 1, dtype=float)
by_dim: dict[str, list[float]] = defaultdict(list)
for it in items.values():
assert it["dimension"] is not None, "ordinal item has dimension=None; it would pool into a phantom factor"
E = float((it["p"] * w).sum())
agr = (instr.scale_max + 1 - E) if it["sign"] < 0 else E
by_dim[it["dimension"]].append(agr)
return np.array([float(np.mean(by_dim[d])) for d in instr.dimensions])