Watch your model think while your app talks to it
Project description
brainscope
pip install brainscope
Watch your model think while your app talks to it.
An OpenAI-compatible chat server over any Hugging Face model with a live view into its residual stream — logit/tuned/J-lens readouts, attention, and per-request steering. For anyone building on an LLM who wants to see what the model is doing (and steer it) without leaving their own OpenAI client.
⚡ Run in 30 s (laptop, no GPU)
pip install -e . && brainscope --model tiny # 0.5B model, CPU is fine
Open the page it launches, type in the built-in chat box, and watch each word surface through the layers before the model writes it. No app to wire up, no card required. (full quickstart ↓)
An OpenAI-compatible chat server over any Hugging Face causal LM with a live view into the residual stream. What it does:
- Point your own app at it and watch real traffic — no code changes:
aim your OpenAI
base_urlat brainscope and every generation your app makes streams per-token, per-layer activity into the browser — logit lens, attention, and where each word's prediction settled. - Steer behaviour live — extract a direction from contrast pairs and
drive it from a slider, per request, or by a tag-matched policy. Speaks
hotwire-vllm's steering spec
(
{"id", "layer", "scale", "decode_only"}, incl. thevllm_xargswire format) — calibrate here, deploy there:python -m brainscope.export_hotwireships vectors with a regime passport, andPOST /replayA/Bs any conversation under a spec, showing per-layer cosine and which words the vector suppressed (docs). - Watch words surface before they're written — a J-lens (Jacobian lens, Anthropic 2026) readout next to the logit lens: words represented and pushed toward future output before — or without — being emitted. Type a word to turn it into a steering vector and nudge what the model is disposed to say.
- Inspect reasoning traces — every generation can be persisted, replayed
token by token, and analyzed: when did the answer emerge inside the
<think>block, and which lens saw it first? - Audit baked personas — serve a model that carries a baked-in covert advocate and the per-layer cosines expose it, token by token (details ↓).
flowchart LR
app["Your app<br/>(any OpenAI client)"] <-->|"base_url → /v1"| bs["brainscope<br/>server"]
bs <--> model["HF model<br/>(transformers)"]
model -. "residual stream" .-> ui["Browser viz 👁"]
Built at Lifeheck while evaluating local models for a Czech agentic assistant - thanks to the whole team for the playground. 💛
Docs: Steering · Auditing baked personas · J-lens · Reasoning traces
Quickstart
git clone https://github.com/moudrkat/brainscope && cd brainscope
pip install -e . # needs Python 3.11+
brainscope --model tiny # 0.5B, runs on CPU - good first try
# → your app: http://<host>:8010/v1 (chat completions, incl. tool calls)
# → your eyes: http://<host>:8010 (opens automatically)
No app handy? The viz page has a built-in chat box - type and watch.
Or skip Python entirely and run the Docker image:
docker run -p 127.0.0.1:8010:8010 -v ~/.cache/huggingface:/root/.cache/huggingface \
ghcr.io/moudrkat/brainscope:cpu
Anything after the image name goes to the brainscope CLI. With an NVIDIA GPU
(and nvidia-container-toolkit) use the :cuda tag and bigger models:
docker run --gpus all -p 127.0.0.1:8010:8010 -v ~/.cache/huggingface:/root/.cache/huggingface \
ghcr.io/moudrkat/brainscope:cuda --model qwen3-4b
The cache mount keeps downloaded model weights on your disk, so they survive
container restarts. The 127.0.0.1: binding keeps the port private to your
machine — brainscope has no auth, and Docker port mappings bypass ufw-style
firewalls, so only drop it (-p 8010:8010) on a network you trust.
--model takes any Hugging Face model id, plus presets: tiny
(Qwen2.5-0.5B, CPU-friendly), qwen3-4b, qwen3-8b, qwen3.5-9b,
gemma-e4b. Bigger models fit a 16 GB card with --quantize 8bit.
Pointing your app at it is one line - wherever it builds its OpenAI client:
client = OpenAI(base_url="http://localhost:8010/v1", api_key="unused")
What am I looking at?
Left: the model itself - the prompt enters at the bottom, one clickable row per decoder layer, the next word exits at the top (lm_head). On the right, the instruments:
- activity over time - one column per generated token, one row per layer, color = how loudly that layer works relative to its own average.
- neurons (click any cell of the heatmap) - the number un-collapsed: every residual channel behind that (token, layer) cell as a bipolar bar strip, with per-generation z-scores flagging the channels that are unusual now rather than just always loud. Hover a bar for channel, value and z; "follow live" tracks the newest token while generating.
- attention - for the clicked layer: what each answer token looks back at; heads splits the newest token per attention head.
- logit lens (click lm_head) - every layer's next-token readout: watch
the answer crystallize with depth. Hover a cell for the top-5 candidates,
click to pin the tooltip. With
--tlensit reads through a tuned lens (Belrose et al. 2023, trained per-layer corrections), which removes the raw lens's mid-stack noise — same grid, honest depths. - J-lens (with
--jlens) - the same grid, but reading what each layer is disposed to make the model say later - words visible before they are emitted, see below. - traces (with
--traces) - stored generations: replay any of them with a scrubber and chart when the answer emerged inside the think block. - the answer text is an instrument too - each word is tinted by the layer where its prediction settled (clean = early, amber = late, red = never before lm_head); hovering shows what the model almost said instead.
The ◉ capture button pauses the instruments when you just want fast generation; ● record exports a WebM, PNG saves a snapshot. In the header, ⏻ switches steering on and off without losing the strength and layer settings - instant A/B.
An example of what the lens view can catch:
Qwen3-4B writing the Czech word "zážitkům" (experiences). Mid-stack readouts decode the meaning - in English and Chinese - while the Czech surface form assembles only in the last few layers: the geometry of multilingual representations, studied properly in Wendler et al. 2024 (arXiv:2402.10588). Readouts are a raw logit lens, so mid-stack tokens are approximate.
Tuned lens (trusting the middle of the stack)
The raw logit lens reads every layer through the final head, which the model
never trained the middle layers for — mid-stack readouts are suggestive, not
reliable. A tuned lens (Belrose et al. 2023, arXiv:2303.08112) fixes
that with one trained affine correction per layer. Fit one with the
tuned-lens package
(hours, once per model), then point brainscope at it:
pip install tuned-lens
python -m tuned_lens train --model.name Qwen/Qwen3-4B-Instruct-2507 \
--data.name wikitext --data.config_name wikitext-103-raw-v1 \
--output lenses/qwen3-4b-tuned
brainscope --model qwen3-4b --tlens lenses/qwen3-4b-tuned/params.pt
The lens tab relabels itself "tuned lens" and everything downstream — the
per-word settle depths in the answer text, the recorded trace readouts —
reads through the translators. One extra hidden × hidden matmul per layer
per token.
J-lens (reading ahead of the output)
The logit lens reads what each layer would say now; the J-lens (Jacobian lens — Anthropic, A global workspace in language models, 2026) reads what it is disposed to make the model say later: words visible in the activations before any of them are emitted — violet cells in the grid are words that really arrive later in the answer. Type a word and it becomes a steering vector: nudge what the model is disposed to say, with the same panel as the readout.
brainscope-jlens fit --model qwen3-4b --prompts wikitext --out lenses/qwen3-4b.pt
brainscope --model qwen3-4b --jlens lenses/qwen3-4b.pt --traces traces/
Fit once per model (minutes on a GPU, reproducible: examples/fit_jlens.sh); the readout is cheap and switchable live.
Qwen3-4B writing "The capital of France is Paris." — violet cells are words the J-lens read out before they were emitted: while the model is writing " of", the readout already shows " France" and " Paris", the words that complete the sentence (the "saw coming" line under each column sums this up). Verify any of it against the trace with examples/audit_jlens_hits.py.
→ docs/jlens.md - method, fitting, health checks, steering × J-lens, the experimental A-lens, limitations, licensing.
Reasoning traces
With --traces DIR every generation is persisted and replayable token by
token — the <think> block segmented out, both lens columns per step, and
an answer-emergence chart: for the token that opens the final answer
(or any word you click), its probability at every reasoning step, under
each lens. When did the answer settle, and which lens saw it first?
Replaying a trace, tracking " Paris". The leftmost green bump is the point: while the model is still writing " of", the J-lens already reads " Paris" at p ≈ 0.4 — the logit lens sits at ~0.0002 there and only spikes once the word is actually being written. Later peaks coincide by nature: whenever the word is being emitted, both lenses see it.
→ docs/traces.md - replay, the emergence chart and its honest limits, the API, and storage costs.
Steering
Extract a direction from contrast pairs and drive it live - activation
addition (Turner et al., arXiv:2308.10248) on real traffic: from a slider,
per request, or by a tag-matched policy so the app stays steering-agnostic.
Steer your app's own requests, not just the built-in chat box - the
same per-request extra_body scopes a vector to one call and leaves every
other agent on the server untouched.
flowchart LR
pairs["contrast pairs<br/>+ / − completions"] -->|"extract<br/>mean-diff / PCA"| vec["steering vector<br/>+ layer & strength"]
vec -->|"add mid-stack"| out["shifted<br/>behaviour"]
The best source of vectors is the sister repo
hidden-directions: 40
pre-verified directions to load and use as-is, plus the pipeline to extract
more and the references behind the method - often the better place to start
than a blank pairs.jsonl (see Auditing baked personas).
Steering also goes agent-to-agent: the /capture endpoint reads one
agent's residual-stream state so another can be steered by it — that
experiment lives in steeropathy
(agents steering agents), with brainscope as the window into what it does
to the model.
Extraction quality decides everything and over-steering quietly breaks the model, so before steering anything real read the full guide:
→ docs/steering.md - the two extractors, the live API (
/steer, per-request, policies), a real case study, and the lessons we learned the hard way.
Auditing baked personas
A 9 KB weights patch - one MLP bias - can turn a model into a covert advocate
(a flat-earther, a sycophant) with no runtime steering active. brainscope
is the live half of that audit: serve the baked model, load the persona
catalogue from
hidden-directions, and watch
v_pref_flat_earth light up token by token from the baked layer on. The same
40-direction catalogue doubles as the pre-verified vector library for
steering.
→ docs/auditing.md - the
--bakewalkthrough, the dictionary, and stacking vectors to re-create a bake recipe live.
Will it work with my app?
Works when your app talks the OpenAI chat-completions API with
non-streaming responses (stream: true not supported yet); tool calls
are parsed in hermes/qwen, gemma-fenced and plain-JSON formats, and
tool_choice: "required" (or a named function) is enforced by seeding the
generation with the opening of a tool call in the model's own format.
Honest limitations: generation runs on plain transformers - tens of tokens
per second, one request at a time, no auth, context bounded by VRAM. Why not
vLLM? vLLM is a black box by design - per-layer states are consumed the
moment they're produced; transformers exposes them for every architecture
with one flag. That's the trade: brainscope is slower, but it sees
everything. It's a lab instrument for development - run it next to
production, not instead of it.
Where this sits in the lab
flowchart LR
hd["🧭 hidden-directions<br/>behavior → vector"]
bs(["🧠 brainscope<br/>watch the model think"])
hw["🔥 hotwire-vllm<br/>steering in production"]
st["🕹️ steeropathy<br/>agents talk via activations"]
tm["⚖️ in-two-minds<br/>agent hesitating between tools"]
sm["🧪 steering-mechanics<br/>how steering actually works"]
hd -->|vectors| bs
hd -->|vector + passport| hw
bs --> st
bs --> tm
bs -->|causal replay| sm
hw -.->|vector under study| sm
click hd "https://github.com/moudrkat/hidden-directions"
click bs "https://github.com/moudrkat/brainscope"
click hw "https://github.com/moudrkat/hotwire-vllm"
click st "https://github.com/moudrkat/steeropathy"
click tm "https://github.com/moudrkat/in-two-minds"
click sm "https://github.com/moudrkat/steering-mechanics"
classDef dim fill:#f6f8fa,stroke:#d0d7de,color:#57606a;
classDef here fill:#8957e5,stroke:#6e40c9,color:#ffffff;
class hd,bs,hw,st,tm,sm dim;
class bs here;
Highlighted = this repo. The full lab map (with the two other repos' stories) lives on moudrkat.
brainscope is the middle of a three-repo stack; each piece also runs alone:
- hidden-directions — the direction catalogue: extract, bake, and audit steering directions, per model. Where the vectors come from.
- brainscope (you are here) — the instrument: hosts the model, captures activations, steers at runtime, reads the J-lens, keeps traces.
- steeropathy — the lab on top: agents that communicate through activations and J-space instead of text, brainscope as their only channel.
Standing on shoulders
The instruments implement or adapt published techniques - the credit belongs with the originals:
- Logit lens - nostalgebraist, interpreting GPT: the logit lens (LessWrong, 2020); the cleaned-up successor is the tuned lens, Belrose et al. (arXiv:2303.08112).
- Jacobian lens / J-space - Gurnee, Sofroniew et al. (Anthropic),
Verbalizable Representations Form a Global Workspace in Language Models
(Transformer Circuits Thread, 2026;
announcement: A global workspace in language models);
reference implementation
anthropics/jacobian-lens
(Apache-2.0). brainscope's
jlens.pyis an independent reimplementation from the paper (full citation + BibTeX in docs/jlens.md); the A-lens ("answer lens") variant is a brainscope experiment on top of their estimator, not part of the published work. - Workspace decomposition - the sparse nonnegative recipe is from the same Gurnee et al. paper; the pursuit algorithm family is Blumensath & Davies, Gradient Pursuits (IEEE Trans. Signal Processing, 2008). Experimental — see docs/jlens.md.
- Concept-before-language - Wendler et al., Do Llamas Work in English? (arXiv:2402.10588).
- Activation steering - Turner et al., Zou et al., Rimsky et al.; cited in full in docs/steering.md.
- Attention aggregation - the "sources" view averages attention across layers; the principled cross-layer flow is Abnar & Zuidema (arXiv:2005.00928).
License
MIT © Kateřina Fajmanová
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file brainscope-0.1.1.tar.gz.
File metadata
- Download URL: brainscope-0.1.1.tar.gz
- Upload date:
- Size: 94.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
79f5069de169ff61824af4a1532c4384597fe15a1cb2f40cd619b562e2ae5b6d
|
|
| MD5 |
d3f71cfcc00db46d8dbc631e9ff7a0f4
|
|
| BLAKE2b-256 |
786b4ef0399180a9b2fbd1c1d7ed25ff3d4e60aa9b73be8dba71aca88baaff91
|
File details
Details for the file brainscope-0.1.1-py3-none-any.whl.
File metadata
- Download URL: brainscope-0.1.1-py3-none-any.whl
- Upload date:
- Size: 82.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1251cc8ef25d302ab604ab90a44103ea9630b1fe7ed6def50220f72baf27a9ab
|
|
| MD5 |
e9010ae1c4b62fd1a84c4d0eb97eada4
|
|
| BLAKE2b-256 |
4cc63ca40290636f960ac874fd2c92d7ef3e02b873d0933804e3abea10e57cf3
|