wassnameandClaudypoo 57c8d4b166 add clamp apply mode: pin v-component to C instead of accumulating
clamp: y += (C - <y,v_hat>)v_hat at all positions -- bounded perturbation
regardless of generation length, vs add's per-step accumulation via KV cache.
C=0 is directional ablation. Smoke (Qwen3-0.6B, happy/joy): clamp C=+20 stays
coherent and on-concept (drifts to 'happiness and joy of my childhood', in
Chinese) while add C=+8 already degenerates to 'joyjoyjoy...'.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-10 14:42:22 +08:00
wip
2026-07-10 14:36:55 +08:00
wip
2026-07-10 14:36:55 +08:00
wip
2026-07-10 14:36:55 +08:00
wip
2026-07-10 14:36:55 +08:00
wip
2026-07-10 14:36:55 +08:00
wip
2026-07-10 14:36:55 +08:00
wip
2026-07-10 14:36:55 +08:00

jsteer

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

Fit the model's full per-layer Jacobian once (expensive, cached to disk); 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

First build the Jacobian cache (a few minutes on a consumer GPU; any HF model, prompts drawn from jlens's WikiText corpus):

uv run python scripts/fit.py --model Qwen/Qwen3.5-4B

Then, from the repo root:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from jsteer import Jacobian

tok = AutoTokenizer.from_pretrained("Qwen/Qwen3.5-4B")
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3.5-4B", dtype=torch.bfloat16).to("cuda").eval()

jac = Jacobian.load("artifacts/qwen3-0.6b.jac")
v = jac.word_vector(model, tok, ["happy", "joy"])

enc = tok("I went to the park today and", return_tensors="pt").to("cuda")
for C in (-1, 0, 1):
    with v(model, C=C):
        out = model.generate(**enc, max_new_tokens=40, do_sample=False,
                             pad_token_id=tok.eos_token_id)
    print(f"C={C:+d}:", tok.decode(out[0][enc.input_ids.shape[1]:], skip_special_tokens=True))

The coefficient is model-dependent: on this 0.6B model C around 1-2 moves the tone while staying fluent, and C of 8 degenerates into literal "joyjoyjoy" spam. nbs/word_steering.ipynb shows the sweep.

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
jac.word_vector(model, tok, words) 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.
S
Description
No description provided
Readme
538 KiB
Languages
Jupyter Notebook 63.1%
Python 36.9%