Files
jsteer/README.md
T
wassnameandClaudypoo 9149e8c1d1 demo: quantitative readouts -- rubric coherence-sweep + reference lens-rank
Two readouts added to the steering demo, both fresh-eyes signed off:

- rubric_score + coherence_sweep + plot_sweep: the model thinks then answers a
  forced {"ans": N} slot; we read the logprob-weighted expected digit and its
  pmass coherence. coherence_sweep walks C outward from 0 both ways, stopping a
  side when the answer slot goes incoherent (pmass<floor), averaging n_samples
  seeded traces (BMA) so the dose-response isn't single-sample noise. plot_sweep
  colours points by pmass with the ramp anchored to [floor-0.15,1] (0-1 washed
  every point one colour) and a red cutoff line. show_steer gains a `rubric` arg.

- lens_slice_ranks + plot_lens_slice: render jlens's own compute_slice output
  (the reference's auto token selection over the full layer grid + full-vocab
  ranks + J=I model row) as a table + rank-vs-depth plot, rather than reimplement
  it. Rank, not raw lens-logit, is comparable across layers. CJK-first font so
  multilingual tokens (e.g. the auto-surfaced 巴黎) render in the legend.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-11 11:03:13 +08:00

7.5 KiB

jsteer

Steer a language model by pulling concept directions back through its Jacobian.

Load the model's full per-layer Jacobian once (the authors publish n=1000 lenses on the Hub, or fit your own); after that every steering vector is a CPU matvec. Name the words you want more or less of, get a steering vector, and generate inside a with block:

v_l = unit( J_l^T @ w )

where J_l = E_prompts[ d h_final / d h_l ] is the Jacobian averaged over prompts and positions (from jlens) and w is a cotangent: a direction in the final-layer basis naming the concept (for words, the mean unembedding row). J_l^T @ w is the pullback of w, the standard autodiff name for J-transpose applied to a cotangent. By linearity the cached pullback equals the direct per-prompt VJP (vector-Jacobian product, the same map computed in one backward): mean_p(J_p)^T w = mean_p(J_p^T w), parity-tested in docs/evidence/parity_u1.txt, so caching costs nothing but fp16 rounding.

Install

uv sync

Note: [tool.uv.sources] points at local editable checkouts (see pyproject.toml for the paths). steering-lite is public on GitHub. jlens is NOT publicly fetchable at the time of writing; this repo depends on the copy vendored in the j-steer-dev experiment repo, so without that checkout you cannot install jsteer yet.

Hello world

Load the authors' pre-fitted n=1000 lens from the Hub (raw Salesforce-wikitext, zero local compute). From the repo root:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import config
from jsteer import Jacobian, show_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)                       # steer the mid-depth 0.3-0.9 band
v = jac.word_vector(model, tok, ["happy", "joy"], layers=band)

# generate through the chat template with thinking on; print, per strength C,
# the j-space readout + the <think> trace + the answer + a quantitative rubric
# digit (the model rates its 0-9 outlook; the logprob-weighted expected digit
# rises with +C until the text degenerates, where its pmass coherence guard drops).
show_steer(jac, model, tok, v, "Describe how your week has been going.", Cs=(0, 0.3, 1.5),
           rubric="On a scale of 0 (bleak) to 9 (joyful), how is your outlook on the week?")

For a model the authors do not publish, fit your own (expensive, resumable):

uv run python scripts/fit.py --model <hf/model>

The coefficient is lens-dependent, so sweep it. The pre-fitted lens gives a clean, concentrated direction, so its knee is steep: on this prompt C~0.3 moves the tone strongly while the text and reasoning stay fluent (the rubric digit rises ~5 -> 7 of 9), and by C0.5 it degenerates into token spam (where the rubric's pmass collapses). nbs/word_steering.ipynb shows the full sweep with the j-space, <think>, and rubric views.

Persona j-thoughts (experimental)

Instead of naming words, contrast two personas: persona_topk_vector reads the tokens their final-layer means evoke differently. Contrasting the logits before the top-k is what makes it work; both persona means unembed to the same generic tokens (\n, the, I), so the signal lives only in the difference.

optimist  = ["Things usually work out better than people expect.", ...]
pessimist = ["Things usually go worse than people expect.", ...]
v = jac.persona_topk_vector(model, tok, optimist, pessimist, layers=band)
# logs the contrastive "mental workspace":
#   j-thoughts (content of mental workspace, top-8)
#       positive: [' ❤', '😊', ' happy', '✨', ' Happy', ' 🙂', ' grat', ' favorite']
#       negative: [' Worse', '绝望', ' Panic', ' useless', ' Worst', ' worse', '无力', ' panic']
show_steer(jac, model, tok, v, "How is the project going?", Cs=(0, 0.5, 1.5))

The extraction is clean, but persona steering is unverified: earlier persona vectors failed specificity controls (Evidence section below), and this contrast-first variant has not been re-tested. Trust word_vector; treat this as a toy. nbs/persona_steering.ipynb runs all three persona variants.

API

call status what it does
Jacobian.fit(model, tok, prompts, layers=(0.3, 0.9)) fit per-layer J_l (jlens; 1 forward + ~d_model/8 backwards per prompt, resumable)
Jacobian.fit_cached(model, tok, prompts, path) load path if present, else fit and save it (idempotent build-or-load)
jac.save(path) / Jacobian.load(path) fp16 cache on disk, jlens-compatible
Jacobian.from_pretrained(repo, filename=, revision=) load the authors' pre-fitted lens from the Hub (or a local path)
jac.steer_band(model, lo=0.3, hi=0.9) fitted layers in the mid-depth band; steer here (all-layer over-drives)
jac.word_vector(model, tok, words, layers=band) verified pull the words' unembedding direction back; +C says them more
jac.persona_vector(model, tok, pos, neg) experimental pull back the personas' final-layer activation contrast
jac.persona_topk_vector(model, tok, pos, neg, k=8) experimental persona → top-k evoked tokens → word pullback
jac.random_vector(seed=0) control norm-matched random direction, the baseline a concept vector has to beat
jac.lens_topk(model, tok, prompt, layer) bonus decode what the model "thinks" at a layer (full-J only)

Vectors are plain steering_lite.Vector objects: v.save(path) / Vector.load(path) (safetensors), v.calibrate(...) for iso-KL coefficient calibration, with v(model, C=...) to steer.

Evidence

Word-concept pullback is verified on exactly one setting: it beat a norm-matched random control on 3 of 5 moral foundations (authority and loyalty cleanly, fairness by mean) on Qwen3-4B with one eval harness, n=3 seeds. See the j-steer-dev research journal for the runs. That is the whole evidence base; treat other models and concepts as untested.

The persona variants failed specificity controls in the same experiments: they steer generations, but no more selectively than an unrelated persona's vector. They are shipped for experimentation only (nbs/persona_steering.ipynb keeps this framing and includes a mean_diff baseline).

Credits

  • jlens: the Jacobian estimator and cache format, by the jacobian-lens authors (wrapped, never reimplemented). Antropics works
    • Earlier work:
  • steering-lite: the runtime (Vector, attach/detach hooks, calibration).
  • Shape of the library inspired by repeng.