Skip to content

Prisma CLIP SAEs: ViT-Prisma loads, TorchLens splices

ViT-Prisma publishes sparse autoencoders for vision transformers, CLIP among them, on the Hugging Face Hub (for example Prisma-Multimodal/sparse-autoencoder-clip-b-32-sae-vanilla-x64-layer-11-hook_resid_post-l1-1e-05, trained on the residual stream after block 11 of OpenCLIP's ViT-B/32). ViT-Prisma loads the SAE; TorchLens traces the vision model, splices the SAE's reconstruction in at the block it was trained on with torchlens.bridge.sae.splice and reports what changed, as the pretrained-SAE recipe does for language models.

pip install torchlens open_clip_torch timm
pip install "vit-prisma @ git+https://github.com/Prisma-Multimodal/ViT-Prisma@771494b8181a0f7779fab19adb5342688f95c724"

Minimal example on the running model

This page uses ResNet-18 on one image. A vision SAE reads one layer's activations; these are layer4's on the running CNN. The recipe below builds its own model for the method.

# recipe-test: tool=prisma expected=True
import torch
import torchvision

import torchlens as tl

torch.manual_seed(0)
model = torchvision.models.resnet18(weights=None).eval()
x = torch.rand(1, 3, 224, 224)

trace = tl.trace(model, x)

acts = trace["layer4"].out
RESULT = acts.shape[1] == 512

Real use: a Prisma CLIP SAE

Not run here: the SAE (about 300 MB) and the CLIP weights download from the Hub. A Prisma SAE's forward returns the reconstruction and the latents together with its training losses, so a small adapter gives splice the encode and decode pair it expects. load_prisma_sae and PrismaCodec are defined and run in the stand-in section below:

import json

import open_clip
import torch
from huggingface_hub import hf_hub_download
from vit_prisma.sae.config import VisionModelSAERunnerConfig
from vit_prisma.sae.sae import SparseAutoencoder

from torchlens.bridge.sae import splice

repo = "Prisma-Multimodal/sparse-autoencoder-clip-b-32-sae-vanilla-x64-layer-11-hook_resid_post-l1-1e-05"
sae = load_prisma_sae(hf_hub_download(repo, "config.json"), hf_hub_download(repo, "weights.pt"))

clip, _, preprocess = open_clip.create_model_and_transforms(
    "ViT-B-32", pretrained="datacomp_xl_s13b_b90k"
)
vision = clip.visual.eval()
images = torch.stack([preprocess(image) for image in my_images])  # your PIL images
result = splice(vision, images, site="transformer.resblocks.11", sae=PrismaCodec(sae))
result.fraction_variance_explained_pooled, result.output_delta_max

Prisma's hook_resid_post at layer L is the output of OpenCLIP's transformer.resblocks.L (blocks.L in a timm ViT); hook_mlp_out is the output of that block's mlp.

The class token

A ViT's activations are [images, tokens, features] with the class token at position 0. The Prisma CLIP SAEs above were trained on every token (cls_token_only: false in their config.json), so the recipe splices every position, the class token included, which is splice's default. exclude_bos=True treats position 0 as a language model's BOS token: it keeps the class token clean and leaves it out of every metric, which suits an SAE trained on patch tokens only.

The same steps on a stand-in

Offline: a two-block timm ViT with random weights and a small ViT-Prisma SparseAutoencoder with seeded weights, written as the Hub repos are laid out: a config.json of the SAE's settings and a weights.pt holding only its four tensors (W_enc, b_enc, W_dec, b_dec). The published files were saved from a GPU, and Prisma's own load_from_pretrained expects a pickled config inside weights.pt and no device mapping, so on a CPU machine load_prisma_sae builds the SAE from config.json and loads the tensors with torch.load(..., map_location="cpu", weights_only=True).

# recipe-test: tool=prisma expected=('SparseAutoencoder', 64, (2, 17, 16), True)
import contextlib
import io
import json
import tempfile
from pathlib import Path

import torch
from timm.models.vision_transformer import VisionTransformer
from vit_prisma.sae.config import VisionModelSAERunnerConfig
from vit_prisma.sae.sae import SparseAutoencoder


def load_prisma_sae(config_path, weights_path):
    """A Prisma SAE from its Hub files, on the CPU."""
    settings = json.loads(Path(config_path).read_text())
    known = VisionModelSAERunnerConfig.__dataclass_fields__
    with contextlib.redirect_stdout(io.StringIO()):  # Prisma prints its config
        cfg = VisionModelSAERunnerConfig(
            **{k: v for k, v in settings.items() if k in known and not isinstance(v, dict)}
        )
        cfg.device = "cpu"
        sae = SparseAutoencoder(cfg)
    sae.load_state_dict(torch.load(weights_path, map_location="cpu", weights_only=True))
    return sae.eval()


class PrismaCodec:
    """A ViT-Prisma SparseAutoencoder as the encode/decode pair splice expects."""

    def __init__(self, sae):
        self.sae = sae

    def encode(self, x):
        return self.sae(x)[1]  # forward returns (reconstruction, latents, losses...)

    def decode(self, latents):
        out = latents @ self.sae.W_dec + self.sae.b_dec
        return self.sae.run_time_activation_norm_fn_out(out)


torch.manual_seed(0)
vit = VisionTransformer(
    img_size=32, patch_size=8, embed_dim=16, depth=2, num_heads=2, num_classes=10
).eval()
images = torch.randn(2, 3, 32, 32, generator=torch.Generator().manual_seed(1))

# Write a stand-in SAE in the Hub layout: config.json and a tensors-only weights.pt.
folder = Path(tempfile.mkdtemp())
settings = {"d_in": 16, "expansion_factor": 4, "normalize_activations": None,
            "activation_fn_str": "relu", "b_dec_init_method": "zeros", "log_to_wandb": False}
(folder / "config.json").write_text(json.dumps(settings))
generator = torch.Generator().manual_seed(2)
tensors = {"W_enc": (16, 64), "b_enc": (64,), "W_dec": (64, 16), "b_dec": (16,)}
torch.save({name: 0.3 * torch.randn(shape, generator=generator) for name, shape in tensors.items()},
           folder / "weights.pt")

sae = load_prisma_sae(folder / "config.json", folder / "weights.pt")
first_block, second_block = vit.blocks
with torch.no_grad():
    tokens = vit._pos_embed(vit.patch_embed(images))  # class token and 16 patches
    block_out = second_block(first_block(tokens))
RESULT = (type(sae).__name__, sae.d_sae, tuple(block_out.shape), sae.cfg.normalize_activations is None)

Splice at the block

The site is the output of block 1, [images, 17 tokens, 16 features]: the class token and 16 patches. The splice's reconstruction equals Prisma's own forward on the same activations:

# recipe-test: tool=prisma expected=(True, True, True, False)
import torchlens as tl
from torchlens.bridge.sae import splice

trace = tl.trace(vit, images)
result = splice(trace, site="blocks.1", sae=PrismaCodec(sae))
with torch.no_grad():
    own_reconstruction, own_latents = sae(result.clean_site_out)[:2]
RESULT = (
    bool(torch.allclose(result.clean_site_out, block_out, atol=1e-6)),
    bool(torch.allclose(result.reconstruction, own_reconstruction, atol=1e-6)),
    bool(torch.equal(result.latents, own_latents)),
    result.zero_variance,
)