Network dissection: label units with CLIP-Dissect¶
CLIP-Dissect (Oikarinen and Weng, 2023, "CLIP-Dissect: Automatic Description of Neuron Representations in Deep Vision Networks", ICLR, https://arxiv.org/abs/2204.10965) names what a unit responds to without labelled data. Run a probe set of images through the network and record each unit's mean activation per image; ask CLIP how well each image matches each word of a concept set; then score every pair of unit and concept by how strongly the unit's top images match the concept, compared with how strongly every unit's top images do. The unit's label is its best-scoring concept.
TorchLens does the network side: tl.extract_dataset runs the probe set and
pools each unit over space, and tl.stats.TopK ranks each unit's top images
with their example ids. CLIP's side is any CLIP model; this page builds a
stand-in from the same concepts the probe set contains, so the labels can be
checked. The score is the paper's soft-WPMI, below as plain PyTorch.
pip install torchlens
pip install "open_clip_torch==3.3.0" # for real use only; the stand-in needs neither
Minimal example on the running model¶
This page uses ResNet-18 on one
image. Dissection labels the units
of one layer from their activations; these are the units of layer4. The recipe below builds
its own model for the method.
# recipe-test: tool=torchlens 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)
units = trace["layer4"].out
RESULT = tuple(units.shape) == (1, 512, 7, 7)
A probe set with planted concepts¶
Ninety 16 by 16 images: each is tinted red, green or blue, and carries horizontal stripes, vertical stripes or no texture, with a little noise. The concept set adds two words no image shows, "dots" and "grey". The network's first convolution has five planted units, one per concept present: three colour-opponent units and two edge units.
# recipe-test: tool=torchlens expected=(90, ['input_1', 'conv2d_1_1', 'relu_1_2', 'mean_1_3', 'linear_1_4', 'output_1'])
import torch
from torch import nn
import torchlens as tl
CONCEPTS = ["red", "green", "blue", "horizontal stripes", "vertical stripes", "dots", "grey"]
PLANTED = ["red", "green", "blue", "horizontal stripes", "vertical stripes"]
TEXTURES = ["horizontal stripes", "vertical stripes", None]
generator = torch.Generator().manual_seed(0)
bands = (torch.arange(16) % 4 < 2).float()
images, present = [], torch.zeros(90, len(CONCEPTS))
for index in range(90):
tint, texture = index % 3, TEXTURES[(index // 3) % 3]
image = 0.1 * torch.rand(3, 16, 16, generator=generator)
image[tint] += 0.6
present[index, tint] = 1.0
if texture is not None:
image += 0.3 * (bands[:, None] if texture == "horizontal stripes" else bands[None, :])
present[index, CONCEPTS.index(texture)] = 1.0
images.append(image)
probe = torch.stack(images)
class Probed(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(3, 5, 3)
self.head = nn.Linear(5, 10)
weight = torch.zeros(5, 3, 3, 3)
for colour in range(3): # tint minus the other two channels
weight[colour, :, 1, 1] = -0.5
weight[colour, colour, 1, 1] = 1.0
weight[3, :, 0, 1], weight[3, :, 2, 1] = 1.0, -1.0 # a change down the rows
weight[4, :, 1, 0], weight[4, :, 1, 2] = 1.0, -1.0 # a change across the columns
with torch.no_grad():
self.conv.weight.copy_(weight)
self.conv.bias.zero_()
def forward(self, x):
return self.head(torch.relu(self.conv(x)).mean(dim=(2, 3)))
torch.manual_seed(0)
model = Probed().eval()
labels = tl.trace(model, probe[:1]).layer_labels
RESULT = (len(probe), labels)
Extract and rank¶
The units are the outputs of relu_1_2, a functional op. pool="spatial_mean"
averages each unit over space, CLIP-Dissect's default pool_mode="avg".
TopK(k=10, unit_axis=1) keeps each unit's ten most active probe images,
highest first; the paper uses the top 100 of a larger probe set.
# recipe-test: tool=torchlens expected=((90, 5), (5, 10))
activations = tl.extract_dataset(
model, probe, ["relu_1_2"], batch_size=32, pool="spatial_mean", progress=False
)["relu_1_2"]
top = tl.stats.TopK(k=10, unit_axis=1)
top.update(activations)
ranked = top.compute()
print(ranked)
for unit in range(5):
print(f"unit {unit}: top images {ranked.example_ids[unit, :5].tolist()}")
RESULT = (tuple(activations.shape), tuple(ranked.example_ids.shape))
UnitTopK(units=5, k=10, position_axes=0, unit_axis=1)
unit 0: top images [57, 33, 48, 9, 69]
unit 1: top images [43, 76, 34, 79, 55]
unit 2: top images [71, 65, 86, 68, 41]
unit 3: top images [28, 74, 54, 55, 2]
unit 4: top images [57, 39, 68, 84, 77]
CLIP's side: image and concept similarity¶
CLIP-Dissect needs one matrix: the cosine similarity of every probe image's CLIP embedding with every concept's text embedding. The stand-in embeds each concept as one of seven orthonormal directions; an image's embedding is the sum of the directions of the concepts it shows plus noise, and a concept's text embedding is its direction plus a little noise.
# recipe-test: tool=torchlens expected=(90, 7)
def concept_similarity(image_features, text_features):
"""Cosine similarity of every image with every concept: [images, concepts]."""
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
text_features = text_features / text_features.norm(dim=-1, keepdim=True)
return image_features @ text_features.T
clip_generator = torch.Generator().manual_seed(2)
directions = torch.linalg.qr(torch.randn(32, len(CONCEPTS), generator=clip_generator))[0].T
image_features = present @ directions + 0.5 * torch.randn(90, 32, generator=clip_generator)
text_features = directions + 0.1 * torch.randn(len(CONCEPTS), 32, generator=clip_generator)
similarity = concept_similarity(image_features, text_features)
RESULT = tuple(similarity.shape)
Score with soft-WPMI and label¶
The paper's soft-WPMI, as its reference code computes it
(https://github.com/Trustworthy-ML-Lab/CLIP-dissect, similarity.py,
soft_wpmi). For unit k and concept m: the softmax over concepts of a
times the similarity gives P(m | x) for each image; an image at rank r of
the unit's top K belongs to the unit's set with probability falling linearly
from p_start = 0.998 to p_end = 0.97; the log of the soft-set probability is
log P(m | k) = sum over the top images of log(1 + p_r (P(m | x_r) - 1) + 1e-7);
the concept's prior log P(m) is the log of the mean of P(m | k) over every
unit of the layer; and the score is log P(m | k) - lambda log P(m), with
a = 10 and lambda = 1:
# recipe-test: tool=torchlens expected=True
import math
def soft_wpmi(similarity, top_ids, *, a=10.0, lam=1.0, p_start=0.998, p_end=0.97, min_prob=1e-7):
"""CLIP-Dissect's soft-WPMI of every unit and concept: [units, concepts].
similarity: [images, concepts] cosine similarities; top_ids: [units, K] each
unit's top images, most active first (UnitTopK.example_ids).
"""
p_concept = torch.softmax(a * similarity.double(), dim=1)
k = top_ids.shape[1]
p_in_set = p_start - torch.arange(k, dtype=torch.float64) / k * (p_start - p_end)
soft = 1 + p_in_set[None, :, None] * (p_concept[top_ids] - 1) # [units, K, concepts]
log_given_unit = torch.log(soft + min_prob).sum(dim=1)
log_prior = torch.logsumexp(log_given_unit, dim=0) - math.log(log_given_unit.shape[0])
return log_given_unit - lam * log_prior
scores = soft_wpmi(similarity, ranked.example_ids)
best = scores.argmax(dim=1)
labels = [CONCEPTS[index] for index in best.tolist()]
for unit, label in enumerate(labels):
print(f"unit {unit}: {label} ({float(scores[unit].max()):.2f})")
RESULT = labels == PLANTED
unit 0: red (1.61)
unit 1: green (1.61)
unit 2: blue (1.61)
unit 3: horizontal stripes (1.61)
unit 4: vertical stripes (1.54)
Each planted unit gets its planted concept, and neither absent concept labels a unit. The prior is a mean over the layer's units, so a concept that many units' top images share scores lower for each of them: score a whole layer at once, as the reference code does, and read each label with its score beside it.
Real use: a torchvision model with OpenCLIP¶
Not run here: the weights and the probe set download. Any probe set works; the
paper uses Broden or the ImageNet validation set, and the concept set is a word
list (the paper's 20,000 most common English words, or a dataset's class
names). The CLIP side is OpenCLIP's ViT-B/32; the network is a torchvision
ResNet-18, dissected at layer4:
import open_clip
import torch
from torchvision.datasets import CIFAR10
from torchvision.models import ResNet18_Weights, resnet18
import torchlens as tl
clip, _, clip_preprocess = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
tokenizer = open_clip.get_tokenizer("ViT-B-32")
weights = ResNet18_Weights.IMAGENET1K_V1
net = resnet18(weights=weights).eval()
pictures = [image for image, _ in CIFAR10("data", train=False, download=True)][:1000]
concepts = [line.strip() for line in open("concepts.txt") if line.strip()]
with torch.no_grad():
image_features = clip.encode_image(torch.stack([clip_preprocess(p) for p in pictures]))
text_features = clip.encode_text(tokenizer(concepts))
similarity = concept_similarity(image_features.float(), text_features.float())
activations = tl.extract_dataset(
net, torch.stack([weights.transforms()(p) for p in pictures]), ["layer4"],
batch_size=64, pool="spatial_mean", progress=False,
)["layer4"]
top = tl.stats.TopK(k=100, unit_axis=1)
top.update(activations)
scores = soft_wpmi(similarity, top.compute().example_ids)
labels = [concepts[index] for index in scores.argmax(dim=1).tolist()]
top.compute().example_ids[unit] are the probe images to look at beside each
label. The soft-WPMI step needs only each unit's top image ids and the similarity
rows of those images, so the activations can be ranked in batches with repeated
top.update(...) calls instead of held all at once.