Getting started

Install

entoscope depends on Python 3.11 to 3.14 and is published on PyPI:

pip install entoscope
# or, in a uv project
uv add entoscope

To work on entoscope itself, see Development.

Its main dependencies are PyTorch and transformer-lens 3 or later, which provides TransformerBridge, the wrapper these docs load models with.

Note

Intel macOS is the exception. PyTorch stopped publishing x86_64 macOS wheels after 2.2, which pins that platform to PyTorch 2.2, transformer-lens 2.15, numpy<2, transformers<5 and Python 3.12 or older, and the installer picks those versions there by itself. transformer-lens 2.15 has no TransformerBridge, so load models as HookedTransformer there (see Models and caching).

Load a model

Operations take a transformer_lens model. A TransformerBridge wraps a HuggingFace model as it is, so what entoscope reads is HuggingFace’s own computation:

from transformer_lens.model_bridge import TransformerBridge

model = TransformerBridge.boot_transformers("gpt2")
model.eval()

Make a first run

A logit lens reads the residual stream after every layer, at every position of a prompt, as if the model stopped there and predicted the next token:

from entoscope import (
    Lens,
    LensConfig,
    LensInput,
    LensMethodConfig,
    ModelAxis,
    ModelLayerLocation,
    ModelLocatorConfig,
)

config = LensConfig(
    method=LensMethodConfig(type="logit_lens"),
    top_k=3,
    source=ModelLocatorConfig(
        activation=ModelLayerLocation.RESIDUAL_POST,
        location=[ModelAxis.LAYER, ModelAxis.POSITION],
    ),
)
lens_input = LensInput(config, "The capital of France is")

Lens(model).apply(lens_input, save_dir="runs/first_lens")

The parts of that run:

  • LensConfig says what to run: the lens method, how many tokens to keep per row, and source, where in the model to read.

  • source is a locator: the resid.post activation at every layer and every position. That makes 12 × 6 rows for GPT-2 and this prompt (the tokenizer prepends a BOS token, so the prompt has 6 positions). Locating activations covers the locator in full.

  • LensInput pairs the config with the prompt it reads.

  • Lens(model).apply(...) runs the model once, reads every row out onto the vocabulary and writes the results into save_dir.

Read the results

The run leaves four files behind:

runs/first_lens/
├── result.html   an interactive page: layer up, position across, each cell's top token
├── result.csv    one row per (layer, position), with its top-3 tokens and their probabilities
├── index.csv     the rows the run swept
└── config.json   the config the run was made under

Open result.html in a browser; it needs no server and no network. result.csv is the same result as a table: the row’s location (source_layer, source_position, source_activation), then token_i, str_token_i and probability_i for each rank i:

import pandas as pd

result = pd.read_csv("runs/first_lens/result.csv", keep_default_na=False)
print(result[result.source_position == 5][["source_layer", "str_token_0", "probability_0"]])

keep_default_na=False keeps tokens such as "null" or "NA" from being read as missing values.

The same file from a shell: less -S pages through it without wrapping its long rows, and awk prints the header and the rows at position 5, the second column:

less -S runs/first_lens/result.csv
awk -F, 'NR == 1 || $2 == 5' runs/first_lens/result.csv

To keep the results in memory instead, pass return_collector=True. The collector’s result is the frame result.csv holds, its figures are the plots result.html holds, keyed by top-k rank, and save_dir can be left out to write nothing at all:

collector = Lens(model).apply(lens_input, return_collector=True, verbose=False)
print(collector.result.shape)
print(list(collector.figures))  # [0, 1, 2]

Each is a plotly Figure, so in a notebook collector.figures[0].show() draws the top-1 grid inline.

Plan before you run

Every operator can describe a run without making it. plan resolves the same rows apply would iterate and reports the work, the activations it would read and the files it would write, without a forward pass:

plan = Lens(model).plan(lens_input, save_dir="runs/first_lens")
Lens plan
  method: logit_lens
  prompt: "The capital of France is" (6 tokens)
  reads: blocks.0.hook_resid_post, blocks.1.hook_resid_post, blocks.10.hook_resid_post, blocks.11.hook_resid_post, blocks.2.hook_resid_post, blocks.3.hook_resid_post, blocks.4.hook_resid_post, blocks.5.hook_resid_post, blocks.6.hook_resid_post, blocks.7.hook_resid_post, +2 more
  work: 72 elements, 1 forward pass
  writes: runs/first_lens/{result.html, result.csv, index.csv, config.json}
  index (72 rows):
     source_layer  source_position source_activation
                0                0        resid.post
                0                1        resid.post
                0                2        resid.post
  (the remaining 69 rows of the index are printed too)

The plan is logged as it is built and returned as an OperationPlan you can read in code. See Dry runs.

Next steps