Skip to content

TCAV with a causal check

TCAV (Kim et al., 2018) asks whether a human concept matters to a class: learn a concept activation vector (CAV) at one layer, then count how often the class logit increases along it. A TCAV score alone is correlational. This recipe adds the causal check: steer the layer along the CAV and measure the change in the class score. TorchLens supplies the activations, the gradients and the steering; TCAV itself stays a recipe. For a packaged TCAV with its statistics, use Captum's captum.concept.TCAV. To find concepts instead of naming them, see CRAFT, which factorizes one layer's activations and ranks the factors by importance.

Minimal example on the running model

This page uses ResNet-18 on one image. TCAV learns a concept direction from one layer's activations; tl.extract returns layer3's for the running CNN. 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)

acts = tl.extract(model, x, ["layer3"])["layer3"]
RESULT = tuple(acts.shape) == (1, 256, 14, 14)

Setup: a model whose class depends on a known direction

The toy task makes the answer known: class 1 means the input points along a direction d, so "points along d" is a concept the model must use.

# recipe-test: tool=torchlens expected=True
import torch
from torch import nn

import torchlens as tl

torch.manual_seed(0)
d = torch.randn(8)
d = d / d.norm()
model = nn.Sequential(
    nn.Linear(8, 16), nn.ReLU(), nn.Linear(16, 16), nn.ReLU(), nn.Linear(16, 2)
)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
train_x = torch.randn(512, 8)
train_y = (train_x @ d > 0).long()
for _ in range(300):
    optimizer.zero_grad()
    nn.functional.cross_entropy(model(train_x), train_y).backward()
    optimizer.step()
model.eval()

test_x = torch.randn(256, 8)
accuracy = (model(test_x).argmax(1) == (test_x @ d > 0).long()).float().mean().item()
RESULT = accuracy > 0.9

1. Learn the CAV at a layer

Concept examples carry the direction; random examples do not. The CAV is the unit normal of a linear classifier separating their activations at site "1" (the first ReLU output). Concept examples are inputs drawn from the data distribution that point strongly along d; random examples are plain draws.

# recipe-test: tool=torchlens expected=True
SITE = "1"


def site_activations(x):
    return tl.extract(model, x, [SITE])[SITE]


def learn_cav(positive, negative, steps=300):
    """Unit normal of a logistic-regression boundary between two activation sets."""

    feats = torch.cat([positive, negative])
    labels = torch.cat([torch.ones(len(positive)), torch.zeros(len(negative))])
    w = torch.zeros(feats.shape[1], requires_grad=True)
    b = torch.zeros(1, requires_grad=True)
    opt = torch.optim.Adam([w, b], lr=0.05)
    for _ in range(steps):
        opt.zero_grad()
        nn.functional.binary_cross_entropy_with_logits(feats @ w + b, labels).backward()
        opt.step()
    return (w / w.norm()).detach()


pool = torch.randn(4000, 8)
concept_x = pool[pool @ d > 1.0][:100]  # in-distribution examples of the concept
random_x = torch.randn(100, 8)
cav = learn_cav(site_activations(concept_x), site_activations(random_x))
RESULT = cav.shape == (16,)

2. The TCAV score

The score is the fraction of class-1 inputs whose class-1 logit has a positive directional derivative along the CAV at the site. TorchLens saves the site's gradient during one backward pass.

# recipe-test: tool=torchlens expected=(True, True)
def tcav_score(direction, x, target=1):
    trace = tl.trace(model, x, capture=tl.options.CaptureOptions(save_grads=True))
    logits = trace[trace.output_layers[0]].out
    trace.backward(logits[:, target].sum())  # rows are independent: per-example grads
    grads = trace[SITE].grad
    return (grads @ direction > 0).float().mean().item()


class_x = test_x[test_x @ d > 0]
score = tcav_score(cav, class_x)

# Random-vs-random CAVs give the null distribution the score must stand out from.
null_scores = [
    tcav_score(learn_cav(site_activations(torch.randn(100, 8)), site_activations(random_x)), class_x)
    for _ in range(5)
]
RESULT = (score > 0.9, any(null > 0.9 for null in null_scores))

The concept CAV scores near 1, but so does at least one random CAV on this model: the gradient field at this layer points the same way for most directions, and the score alone does not separate the concept from noise. That is why the causal check follows.

3. The causal check

Steer the site along the CAV and read the class margin (class-1 logit minus class-0 logit). If the concept matters, adding the CAV raises the margin and subtracting it lowers it; a random direction of the same norm moves it much less (here, less than a third as far).

# recipe-test: tool=torchlens expected=True
def mean_margin(direction, magnitude, x):
    spec = tl.when(tl.module(SITE), tl.steer(direction, magnitude, dim=1))
    with torch.no_grad():
        logits = spec.bind(model)(x)
    return (logits[:, 1] - logits[:, 0]).mean().item()


margins = {m: mean_margin(cav, m, class_x) for m in (-2.0, 0.0, 2.0)}
random_direction = torch.randn(16)
random_direction = random_direction / random_direction.norm()
random_shift = abs(mean_margin(random_direction, 2.0, class_x) - margins[0.0])
cav_shift = margins[2.0] - margins[0.0]
RESULT = margins[-2.0] < margins[0.0] < margins[2.0] and cav_shift > 3 * random_shift

Limits

  • A high TCAV score with a failed causal check means the CAV tracks something the class correlates with but does not use at that layer; report both.
  • The CAV depends on the concept and random sets. Repeat with fresh random sets and report the spread, as the null scores above do once.
  • Steering adds the direction at every row of the batch. For token models, pick the positions explicitly.