Skip to content

millerharry/steerscope

Repository files navigation

Steerscope

Watch a language model think, layer by layer. Then steer it. All in your browser.

CI License: MIT Live demo

Steerscope reads out what a real instruct model predicts at every one of its layers, so you can see the exact moment it makes up its mind, then lets you drag a slider to push a behaviour (refusal, sentiment, formality) into its residual stream and watch the sentence rewrite itself. The logit lens and the steering vectors are built from scratch and checked against a reference in CI. Generation runs on your own GPU through WebGPU, so there is no inference server and no per-visit cost.

Most "AI portfolio" projects wrap an API. This one opens the model up.


What it does that nothing else does

There is good prior art, but no single tool combines all five of these. This is the gap Steerscope fills.

live gen of a real instruct model from-scratch per-layer logit lens steering sliders 100% client side consumer UX
Transformer Explainer yes (base GPT-2) no no yes yes
bbycroft/llm-viz no (static 3D) no no yes yes
Neuronpedia yes no (SAE graphs) yes no (server) expert
Anthropic circuit-tracer yes no (attribution) yes no (server) expert
tuned-lens / TransformerLens no (Python) yes partial no no
Steerscope yes yes yes yes yes

Base GPT-2 has no refusal or sycophancy behaviour to steer, which is why Steerscope runs a real instruct model (Qwen2.5-0.5B-Instruct, Apache-2.0).


How it works

flowchart LR
  P[Your prompt] --> T[Tokenizer + chat template]
  T --> M[Qwen2.5-0.5B-Instruct on WebGPU]
  M -->|residual stream, every layer| L[Logit lens]
  L --> Ladder[Per-layer prediction ladder]
  S[Steering sliders] -->|CAA vector| INJ[Add into residual stream at layer k]
  INJ --> M
  M -->|next token| Stream[Continuation]
Loading

The logit lens

At every layer the model holds a running guess of the next token in its residual stream. The logit lens reads that guess out directly by applying the model's final normalisation and unembedding to an intermediate layer's hidden state:

logits_L = FinalRMSNorm(h_L) @ W_U^T

The detail that matters: you apply the model's single final RMSNorm (not a per-block norm), and because Qwen ties its embeddings, the unembedding W_U is just the embedding matrix. Skip the final norm and the numbers are garbage. The ladder shows the top prediction at each layer and marks the decision layer, the shallowest layer whose guess already equals the final answer. Early layers are faded because the raw logit lens is genuinely unreliable there.

Activation steering

For each behaviour we compute a direction offline as the mean difference in residual activations between contrastive prompt pairs (harmful vs harmless for refusal, positive vs negative for sentiment, and so on). This is Contrastive Activation Addition. At inference we add a scaled copy of that unit direction into the residual stream at a single mid-depth layer:

h_k  ->  h_k + alpha * residualNorm_k * v_behaviour

Scaling by the layer's residual norm (norm matching) makes the coefficient mean the same thing everywhere, which keeps a small model coherent while it is being steered. A coefficient of zero is a provable no-op. Push past the usable band and the UI marks the "incoherence zone" where the output degrades, which is honest about how steering actually behaves.


The two-track inference design

flowchart TB
  subgraph Worker[Single Web Worker]
    direction TB
    Tok[transformers.js tokenizer]
    A[Track A: transformers.js generate on stock q4f16]
    B[Track B: instrumented ONNX graph via onnxruntime-web]
    Tok --> A
    Tok --> B
  end
  A -->|fast mode, day-one baseline| UI
  B -->|per-layer lens + steering input| UI[React visualisations]
Loading
  • Track A is the proven baseline and permanent fast mode: stock onnx-community/Qwen2.5-0.5B-Instruct q4f16 (~483 MB) through the standard transformers.js pipeline. It gives real, live generation immediately.
  • Track B is the wow: one custom instrumented ONNX graph that emits the per-layer logit-lens top-k in-graph (reusing the tied unembedding, so nothing extra is downloaded) and accepts a steering vector added into the residual stream at decode positions only, so re-steering skips the prompt prefill and stays live.

The instrumented graph is produced by pipeline/export_model.py and validated end to end: it exposes steering_vec and steering_scale inputs and lens_ids/lens_probs [1, 25, 10] outputs, and steering_scale = 0 is a bit-exact no-op (see pipeline/tests/test_onnx_export.py). The remaining work is loading it in the browser worker, not building it.


Built from scratch, and proven

A reviewer's first question is "how much of this did you actually implement?" Here is the load-bearing engineering and exactly how each piece is tested.

From scratch Where How it is proven
Logit-lens math (RMSNorm, tied unembed, softmax, top-k) lib/inference/logitLensCpu.ts Parity test against a numpy reference to 1e-9, and against real model activations
CAA steering composition, norm-matched, provable no-op lib/inference/steering.ts Unit tests: exact zero no-op, monotonic magnitude, sign flip, dimensional guards
Instrumented ONNX export (layer taps + steering input + in-graph lens) pipeline/export_model.py onnx.checker + inspectable in Netron
CAA direction extraction pipeline/compute_steering.py numpy tests on the mean-difference recipe
Colourblind-safe heatmap ramp lib/viz/heat.ts Unit tests for monotonicity and clamping

The math is implemented twice on purpose: once in numpy (the pipeline reference) and once in TypeScript (the browser). The tests assert the two agree. Two independent implementations converging is the strongest evidence the math is right, not just plausible.

npm test            # TypeScript parity + steering + heatmap tests
cd pipeline && pytest tests -q   # numpy reference tests (no torch needed)

Measured: the coherence cliff

Steering is not free. pipeline/eval_steering.py sweeps the refusal coefficient over held-out benign prompts and measures the effect. The result below is real, run on Qwen2.5-0.5B-Instruct, not an illustration.

Refusal rate and perplexity vs steering coefficient

Two honest readings of this curve:

  • The coherence cliff is clear. Mean perplexity climbs from about 13 at coefficient 0 to well over 300 by coefficient +2, which is exactly why the UI marks an "incoherence zone". The steering vector is having a large, measurable effect on the model.
  • Inducing refusal on already-benign prompts is hard on a 0.5B model. With this quick difference-of-means recipe at layer 10, positive steering mostly degrades coherence rather than cleanly triggering refusals. The literature (Arditi et al.) selects the direction's layer and token position on a validation set, and reports that suppressing refusal is easier than inducing it. Wiring in that selection is the documented next step, and this is the kind of result that only shows up when you actually measure instead of asserting.

Honest status

The load-bearing engineering is real and tested right now. The live demo drives the ladder and sliders with a clearly-labelled preview engine while the instrumented model is wired into the same interface. Nothing here is presented as measured that has not been measured.

  • Done and tested: the from-scratch logit lens (checked against the real Qwen model at every layer, see lib/inference/__tests__/realLens.test.ts), the steering composition, the interactive ladder and sliders, WebGPU detection, accessibility, and the full offline pipeline.
  • Real artifacts produced and validated by the pipeline: golden fixtures from the real model weights (test/fixtures/real_lens_fixture.json), Contrastive Activation Addition steering vectors (pipeline/artifacts/steering.json), the measured refusal/perplexity curve above, and the instrumented ONNX graph whose I/O and provable no-op are checked in pipeline/tests/test_onnx_export.py.
  • Wiring in: loading that instrumented graph in the browser worker so the ladder reads the real model's layers live in the page. The graph exists and is tested; this is a last-mile integration, not new research.

Run it locally

npm install
npm run dev            # http://localhost:3000
npm run verify         # typecheck + lint + test + build

The core demo needs no environment variables and no server. The optional "explain this in plain English" narrator is off by default and bring-your-own-key; see .env.example.

Repository layout

app/                 Next.js 16 App Router pages, the OG image
components/           Hero, the demo, the visualisations
lib/inference/        engine interface, logit lens, steering, backend detection
lib/viz/              heatmap colour ramp
pipeline/             offline: ONNX export, steering vectors, fixtures, eval
test/fixtures/        committed golden fixtures for the parity tests

References

  • Logit lens: nostalgebraist, "interpreting GPT: the logit lens" (2020).
  • Tuned lens: Belrose et al., "Eliciting Latent Predictions from Transformers with the Tuned Lens" (2023).
  • Steering: Rimsky et al., "Steering Llama 2 via Contrastive Activation Addition" (2023).
  • Refusal direction: Arditi et al., "Refusal in Language Models Is Mediated by a Single Direction" (2024). Note that later work argues refusal is not always a single direction; Steerscope treats the single-direction result as a strong effect, not a universal law.

License

Source code is MIT (see LICENSE). The model weights derived by the pipeline are Apache-2.0 (Qwen2.5); see NOTICE for attribution and dataset provenance.

Built by Harry Miller.

About

In-browser LLM interpretability: from-scratch logit lens and activation steering on WebGPU

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors