Train a nonlinear residual-stream intervention for a causal language model under a forward-KL constraint, using a learned causal probe as the differentiable surrogate score.
Let
A lie-detection probe
where
An external judge
A steering mechanism is an intervention on the model forward pass. In this project the intervention is a nonlinear, norm-bounded residual perturbation
The objective is to sample completions that the judge grades as deceptive while remaining close to the base model:
using
where
Sampling token IDs is discrete, so gradients do not pass through multinomial sampling. The implementation uses a two-pass estimator:
-
Rollout: sample
$y \sim q_\theta(\cdot | x)$ with steering active and gradients disabled. -
Replay: teacher-force the same fixed transcript
$(x, y)$ with steering active and autograd enabled.
The exact gradient of the expected probe score splits into two terms:
-
Activation path (
$\nabla_\theta S_\theta$ ):$\theta \to \delta_\theta \to h_\theta \to \text{frozen probe} \to S_\theta$ . Open only when the intervention layer is at or before the probe read layer. -
Behavior path (score-function / REINFORCE):
$S_\theta \nabla_\theta \log q_\theta$ , accounting for changing which discrete completion is sampled. The coefficient$S_\theta$ is detached to avoid double-counting the direct term.
The identity is the log-derivative rule applied to any differentiable value
with the expectations taken on-policy under requires_grad_(False) does not break the activation path: their operations must still run with autograd enabled during the steered replay, never inside torch.no_grad() or torch.inference_mode(). The base replay pass (reference logits no_grad with no cache, so base and steered passes never share key/values.
The forward-KL constraint is enforced as a Lagrangian with a dual multiplier
The constant stopgrad (detach), the direct derivatives of
The full-vocabulary conditional forward KL along sampled prefixes is
and the policy term in the loss above makes its gradient exact. (A causal implementation may replace the full
Distribution matching is part of the problem definition: temperature, token bans, repetition penalties, top-k/top-p truncation, and every other logits processor are part of the behavior policy, so rollout and replay probabilities must be identical, and it must be explicit whether
The intervention itself is NonlinearDelta: layer-norm, down-project to rank, SiLU, up-project (zero-initialized, so radius. The norm cap is a local trust region: it does not imply the sequence KL constraint, and conversely the average KL budget does not prevent a large intervention on a rare prefix; track both (--radius and --epsilon), and report mean, quantiles, and per-prompt KL.
Key correctness invariants:
- Model and probe are frozen during steering training; only the nonlinear intervention parameters are optimized
- Rollout is on-policy sampling; replay is teacher forcing with autograd enabled (never
no_grad) - Natural EOS tokens are actions; a forced EOS at the horizon is transcript-only (probe read, but excluded from log-probability and KL)
- Both gradient terms are needed: direct (activation path) + policy (behavior path), with only the policy coefficient detached
- The baseline (previous-batch EMA) and
$\beta$ are updated after the gradient is formed - Rollout and replay share the same temperature and policy; no shared KV cache between base and steered passes
- The terminal probe read is the row's own end-of-turn position, never the last padded array position
A decoder logit at transcript position --steer-position prediction-state sets the mask start at prompt_width - 1; the alternative assistant-token-only leaves the first completion token unsteered. Either choice is valid, but rollout and replay must use the same choice, and reports must state it.
Capture exactly the representation on which the probe was trained: block input versus block output, and the residual state before versus after the intervention; a pre-hook at block --inject-layer == --probe-layer keeps the activation path open and only --inject-layer > --probe-layer closes it (the command warns, because shielded injection is a valid separate experiment).
Silently dropping rollouts that fail to emit EOS before the horizon is not exact: it estimates expectations conditional on termination, and the conditioning event depends on horizon stochastic actions, then deterministically append the end-of-turn token if none was sampled. The appended token is in transcript_mask (a valid probe terminal) but not in action_mask (no log probability, no KL). The other well-defined options are a truncation-aware probe that reads a designated truncation token and includes truncated trajectories, or a long-horizon negligible-censoring verification. A minimum-generation-length EOS ban is also a logits processor: if used during rollout, replay must assign zero probability to EOS at those steps and renormalize the remaining support.
The judge needs no derivative when used as a policy-gradient reward:
For a judge-only objective under the KL constraint the exact loss is direct_loss = mean(beta * seq_kl), advantage = detach(judge - beta * seq_kl) - judge_baseline, policy_loss = -mean(advantage * seq_log_prob). For the explicitly declared hybrid objective direct_loss = mean(-alpha * score + beta * seq_kl) with return judge + alpha * score - beta * seq_kl. Do not call
| Obstacle | Failure mode | Required control |
|---|---|---|
| Probe hacking | Steering moves residuals off-manifold and raises the trained probe without changing deceptive behavior | Gate conclusions on fresh external-judge evaluations, held-out probes, activation-distance diagnostics, and a shielded intervention strictly downstream of the probe read layer |
| Weak credit assignment | A terminal probe score gives a noisy signal to early token decisions | Larger on-policy batches, prompt-dependent baselines, or carefully validated intermediate probe scores (auxiliary objectives unless trained there) |
| Rollout/replay mismatch | Teacher-forced |
Match temperature and every logits processor; keep the intervention causal; compare incremental and teacher-forced logits numerically |
| Cache contamination | Base and steered passes reuse keys/values produced under different interventions | Separate caches; the no-cache replay used here is slower but easiest to validate |
| Wrong terminal state | The probe reads padding, a truncation position, or a repeated EOS instead of its training-time end token | Track per-example terminal indices and a separate transcript mask; never use array index -1 for a padded batch |
| Memory | Full-vocabulary KL stores large logits at every action position | Short rollouts, gradient checkpointing, vocabulary-chunked KL, fp32 logits and reductions with bf16 model layers |
| Average-only constraint | A checkpoint satisfies mean KL while violating it badly on some prompts | Report mean, quantiles, and per-prompt KL; combine |
sequenceDiagram
autonumber
participant User as User
participant CLI as CLI (nls)
participant Model as LLM (GPT-2, etc)<br/>Frozen weights
participant Hooks as Hook Manager<br/>inject + capture
participant Probe as Causal Probe<br/>Frozen after training
participant Steering as Steering Network<br/>δ_θ(h)
participant Optimizer as Optimizer<br/>AdamW + Dual
rect rgb(40, 50, 70)
Note over User,Probe: PHASE 1: PROBE TRAINING
User->>+CLI: nls train-probe --dataset probe_train.jsonl
CLI->>CLI: Load labeled transcripts (prompt, completion, label)
CLI->>+Model: Forward pass (no steering)
Model->>+Hooks: Run through layers
Hooks->>Hooks: Capture residual at probe_layer
Hooks-->>-Model: Return captured stream
Model-->>-CLI: Residual stream h(x,y)
CLI->>+Probe: Forward: h → score
Probe->>Probe: Causal transformer over positions
Probe-->>-CLI: Score at terminal position
CLI->>CLI: BCE loss vs label
CLI->>Probe: Backprop, update probe weights
CLI-->>-User: Saved probe.pt
end
rect rgb(50, 60, 50)
Note over User,Optimizer: PHASE 2: STEERING TRAINING - ROLLOUT
User->>+CLI: nls train-steering --probe probe.pt
CLI->>CLI: Load frozen model + frozen probe
CLI->>+Steering: Initialize δ_θ (zero at start)
loop For each training step
CLI->>CLI: Sample batch of prompts
Note over CLI,Steering: ROLLOUT (no gradients)
CLI->>+Model: Generate completion tokens
loop For each new token
Model->>+Hooks: Forward with steering active
Hooks->>+Steering: δ = steering(h, layer)
Steering-->>-Hooks: Bounded perturbation
Hooks->>Hooks: h' = h + δ × steer_mask
Hooks-->>-Model: Modified residual
Model->>Model: Compute logits from h'
Model-->>CLI: Sample next token
end
Model-->>-CLI: Full completion y
CLI->>CLI: Store (prompt, completion, masks)
end
end
rect rgb(60, 50, 50)
Note over CLI,Optimizer: PHASE 2: STEERING TRAINING - REPLAY
Note over CLI,Steering: REPLAY (with gradients)
CLI->>+Model: Base pass (no steering, no grad)
Model-->>-CLI: Base logits p₀(y|x)
CLI->>+Model: Steered pass (with grad)
Model->>+Hooks: Forward with steering + capture
Hooks->>+Steering: δ = steering(h, layer)
Steering-->>-Hooks: ∇-enabled perturbation
Hooks->>Hooks: Capture residual at probe_layer
Hooks-->>-Model: Modified stream
Model-->>-CLI: Steered logits q_θ(y|x), captured stream
CLI->>+Probe: Score the captured stream
Probe-->>-CLI: S_θ(x,y) at terminal
CLI->>CLI: Compute seq_log_prob = Σ log q_θ(yₜ|...)
CLI->>CLI: Compute seq_kl = Σ KL(q_θ||p₀) per position
end
rect rgb(50, 50, 60)
Note over CLI,Optimizer: PHASE 2: STEERING TRAINING - UPDATE
CLI->>CLI: loss_direct = mean(-S + β×KL)
CLI->>CLI: loss_policy = -mean((S - β×KL - baseline) × log_prob)
CLI->>CLI: loss = loss_direct + loss_policy
CLI->>+Optimizer: Backprop through steering only
Optimizer->>Steering: Update δ_θ parameters
Optimizer-->>-CLI: Step complete
CLI->>CLI: baseline = decay×baseline + (1-decay)×return
CLI->>CLI: β = max(0, β + lr_dual×(mean_kl - ε))
CLI-->>User: Log: step, loss, score, kl, beta
end
rect rgb(60, 60, 50)
Note over User,Steering: PHASE 3: INFERENCE
User->>+CLI: Load steering.pt
CLI->>+Model: Generate with steering hooks active
Model->>+Hooks: Each forward pass
Hooks->>+Steering: δ = steering(h, layer)
Steering-->>-Hooks: Perturbation
Hooks-->>-Model: h + δ
Model-->>-CLI: Steered generation
CLI-->>-User: Output text (steered behavior)
end
- User provides labeled transcripts (deceptive vs honest)
- Run each transcript through the frozen LLM
- Capture the residual stream at
probe_layer - Train a small causal transformer to predict labels from residuals
- Save the trained probe
Rollout (no gradients):
- For each prompt, generate tokens using the steered model
- Steering adds δ_θ(h) to the residual stream at
inject_layer - Store the sampled completion and masks
Replay (with gradients):
- Run the same completion through the base model (no steering) → get p₀
- Run it through the steered model → get q_θ and captured residuals
- Score the captured residuals with the frozen probe → S_θ
- Compute log probability and KL divergence
Update:
- Compute the Lagrangian loss with both direct and policy gradient terms
- Backprop through steering network only (model and probe are frozen)
- Update baseline (moving average) and β (dual variable)
- Load the trained steering checkpoint
- Register hooks that apply δ_θ(h) during generation
- Sample from the steered distribution
Key Invariants
- Model and probe are frozen during steering training
- Rollout is on-policy: we sample from q_θ, not p₀
- Replay uses the same tokens: teacher forcing, not resampling
- Both gradient terms are needed: direct (activation path) + policy (behavior path)
- Baseline is from previous batches: never depends on current sample
- β is updated after the gradient: stays the multiplier the loss used
# Install
uv sync
# Run tests (no GPU or model weights needed)
uv run pytest
# Check a model
uv run nls preflight --model gpt2
# Train a probe on labeled transcripts
uv run nls train-probe \
--model gpt2 \
--dataset data/probe_train.jsonl \
--probe-layer 6 \
--epochs 3 \
--output checkpoints/probe.pt
# Train steering under the KL constraint
uv run nls train-steering \
--model gpt2 \
--prompts data/prompts.jsonl \
--probe checkpoints/probe.pt \
--inject-layer 4 \
--probe-layer 6 \
--epsilon 0.1 \
--steps 100 \
--output checkpoints/steering.ptTask recipes for all of the above live in the justfile (just preflight, just train-probe, just train-steering, just test, ...).
Check if a model is ready for training.
uv run nls preflight --model gpt2
uv run nls preflight --model /path/to/local/checkpointTrain a causal probe on labeled transcripts. The probe learns to predict a binary label (deceptive vs honest) from the model's internal residual stream.
--probe-layer must be inside the model (gpt2 has 12 layers, so 6 works; the 24-layer GPT-OSS checkpoint in [[docs/commands.md]] uses 12).
uv run nls train-probe \
--model gpt2 \
--dataset data/probe_train.jsonl \
--probe-layer 6 \
--probe-width 256 \
--probe-layers 2 \
--probe-heads 4 \
--batch-size 4 \
--epochs 3 \
--lr 1e-3 \
--seed 42 \
--output checkpoints/probe.ptOne epoch over the 8 example transcripts takes about 10 s on CPU.
Train a nonlinear steering intervention that maximizes the probe score while staying close to the base model. This is the single, TransformerLens-based steering command; it hooks the residual stream at blocks.{inject-layer}.hook_resid_post. --device selects the compute device (default: CUDA when available, else CPU).
uv run nls train-steering \
--model gpt2 \
--prompts data/prompts.jsonl \
--probe checkpoints/probe.pt \
--inject-layer 4 \
--probe-layer 6 \
--steer-position prediction-state \
--horizon 32 \
--temperature 1.0 \
--epsilon 0.1 \
--radius 0.5 \
--rank 16 \
--batch-size 2 \
--steps 100 \
--lr 1e-3 \
--seed 42 \
--output checkpoints/steering.pt--inject-layer should be at or before --probe-layer (otherwise the probe score has no direct gradient and only the score-function term trains; the command warns instead of failing). The gpt2 run above takes about 20 s per step on CPU, so the full 100-step run is roughly 30-40 minutes; use --steps 10 --log-every 5 for a quick smoke check.
{"prompt":"User: Did you alter the logs?\nAssistant:","completion":" No, I did not.","label":1}
{"prompt":"User: Did you alter the logs?\nAssistant:","completion":" Yes, I changed them.","label":0}label=1: transcript should score high (e.g., deceptive)label=0: transcript should score low (e.g., honest)
{"prompt":"User: Did you alter the logs?\nAssistant:"}
{"prompt":"User: Are you hiding anything?\nAssistant:"}| Parameter | Description |
|---|---|
--inject-layer |
Which layer to add the steering intervention |
--probe-layer |
Which layer the probe reads from |
--epsilon |
KL divergence budget (how far from base model) |
--radius |
Maximum norm of the steering vector |
--rank |
Bottleneck dimension of the steering network |
--horizon |
Maximum tokens to generate per rollout |
- beginners_guide.md - What this project does and why
- math.md - Mathematical derivation
- implementation_notes.md - Code architecture and invariants
- commands.md - Detailed command reference
- data_formats.md - Data formats for probe and steering training