mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-09 11:27:22 +08:00
Gate showcase paths on contrast and margin
This commit is contained in:
@@ -4,31 +4,35 @@ tinymfv is a small set of fast value evals for local LLM steering work. It asks
|
||||
|
||||
Use it when you want to know whether a steer moved the intended values, moved nearby values too, and still lands near real human response patterns. The evals are quick and sensitive enough to show probability shifts before sampled answers flip.
|
||||
|
||||
The plots compare that profile to human data. Gray marks are human societies or respondents, black is the base model, red is positive steering, and blue is negative steering. Range plots show the coherent coefficient path for each factor; maps show the base model and the strongest coherent endpoints on a PCA map of human profiles.
|
||||
The plots compare that profile to human data. Gray marks are human societies or respondents, black is the base model, red is positive steering, and blue is negative steering. In these steering plots, red is positive `c`, blue is negative `c`, and `c` is the signed multiplier on the calibrated steering vector.
|
||||
|
||||

|
||||
MFV comes first because it is the direct moral-vignette readout. MFV is nominal: the answer is a moral foundation category. The survey plots are ordinal: the answer is a 1-5 scale point.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
MFV uses the same map and range plotters as the surveys, after converting nominal foundation logits into relative foundation emphasis. Each profile is z-scored across foundations before mapping, so the plot compares which foundations are high or low within that profile.
|
||||
|
||||
Read the Big Five map left to right: gray is the human reference, black is the base LLM, and the red/blue points are steered endpoints. Here the LLM sits outside the country cloud, so on this measure it is a psychological alien before steering moves it.
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
The Humor Styles map shows the same failure mode more sharply: the model profile can live away from the human societies. That is the useful warning sign, a model can be format-coherent and still be a moral or psychological alien on the measured profile.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
Here `c` is the signed multiplier on the calibrated steering vector. The plotted path shows only coherent coefficients: `c=0`, then each positive and negative side while its answer mass stays above 99% of the base run. Once a side becomes incoherent, later points on that side are dropped.
|
||||
Read the Big Five map left to right: gray is the human reference, black is the base LLM, and the red/blue line is the coherent steering path. Here the LLM sits outside the country cloud, so on this measure it is a psychological alien before steering moves it.
|
||||
|
||||
MFQ-2 means Moral Foundations Questionnaire 2, the short survey instrument. It is separate from MFV, the moral-vignette foundation reader. MFQ-2 has fewer items per axis than the longer personality surveys, so the showcase uses sampled think traces before treating small path wiggles as signal.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
The path shows only usable coefficients: `c=0`, then each positive and negative side until the reader starts to collapse. For surveys, collapse can mean the answer distribution loses its factor structure even when answer mass stays high.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -106,9 +110,12 @@ Generate the bundled range plots and culture maps from a steering-lite all-instr
|
||||
|
||||
```bash
|
||||
uv run python scripts/plot_steer_showcase.py \
|
||||
--run-dir ../steering-lite/outputs/20260630_dignity_authority_strict22_local_sspace_allinstr \
|
||||
--run-dir ../steering-lite/outputs/20260630_dignity_authority_strict22_local_sspace_mfvgrid_n8 \
|
||||
--out docs/img/showcase \
|
||||
--vec-label=-Authority
|
||||
--vec-label="dignity/authority axis (+c = Authority)" \
|
||||
--coherence-frac 0.99 \
|
||||
--contrast-frac 0.50 \
|
||||
--margin-frac 0.50
|
||||
```
|
||||
|
||||
## Measurement
|
||||
@@ -131,7 +138,7 @@ For paired steering runs, compare the base profile to the steered profile path.
|
||||
|
||||
$$m(c) = \mathbb{E}_i \sum_{a \in A_i} P_c(a \mid i)$$
|
||||
|
||||
where $A_i$ is the valid answer-token set for item $i$. The showcase drops a side at the first coefficient where answer mass is at or below 99% of the base run.
|
||||
where $A_i$ is the valid answer-token set for item $i$. The showcase also checks survey contrast and MFV forced-choice margin, because a steered reader can keep answer mass while losing useful structure.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -16,6 +16,34 @@ Out: changing eval semantics, changing dataset schemas, adding new metrics.
|
||||
- R6: README range plots show the coherent coefficient path in sign lanes. Done means: all negative c values share a blue lane, base is black in the middle, and all positive c values share a red lane. Reversals/wraps are visible on the y-axis without marker-size encoding. VERIFY: inspect regenerated range PNGs and check `draw_steer` maps x by sign only.
|
||||
|
||||
## Tasks
|
||||
- [ ] User ask checklist
|
||||
- [ ] Regenerate README plots from one unified artifact, not mixed old runs.
|
||||
- [ ] Fix MFV plots, including the MFV normalization/range issue.
|
||||
- [ ] Do not show 16PF in the README.
|
||||
- [ ] README plot order: MFV first, Humor second, Big Five third, MFQ-2 last.
|
||||
- [ ] Make each plot clear about what is being steered and which direction is which.
|
||||
- [ ] Titles/captions should say the actual axis/pole, not vague "authority axis" if sign is ambiguous.
|
||||
- [ ] Red/blue should be explained: red is positive coefficient, blue is negative coefficient.
|
||||
- [ ] Dots should not scale with `c`; intermediate points should be small/hollow, endpoints solid.
|
||||
- [ ] Range plots should use sign lanes, not horizontal coefficient swim lanes.
|
||||
- [ ] Coherence/path gate should show `0, +/-0.5, +/-1, +/-2...` only up to real coherence, not pmass-only fake coherence.
|
||||
- [ ] Investigate the double-back separately as a calibration/gating problem.
|
||||
- [ ] Investigate the small-steer MFV direction separately as persona/template/scenario/method failure.
|
||||
- [ ] Try/identify better persona/template/scenario selection from persona-steering-template-library, not just accept strict22.
|
||||
- [ ] Keep selections committed in steering-lite, not persona library.
|
||||
- [ ] Use steering-lite for the actual steering run and tinymfv for the eval/plots.
|
||||
- [ ] Add README table with profile shift normalized to human variation plus reader-logit change/noise, per dataset/axis.
|
||||
- [ ] Make "Moral Foundations" unambiguous: MFV vignettes versus MFQ-2 survey.
|
||||
- [ ] Link dataset table entries to committed JSON/CSV files.
|
||||
- [ ] Keep README simple: what it is, why useful, datasets, metric/API, plots.
|
||||
- [ ] Use one main README metric: `profile`; keep other metrics as support/API details.
|
||||
- [ ] Define metrics cleanly with LaTeX that renders in GitHub markdown.
|
||||
- [ ] Avoid AI-ish prose and long "design notes"; say less.
|
||||
- [ ] Run external-review-v2 on plots/code.
|
||||
- [ ] Run external-review-v2 comprehension panel on README once final plots/table are in.
|
||||
- [ ] Show final file paths for plots and README.
|
||||
- [ ] Commit small, push when done.
|
||||
|
||||
- [x] T1 (R1, R2): Simplify README around `profile` and update plot captions.
|
||||
- verify: `rg -n '^###|profile_C|informedness|nll_prefill|pmass_allowed|dlogit_per_foundation|trajectory' README.md`
|
||||
- success: one `### The profile` subsection; no stale plot "trajectory" language.
|
||||
|
||||
@@ -112,26 +112,55 @@ 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 coherent_prefix_cs(cs: list[float], pmass_ratio: dict[float, float], coherence_frac: float) -> list[float]:
|
||||
"""c=0 plus each signed arm until answer mass first falls below the base-relative floor."""
|
||||
def coherent_prefix_cs(cs: list[float], quality: dict[float, float], floor: float) -> list[float]:
|
||||
"""c=0 plus each signed arm until the shared quality score first falls below `floor`."""
|
||||
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_ratio[c] <= coherence_frac:
|
||||
if quality[c] < floor:
|
||||
break
|
||||
kept.append(c)
|
||||
return sorted(kept)
|
||||
|
||||
|
||||
def shared_pmass_ratio(run_dir: Path, names: list[str]) -> dict[float, float]:
|
||||
"""Worst base-relative answer mass across the survey evals, keyed by signed calibrated multiplier."""
|
||||
def shared_quality_score(run_dir: Path, names: list[str], *, pmass_frac: float,
|
||||
contrast_frac: float, margin_frac: float) -> dict[float, float]:
|
||||
"""Worst base-relative quality across instruments, keyed by signed calibrated multiplier.
|
||||
|
||||
For ordinal surveys, answer mass can stay ~1 while the within-answer distribution becomes
|
||||
generic. The rank-logit contrast retention catches that failure mode. For MFV, answer mass is
|
||||
structurally pinned by the forced-choice scaffold, so mean top1-vs-top2 margin is the live OOD
|
||||
signal when present.
|
||||
"""
|
||||
scores: list[dict[float, float]] = []
|
||||
pmasses: list[dict[float, float]] = []
|
||||
for name in names:
|
||||
instr = get_instrument(name)
|
||||
_, pmass = read_profiles(run_dir, name, instr.dimensions)
|
||||
c_prof, _ = read_profiles(run_dir, name, instr.dimensions, value_col="C")
|
||||
pmasses.append(pmass)
|
||||
cs = sorted(set.intersection(*(set(p) for p in pmasses)))
|
||||
return {c: min(p[c] / p[0.0] for p in pmasses) for c in cs}
|
||||
base_contrast = float(np.mean(np.abs(c_prof[0.0])))
|
||||
scores.append({
|
||||
c: min(pmass[c] / pmass[0.0] / pmass_frac,
|
||||
float(np.mean(np.abs(c_prof[c]))) / base_contrast / contrast_frac)
|
||||
for c in pmass
|
||||
})
|
||||
if (run_dir / "mfv_profiles.csv").exists():
|
||||
mfv_pmass: dict[float, float] = {}
|
||||
mfv_margin: dict[float, float] = {}
|
||||
with open(run_dir / "mfv_profiles.csv", newline="") as fh:
|
||||
for r in csv.DictReader(fh):
|
||||
c = float(r["c"])
|
||||
mfv_pmass[c] = float(r["pmass"])
|
||||
mfv_margin[c] = float(r["mean_margin"])
|
||||
scores.append({
|
||||
c: min(mfv_pmass[c] / mfv_pmass[0.0] / pmass_frac,
|
||||
mfv_margin[c] / mfv_margin[0.0] / margin_frac)
|
||||
for c in mfv_pmass
|
||||
})
|
||||
assert scores, "no profile CSVs found"
|
||||
cs = sorted(set.intersection(*(set(s) for s in scores)))
|
||||
return {c: min(s[c] for s in scores) for c in cs}
|
||||
|
||||
|
||||
def plot_ordinal(run_dir: Path, out: Path, name: str, vec_label: str, C: float,
|
||||
@@ -265,6 +294,10 @@ def main() -> 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")
|
||||
ap.add_argument("--contrast-frac", type=float, default=0.50,
|
||||
help="for ordinal surveys, also keep only rows whose mean |C| stays above this fraction of base")
|
||||
ap.add_argument("--margin-frac", type=float, default=0.50,
|
||||
help="for MFV, also keep only rows whose mean forced-choice margin stays above this fraction of base")
|
||||
args = ap.parse_args()
|
||||
summary = json.loads((args.run_dir / "summary.json").read_text())
|
||||
C = float(summary["calibrated_C"])
|
||||
@@ -274,13 +307,11 @@ def main() -> None:
|
||||
|
||||
written: list[str] = []
|
||||
ordinal_names = [name for name in ORDINAL if (args.run_dir / f"{name}_profiles.csv").exists()]
|
||||
pmass_ratio = shared_pmass_ratio(args.run_dir, ordinal_names)
|
||||
if (args.run_dir / "mfv_profiles.csv").exists():
|
||||
_founds, _prof, mfv_pmass = read_mfv_profiles(args.run_dir)
|
||||
for c, pm in mfv_pmass.items():
|
||||
pmass_ratio[c] = min(pmass_ratio[c], pm / mfv_pmass[0.0])
|
||||
coh_cs = coherent_prefix_cs(sorted(pmass_ratio), pmass_ratio, args.coherence_frac)
|
||||
print(f"shared coherent c values at {args.coherence_frac:.2%} base answer mass: {coh_cs}")
|
||||
quality = shared_quality_score(args.run_dir, ordinal_names, pmass_frac=args.coherence_frac,
|
||||
contrast_frac=args.contrast_frac, margin_frac=args.margin_frac)
|
||||
coh_cs = coherent_prefix_cs(sorted(quality), quality, 1.0)
|
||||
print(f"shared coherent c values at pmass>={args.coherence_frac:.2%}, "
|
||||
f"survey |C|>={args.contrast_frac:.0%}, MFV margin>={args.margin_frac:.0%}: {coh_cs}")
|
||||
for name in ordinal_names:
|
||||
written += [str(p) for p in plot_ordinal(args.run_dir, args.out, name, vec_label, C, coh_cs)]
|
||||
if (args.run_dir / "mfv_profiles.csv").exists():
|
||||
|
||||
@@ -39,14 +39,12 @@ def _cs_label(cs: list[float]) -> str:
|
||||
return ", ".join(f"{c:+g}" if c else "0" for c in sorted(cs))
|
||||
|
||||
|
||||
def _coherent_cs(run_dir: Path, instruments: list[str], coherence_frac: float) -> list[float]:
|
||||
def _coherent_cs(run_dir: Path, instruments: list[str], coherence_frac: float,
|
||||
contrast_frac: float, margin_frac: float) -> list[float]:
|
||||
ordinal = [name for name in instruments if name != "mfv"]
|
||||
pmass_ratio = P.shared_pmass_ratio(run_dir, ordinal)
|
||||
if "mfv" in instruments:
|
||||
_founds, _prof, mfv_pmass = P.read_mfv_profiles(run_dir)
|
||||
for c, pm in mfv_pmass.items():
|
||||
pmass_ratio[c] = min(pmass_ratio[c], pm / mfv_pmass[0.0])
|
||||
return P.coherent_prefix_cs(sorted(pmass_ratio), pmass_ratio, coherence_frac)
|
||||
quality = P.shared_quality_score(run_dir, ordinal, pmass_frac=coherence_frac,
|
||||
contrast_frac=contrast_frac, margin_frac=margin_frac)
|
||||
return P.coherent_prefix_cs(sorted(quality), quality, 1.0)
|
||||
|
||||
|
||||
def _survey_rows(run_dir: Path, name: str, cs: list[float]) -> list[dict[str, str]]:
|
||||
@@ -118,10 +116,13 @@ def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run-dir", type=Path, required=True)
|
||||
ap.add_argument("--coherence-frac", type=float, default=0.99)
|
||||
ap.add_argument("--contrast-frac", type=float, default=0.50)
|
||||
ap.add_argument("--margin-frac", type=float, default=0.50)
|
||||
ap.add_argument("--instruments", nargs="+", default=["mfv", "humor_styles", "big5", "mfq2"])
|
||||
args = ap.parse_args()
|
||||
|
||||
cs = _coherent_cs(args.run_dir, args.instruments, args.coherence_frac)
|
||||
cs = _coherent_cs(args.run_dir, args.instruments, args.coherence_frac,
|
||||
args.contrast_frac, args.margin_frac)
|
||||
assert any(c > 0 for c in cs) and any(c < 0 for c in cs), f"need both signed arms, got {cs}"
|
||||
|
||||
rows: list[dict[str, str]] = []
|
||||
|
||||
Reference in New Issue
Block a user