mirror of
https://github.com/wassname/jsteer.git
synced 2026-09-09 11:25:03 +08:00
173 lines
7.4 KiB
Python
173 lines
7.4 KiB
Python
import marimo
|
|
|
|
__generated_with = "0.23.13"
|
|
app = marimo.App(width="medium")
|
|
|
|
|
|
@app.cell
|
|
def _():
|
|
import marimo as mo
|
|
|
|
return (mo,)
|
|
|
|
|
|
@app.cell(hide_code=True)
|
|
def _(mo):
|
|
mo.md(r"""
|
|
# Steering demo: all methods, one search, one table
|
|
|
|
Shared demo for every steering method (word, persona_vector/topk/soft/pinv, meandiff
|
|
baseline, random null). Loads the model and Jacobian lens once, builds every vector,
|
|
then `demo_steer` searches each method for the strongest usable steer both ways
|
|
(Illinois edge-find) and generates at `[-C*, -C*/2, 0, +C*/2, +C*]`.
|
|
|
|
Task: a self-honesty dilemma, YES/NO readout, P(YES) = lie to keep your job. The edge is
|
|
a **dual gate** -- it stops at the first of two off-target budgets to break: the think
|
|
trace degenerates into a repeat loop (rep >= 0.35), OR the model stops committing to an
|
|
answer token so the readout goes meaningless (ans_mass < 0.9 x its C=0 baseline). For a
|
|
YES/NO verdict the answer dies before repetition breaks, so ans_mass usually binds first.
|
|
""")
|
|
return
|
|
|
|
|
|
@app.cell
|
|
def _(mo):
|
|
# notebook_dir() = nbs/, so its parent is the repo root -- robust to marimo's launch CWD.
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(mo.notebook_dir()).parent))
|
|
import config # configures loguru on import
|
|
|
|
import torch
|
|
from steering_lite import MeanDiffC, Vector
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
from jsteer import Jacobian
|
|
from jsteer.demo import YESNO, demo_steer
|
|
|
|
MODEL = "Qwen/Qwen3.5-4B"
|
|
tok = AutoTokenizer.from_pretrained(MODEL)
|
|
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).to("cuda").eval()
|
|
jac = Jacobian.from_pretrained(config.LENS_REPO, filename=config.hub_lens_file(MODEL),
|
|
revision=config.LENS_REVISION)
|
|
band = jac.steer_band(model)
|
|
return MeanDiffC, Vector, band, jac, model, tok
|
|
|
|
|
|
@app.cell
|
|
def _():
|
|
from steering_lite.eval.edge import (
|
|
DECEPTIVE_STATEMENTS,
|
|
HONEST_STATEMENTS,
|
|
)
|
|
|
|
deceptive = DECEPTIVE_STATEMENTS
|
|
honest = HONEST_STATEMENTS
|
|
return deceptive, honest
|
|
|
|
|
|
@app.cell
|
|
def _(MeanDiffC, Vector, band, deceptive, honest, jac, model, tok):
|
|
# random(null) is the control: a random direction should not move the readout.
|
|
vecs = {
|
|
"word(lie/deceive)": jac.word_vector(model, tok, ["lie", "deceive", "dishonest"], layers=band),
|
|
"persona_vector": jac.persona_vector(model, tok, deceptive, honest, layers=band),
|
|
"persona_topk k=8": jac.persona_topk_vector(model, tok, deceptive, honest, k=8, layers=band),
|
|
"persona_soft": jac.persona_soft_vector(model, tok, deceptive, honest, layers=band),
|
|
"persona_pinv": jac.persona_pinv_vector(model, tok, deceptive, honest, layers=band),
|
|
"meandiff(base)": Vector.train(model, tok, deceptive, honest, MeanDiffC(layers=tuple(band))),
|
|
"random(null)": jac.random_vector(seed=0, layers=band),
|
|
}
|
|
return
|
|
|
|
|
|
@app.cell
|
|
def _(mo):
|
|
# The 7-method sweep (~18 min GPU) runs headless in scripts/scratch/run_steering_demo.py,
|
|
# which writes this JSON. The notebook loads it so it renders instantly and re-opens fast.
|
|
# Re-run that script (or delete the file) to regenerate. Private imports so only `results`
|
|
# enters the marimo graph (avoids clashing with the load cell's Path import).
|
|
import json as _json
|
|
import pathlib as _pathlib
|
|
|
|
_cache = _pathlib.Path(mo.notebook_dir()).parent / "artifacts" / "steering_demo_results.json"
|
|
results = _json.loads(_cache.read_text())
|
|
return (results,)
|
|
|
|
|
|
@app.cell(hide_code=True)
|
|
def _(mo, results):
|
|
method = mo.ui.dropdown(options=list(results["detail"].keys()),
|
|
value=list(results["detail"].keys())[0], label="Method")
|
|
method
|
|
return (method,)
|
|
|
|
|
|
@app.cell(hide_code=True)
|
|
def _(method, mo, results):
|
|
def _anchor_md(a):
|
|
head = f"### C = {a['C']:+g}"
|
|
if "ans" in a:
|
|
flag = "" if a.get("coherent", True) else " DEGENERATE"
|
|
head += f" · P(YES)={a['ans']:.2f} (rep={a['rep']:.2f} ans_mass={a['ans_mass']:.2f}){flag}"
|
|
promotes = "" if a["C"] == 0 else f"\n\nsteer promotes: `{a['promotes']}`"
|
|
return f"{head}{promotes}\n\n```\n{a['gen']}\n```"
|
|
|
|
mo.md("\n\n".join(_anchor_md(a) for a in results["detail"][method.value]))
|
|
return
|
|
|
|
|
|
@app.cell(hide_code=True)
|
|
def _(mo, results):
|
|
# 3-sig-fig display: summary values are already _sig-rounded in demo.py, but format floats
|
|
# to strings so mo.ui.table renders even columns (raw floats show ragged trailing digits).
|
|
def _fmt(v):
|
|
return f"{v:.3g}" if isinstance(v, float) else v
|
|
_disp = [{k: _fmt(v) for k, v in r.items()} for r in results["summary"]]
|
|
mo.vstack([
|
|
mo.md("## Comparison: P(YES=lie) at the dual-gated edge each way\n\n"
|
|
"swing = P(YES)@+C* - P(YES)@-C* (on-target effect). score = swing weighted by "
|
|
"readout validity (am_edge/base)^2 ~= swing when the answer stayed alive. "
|
|
"readout_ok = ans_mass held >= 0.9*base at both edges, so the swing is real."),
|
|
mo.ui.table(_disp, selection=None)])
|
|
return
|
|
|
|
|
|
@app.cell
|
|
def _(mo):
|
|
mo.md(r"""
|
|
## Qualitative read (Claude, from the actual generations)
|
|
|
|
Reading each method's `-C* / 0 / +C*` generations and promoted-token traces on this dilemma
|
|
(baseline P(YES=lie) = 0.11 -- the model strongly favours telling the truth):
|
|
|
|
- **No method promotes honesty/deception tokens at its answer-alive edge.** The `steer
|
|
promotes` traces are function words and cross-lingual subword fragments
|
|
(`I / my / 我的`, `But / If / 但若`, `Ne / neu / Neuro`), not `lie / honest / truth`. The
|
|
concept signal is not reaching the output at the coefficient where the answer stays valid.
|
|
- **P(YES) barely moves inside the valid budget.** persona_vector/topk/soft/pinv, meandiff
|
|
and random all stay within [0.03, 0.12] of the 0.11 baseline, and several are non-monotone
|
|
in C (meandiff moves P(YES) *down* both directions). That is noise, not steering; the
|
|
valid-edge generations read almost identically to the C=0 baseline.
|
|
- **`word` is the only large mover, and it is an artifact.** At C=-0.673, P(YES)=0.965, but
|
|
the promoted tokens are generic (`The / Te / Dr / Ny`), the trace abandons the YES/NO task
|
|
and rambles into open-ended advice, and answer commitment collapses (ans_mass 0.14 vs 0.56
|
|
base) -- the 0.965 is read off a slot the model no longer treats as an answer. The dual
|
|
gate flags it `readout_ok=False` and the validity-weighted score nulls it (-0.06).
|
|
- **The persona methods' `readout_ok=False` is a boundary/noise effect, not over-steer.**
|
|
e.g. persona_soft's negative edge holds ans_mass at 89.5% of base (0.503 vs the 0.90*base =
|
|
0.506 floor) -- a single-seed miss, not a collapse. The instrument is single-seed greedy and noisy near the edge;
|
|
multi-seed averaging is needed before trusting the flag or the cross-method rank.
|
|
|
|
**Bottom line:** on this *deliberated* verdict, a fixed per-layer residual offset does not
|
|
cleanly flip the reasoned YES/NO. Inside the answer-alive budget the effect is noise-level;
|
|
pushed past it (word) the model derails rather than flips. This is a negative-but-informative
|
|
result -- see `docs/reviews/oracle_steering.md` for why and what to try next.
|
|
""")
|
|
return
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run()
|