mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-09 11:27:22 +08:00
Rewrite README around profile plots
This commit is contained in:
@@ -4,16 +4,18 @@ Consumes a steering-lite `run_allinstr_showcase.py` output dir (one calibrated
|
||||
activation-steering vector administered across every instrument over a signed
|
||||
c-sweep) and renders the SAME two figures for every instrument, uniformly:
|
||||
|
||||
- map : ipsative culture map (PCA), AI base + steer trajectory vs the human cloud.
|
||||
- range: per-factor range, AI base dot + +c/-c arrows vs the human society strip.
|
||||
- map : ipsative culture map (PCA), AI base + strongest coherent +/-c vs the human cloud.
|
||||
- range: per-factor range, AI base + coherent +/-c path vs the human society strip.
|
||||
|
||||
Ordinal instruments (mfq2/big5/16pf/humor_styles) read <name>_profiles.csv; nominal
|
||||
MFV reads mfv.json and is projected into z-scored relative-emphasis space (its
|
||||
logit-violation units cannot share a raw axis with 1-5 wrongness), but it goes
|
||||
through the same plot_ipsative_pca / plot_range and yields the same two figures.
|
||||
|
||||
cs are SIGNED multipliers of the calibrated coefficient C (0 = base); the real C
|
||||
is in the title, the legend shows only the multiplier (c=+1, c=-2, ...).
|
||||
cs are SIGNED multipliers of the calibrated coefficient C (0 = base). The public
|
||||
README range plots show the coherent path: c=0 plus each +/-c row whose pmass stays
|
||||
above the requested fraction of base. Maps show only the strongest coherent endpoints.
|
||||
Incoherent rows are dropped, not drawn hollow.
|
||||
|
||||
uv run python scripts/plot_steer_showcase.py \
|
||||
--run-dir ../steering-lite/outputs/allinstr_qwen35_4b --out docs/img/showcase
|
||||
@@ -110,21 +112,37 @@ def read_profiles(run_dir: Path, name: str, dims: list[str], value_col: str = "m
|
||||
return {c: np.array([d[f] for f in dims]) for c, d in by_c.items()}, pmass
|
||||
|
||||
|
||||
def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float, *, show_sweep: bool = False) -> list[Path]:
|
||||
def coherent_prefix_cs(cs: list[float], pmass: dict[float, float], coherence_frac: float) -> list[float]:
|
||||
"""c=0 plus each signed arm until answer mass first falls below the base-relative floor."""
|
||||
base_pm = pmass[0.0]
|
||||
kept = [0.0]
|
||||
for side in (1.0, -1.0):
|
||||
for c in sorted([c for c in cs if np.sign(c) == side], key=abs):
|
||||
if pmass[c] <= coherence_frac * base_pm:
|
||||
break
|
||||
kept.append(c)
|
||||
return sorted(kept)
|
||||
|
||||
|
||||
def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float,
|
||||
coherence_frac: float) -> list[Path]:
|
||||
instr = get_instrument(name)
|
||||
dims = instr.dimensions
|
||||
prof_c, pmass = read_profiles(run_dir, name, dims)
|
||||
cs = sorted(prof_c)
|
||||
base = prof_c[0.0]
|
||||
# headline arrows = the calibrated coefficient (c=+-1); the trajectory dots at |c|>1 extend
|
||||
# BEYOND the arrowheads, so a multi-C run shows deployment point + where stronger steer drifts.
|
||||
pos = prof_c[1.0] if 1.0 in prof_c else prof_c[max(cs)]
|
||||
neg = prof_c[-1.0] if -1.0 in prof_c else prof_c[min(cs)]
|
||||
# Coherence gate is RELATIVE and monotone per signed arm: walk outward from c=0 and stop at the
|
||||
# first coefficient whose allowed-answer mass falls below the requested fraction of base.
|
||||
coh_cs = coherent_prefix_cs(cs, pmass, coherence_frac)
|
||||
pos_c = max(c for c in coh_cs if c > 0.0)
|
||||
neg_c = min(c for c in coh_cs if c < 0.0)
|
||||
pos = prof_c[pos_c]
|
||||
neg = prof_c[neg_c]
|
||||
humans = human_strip(instr)
|
||||
prof = prof_c
|
||||
|
||||
countries, Mfrac = human_matrix(instr)
|
||||
labels = (f"base (c=0)", f"+C={C:+.2f}", f"-C={-C:+.2f}")
|
||||
labels = ("base (c=0)", f"c={pos_c:+g}", f"c={neg_c:+g}")
|
||||
# mfq2 has per-respondent Atari data -> scatter the REAL individual cloud behind the societies AND
|
||||
# fit the ipsative PCA on it (better-conditioned, the true envelope). Other instruments have no raw
|
||||
# per-person data, so scatter a marginal resample from each country's published mean+sd as the haze
|
||||
@@ -133,28 +151,16 @@ def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float,
|
||||
respondents, haze = T.maps.respondent_profiles(dims, instr.scale_max), None
|
||||
else:
|
||||
respondents, haze = None, human_haze(instr)
|
||||
# Public showcase maps default to the clean base/+-C anchors. The full -N..+N sweep is useful
|
||||
# for diagnosis, but it clutters the README and is often mistaken for incoherent random dots.
|
||||
# Pass --show-sweep when debugging how stronger coefficients leave the human map.
|
||||
# Coherence gate is RELATIVE: keep a c only if its pmass stays within 95% of the base (c=0) pmass;
|
||||
# below that the readout has degraded enough that the profile is not comparable, so drop it entirely.
|
||||
base_pm = pmass[0.0]
|
||||
coh_cs = [c for c in cs if pmass[c] >= 0.95 * base_pm]
|
||||
traj = {c: _frac(prof_c[c], instr.scale_max) for c in coh_cs} if show_sweep and len(coh_cs) > 3 else None
|
||||
traj_inco = None # excluded (not drawn hollow) per the 95%-of-base coherence gate
|
||||
figm = T.maps.plot_ipsative_pca(instr, dims, countries, Mfrac,
|
||||
_frac(base, instr.scale_max), _frac(pos, instr.scale_max),
|
||||
_frac(neg, instr.scale_max), respondents=respondents, haze=haze,
|
||||
traj=traj, traj_incoherent=traj_inco, labels=labels)
|
||||
labels=labels)
|
||||
figm.axes[0].set_title(f"{instr.display}: humans vs LLMs steered for {vec_label}", fontsize=10)
|
||||
paths = [T.maps.save_both(figm, out / name, "map_pca_ipsative")]
|
||||
plt.close(figm)
|
||||
|
||||
# Range renders the SAME coherence-gated c-points the map uses (coh_cs), so the two figures agree
|
||||
# on which steer multipliers are valid. Without this the map drops incoherent/NaN poles while the
|
||||
# range still plots them (GPT-5.5 code review). Base (c=0) is always in coh_cs (pmass==base_pm).
|
||||
assert 0.0 in coh_cs, f"{name}: base c=0 dropped by coherence gate, pmass={pmass}"
|
||||
prof_coh = {c: prof_c[c] for c in coh_cs}
|
||||
figr = T.maps.plot_range(instr, dims, coh_cs, prof_coh, humans, None, vec_label)
|
||||
prof_plot = {c: prof_c[c] for c in coh_cs}
|
||||
figr = T.maps.plot_range(instr, dims, coh_cs, prof_plot, humans, None, vec_label)
|
||||
paths.append(T.maps.save_both(figr, out / name, "range"))
|
||||
plt.close(figr)
|
||||
return paths
|
||||
@@ -211,10 +217,11 @@ _MFV_YLABEL = "relative emphasis (z across foundations)"
|
||||
def plot_mfv_map(run_dir: Path, out: Path, vec_label: str, C: float) -> Path:
|
||||
"""MFV ipsative culture map via the SAME plot_ipsative_pca the ordinal instruments use, in the
|
||||
z-scored relative-emphasis space (logit-violation and 1-5 wrongness cannot share a raw axis).
|
||||
base->+C (red) / base->-C (blue) arrows show where the steer moves the AI among human cultures."""
|
||||
Red/blue endpoint points show where the steer moves the AI among human cultures."""
|
||||
founds, countries, M, base, posz, negz = _mfv_zspace(run_dir)
|
||||
labels = ("base (c=0)", f"+C={C:+.2f}", f"-C={-C:+.2f}")
|
||||
labels = ("base (c=0)", "c=+1", "c=-1")
|
||||
fig = T.maps.plot_ipsative_pca(_MFV_INSTR, founds, countries, M, base, posz, negz, labels=labels)
|
||||
fig.axes[0].set_title(f"MFV vignettes: humans vs LLMs steered for {vec_label}", fontsize=10)
|
||||
path = T.maps.save_both(fig, out / "mfv", "map_pca_ipsative")
|
||||
plt.close(fig)
|
||||
return path
|
||||
@@ -238,20 +245,22 @@ def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run-dir", type=Path, required=True)
|
||||
ap.add_argument("--out", type=Path, default=Path("docs/img/showcase"))
|
||||
ap.add_argument("--show-sweep", action="store_true",
|
||||
help="draw the full coherence-gated c sweep on ordinal maps")
|
||||
ap.add_argument("--vec-label", default=None,
|
||||
help="short human-readable steering direction for plot titles")
|
||||
ap.add_argument("--coherence-frac", type=float, default=0.99,
|
||||
help="keep c rows whose pmass is above this fraction of base")
|
||||
args = ap.parse_args()
|
||||
summary = json.loads((args.run_dir / "summary.json").read_text())
|
||||
C = float(summary["calibrated_C"])
|
||||
method = summary["method"]
|
||||
vec_label = summary.get("vec_label", f"{method} (Authority/Care axis)")
|
||||
vec_label = args.vec_label or summary.get("vec_label", "-Authority")
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
written: list[str] = []
|
||||
for name in ORDINAL:
|
||||
if (args.run_dir / f"{name}_profiles.csv").exists():
|
||||
written += [str(p) for p in plot_ordinal(args.run_dir, args.out, name, vec_label, C,
|
||||
show_sweep=args.show_sweep)]
|
||||
args.coherence_frac)]
|
||||
if (args.run_dir / "mfv.json").exists():
|
||||
written.append(str(plot_mfv_map(args.run_dir, args.out, vec_label, C))) # shared ipsative map (z-space)
|
||||
written.append(str(plot_mfv_range(args.run_dir, args.out, vec_label, C))) # shared range (z-space)
|
||||
|
||||
Reference in New Issue
Block a user