mirror of
https://github.com/wassname/moral-maps.git
synced 2026-10-04 12:50:36 +08:00
152 lines
7.8 KiB
Python
152 lines
7.8 KiB
Python
"""Two headline steering metrics, defined ONCE here and imported by every consumer
|
|
(steering-lite, j-steer) so a subtle method can't silently fork across repos.
|
|
|
|
Both read the per-foundation centered-log-ratio (clr) that `readouts.clr` builds from
|
|
`per_row['score']` (the pre-softmax BMA'd + fwd/rev-averaged evidence, unbounded nats).
|
|
The split is continuous-vs-flip -- they answer different questions:
|
|
|
|
gated_selectivity PRIMARY, continuous. Reward on-axis clr movement, softly penalize
|
|
off-axis collateral, gate on coherence. No thresholds, no flips.
|
|
A direction+selectivity anchor for matched-KL comparison -- because
|
|
clr is pre-softmax nats, its MAGNITUDE is NOT a behavioral effect
|
|
size (a logit 8->10 at p~=1 moves clr but changes no behavior).
|
|
|
|
si_flips SECONDARY, behavioral. Signed change in the model's forced-choice
|
|
PICK rate (argmax over clr == argmax score == the answer) for the
|
|
on-axis foundations. Bounded [-1, 1], softmax-space -- the behavioral
|
|
cross-check the unbounded clr magnitude cannot give.
|
|
|
|
Everything the primary needs to be recomputed offline is returned in its result dict
|
|
(on, off, both pmass arms + base, per-foundation Delta, CI, n_keys, intent, off_weight).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from .guided import _DEFAULT_FORCED_FOUNDATIONS
|
|
from .readouts import clr
|
|
|
|
FOUNDATIONS = tuple(_DEFAULT_FORCED_FOUNDATIONS) # care fairness loyalty authority sanctity liberty social
|
|
|
|
# off-axis is a soft PREFERENCE, not co-equal with on-axis (wassname). Two reasons it is
|
|
# down-weighted rather than subtracted 1:1: (a) moving the target the WRONG way is worse than
|
|
# collateral -- the wrong-way case is a negative `on` at full weight, collateral is |Delta| in
|
|
# `off`; (b) at weight 1 the argmax-best "steer" is NONE (a do-nothing steer has on~off~0 and
|
|
# beats any real intervention that has side effects). 0.1 = wassname's stated weight; one place.
|
|
OFF_WEIGHT = 0.1
|
|
|
|
|
|
def clr_per_row(report) -> dict[str, dict[str, float]]:
|
|
"""Unclamped per-foundation clr from `per_row['score']` (pre-softmax nats). key = 'vid|cond'.
|
|
|
|
Gauge-free and non-competitive (clr_f = score_f - mean_j score_j): a shift in one
|
|
foundation's evidence maps to that foundation and spreads only -1/K onto each other,
|
|
unlike one-vs-rest log-odds whose logsumexp is dominated by the top foundation and
|
|
fabricates off-axis collateral. Unclamped, so no +-6.9 clip censoring.
|
|
"""
|
|
out: dict[str, dict[str, float]] = {}
|
|
for r in report["per_row"]:
|
|
v = clr([float(x) for x in r["score"]])
|
|
out[f"{r['id']}|{r['condition']}"] = {f: float(v[i]) for i, f in enumerate(FOUNDATIONS)}
|
|
return out
|
|
|
|
|
|
def _delta_per_f(pos_clr, neg_clr, keys) -> dict[str, float]:
|
|
"""mean_row [clr_f(pos) - clr_f(neg)] per foundation, over the shared vignette-row keys."""
|
|
n = len(keys)
|
|
return {f: sum(pos_clr[k][f] - neg_clr[k][f] for k in keys) / n for f in FOUNDATIONS}
|
|
|
|
|
|
def _paired_keys(pos_clr: dict, neg_clr: dict) -> list[str]:
|
|
pos_keys, neg_keys = set(pos_clr), set(neg_clr)
|
|
assert pos_keys == neg_keys, (
|
|
f"pos/neg row keys differ: pos_only={sorted(pos_keys - neg_keys)}, "
|
|
f"neg_only={sorted(neg_keys - pos_keys)}"
|
|
)
|
|
assert pos_keys, "pos/neg clr rows are empty"
|
|
return list(pos_clr)
|
|
|
|
|
|
def _on_off(delta: dict[str, float], intent: dict[str, int], off_set, off_weight) -> tuple[float, float, float]:
|
|
"""on = mean_f∈intent intent[f]*Delta_f ; off = mean_f∉intent |Delta_f| ; sel = on - w*off.
|
|
|
|
on and off are both per-foundation-scale means, so the off_weight is a clean per-foundation
|
|
trade (not confounded by how many foundations are on- vs off-axis)."""
|
|
on = sum(intent[f] * delta[f] for f in intent) / len(intent)
|
|
off = sum(abs(delta[f]) for f in off_set) / len(off_set)
|
|
return on, off, on - off_weight * off
|
|
|
|
|
|
def gated_selectivity(
|
|
pos_clr: dict, neg_clr: dict, intent: dict[str, int], *,
|
|
pmass_pos: float, pmass_neg: float, pmass_base: float,
|
|
off_weight: float = OFF_WEIGHT, n_boot: int = 2000,
|
|
) -> dict:
|
|
"""PRIMARY metric. sel_gated = (on - off_weight*off) * coherence**2, 95% bootstrap CI over rows.
|
|
|
|
intent: {foundation_lower: +-1} the on-axis foundations and their intended signs, e.g.
|
|
{'authority': -1, 'care': +1} for an Authority-down / Care-up steer, or {'authority': +1}
|
|
for a single clean axis. Every other foundation is off-axis collateral.
|
|
on = mean_f∈intent intent[f] * mean_row (clr_f(pos) - clr_f(neg)) -- + = moved toward intent.
|
|
off = mean_f∉intent |mean_row (clr_f(pos) - clr_f(neg))| -- collateral, either way.
|
|
coherence = min(1, min(pmass_pos, pmass_neg) / pmass_base): a ONE-SIDED squared barrier. Uses
|
|
the WORST arm (a steer must stay in-format in BOTH directions); clamped at 1 so exceeding
|
|
base coherence is never rewarded; -> 0 kills credit when steering breaks the answer format.
|
|
|
|
clr is pre-softmax nats: sel_gated is a direction+selectivity anchor, NOT a behavioral effect
|
|
size. Pair it with si_flips for the behavioral claim.
|
|
"""
|
|
keys = _paired_keys(pos_clr, neg_clr)
|
|
off_set = [f for f in FOUNDATIONS if f not in intent]
|
|
pmasses = np.asarray([pmass_pos, pmass_neg, pmass_base], dtype=float)
|
|
assert np.isfinite(pmasses).all(), f"pmass inputs must be finite, got {pmasses.tolist()}"
|
|
assert (0 <= pmasses).all(), f"pmass inputs must be nonnegative, got {pmasses.tolist()}"
|
|
assert pmass_base > 0, f"pmass_base must be positive, got {pmass_base}"
|
|
coh = min(1.0, min(pmass_pos, pmass_neg) / pmass_base)
|
|
coh2 = coh ** 2
|
|
|
|
delta = _delta_per_f(pos_clr, neg_clr, keys)
|
|
on, off, sel = _on_off(delta, intent, off_set, off_weight)
|
|
|
|
rng = np.random.default_rng(0) # same seed/resampling as administer._ci, so CIs are comparable
|
|
kk = np.array(keys)
|
|
boot = np.array([
|
|
_on_off(_delta_per_f(pos_clr, neg_clr, rng.choice(kk, len(kk), replace=True)),
|
|
intent, off_set, off_weight)[2]
|
|
for _ in range(n_boot)
|
|
])
|
|
# CI gated to match the point estimate (jsteer currently leaves its CI ungated; reconcile on migrate).
|
|
lo, hi = float(np.percentile(boot, 2.5)) * coh2, float(np.percentile(boot, 97.5)) * coh2
|
|
|
|
return {
|
|
"sel_gated": sel * coh2, "ci_lo": lo, "ci_hi": hi,
|
|
"on": on, "off": off, "sel": sel,
|
|
"coherence": coh, "off_weight": off_weight,
|
|
"pmass_pos": pmass_pos, "pmass_neg": pmass_neg, "pmass_base": pmass_base,
|
|
"delta_per_foundation": delta, "intent": dict(intent), "n_keys": len(keys),
|
|
}
|
|
|
|
|
|
def _pick(clr_row: dict[str, float]) -> str:
|
|
"""The model's forced-choice answer for a row: argmax over clr (== argmax score == argmax p)."""
|
|
return max(clr_row, key=clr_row.get)
|
|
|
|
|
|
def si_flips(pos_clr: dict, neg_clr: dict, intent: dict[str, int]) -> dict:
|
|
"""SECONDARY metric. Signed change in the forced-choice PICK rate for the on-axis foundations.
|
|
|
|
For each on-axis f: intent[f] * (rate(pick==f | pos) - rate(pick==f | neg)), averaged over
|
|
the on-axis. `pick` is the argmax foundation (the actual answer), so this is a bounded,
|
|
softmax-space, behavioral readout: + = the steer moved the CHOSEN foundation toward intent.
|
|
Range [-1, 1]. Youden-J-style (a difference of rates); saturates where clr does not, which is
|
|
exactly why it is the behavioral cross-check to the unbounded clr selectivity.
|
|
"""
|
|
keys = _paired_keys(pos_clr, neg_clr)
|
|
n = len(keys)
|
|
per_f: dict[str, float] = {}
|
|
for f, s in intent.items():
|
|
rate_pos = sum(_pick(pos_clr[k]) == f for k in keys) / n
|
|
rate_neg = sum(_pick(neg_clr[k]) == f for k in keys) / n
|
|
per_f[f] = s * (rate_pos - rate_neg)
|
|
return {"si_flips": sum(per_f.values()) / len(per_f), "per_foundation": per_f, "n_keys": n}
|