Dictionary of Applied Machine Learning · concept activation vector

concept activation vector — Python demo

Numerical companion to the entry concept activation vector: it recomputes what the entry states and prints one line per check

A CAV is fitted the way the entry describes it, in a plane a reader can look at: the activations of two neurons of one hidden layer. A deep net separates two classes in that plane by a decision boundary that is far from a straight line, while the concept ("stripes") is separated from the non-concept examples by a hyperplane whose normal vector is the CAV. The two boundaries are different objects, and drawing them in the same plane is what the entry's figure does.

Run it with python3 pythondemos/cav.py, from the repository root — it writes its data files under pythondemos/. Requires NumPy and Matplotlib only, and uses fixed seeds, so the printed numbers reproduce exactly. Download cav.py

The script, block by block

One cell per block of the script: the code, and what that code printed when it last ran here

setup

"""
cav.py — numerical companion to the glossary entry 'concept activation
vector (CAV)'.

Purpose
-------
A CAV is fitted the way the entry describes it, in a plane a reader can
look at: the activations of two neurons of one hidden layer.  A deep net
separates two classes in that plane by a decision boundary that is far
from a straight line, while the concept ("stripes") is separated from the
non-concept examples by a hyperplane whose normal vector is the CAV.  The
two boundaries are different objects, and drawing them in the same plane
is what the entry's figure does.

The activations are synthetic, since a real network's layer has hundreds
of neurons and no two of them span a plane worth drawing.  Everything
computed from them is real: the linear classifier is fitted to the
supplied examples, the CAV is its weight vector, and the conceptual sensitivity is
the directional derivative of the network's score along that vector.

Self-contained (numpy + matplotlib only), fixed seed.

Blocks
------
[B-plane]  The plane of activations: two classes whose boundary curves,
           and a concept that occupies a half-plane.  A linear classifier
           fitted to the CLASS labels does poorly, which is what makes
           the network's boundary nonlinear rather than a line in
           disguise.
[B-cav]    The CAV: a binary linear classifier separating concept from
           non-concept activations, fitted to the supplied examples.
           It
           reaches high accuracy, and its weight vector -- the CAV -- is
           the normal of the separating hyperplane.
[B-tcav]   Conceptual sensitivity: the directional derivative of the
           network's score along the unit CAV, averaged over the data
           points of one class.  The concept the network uses raises the
           score at about two thirds of them -- 73% is the ceiling for
           any direction here, since a curved boundary bends away from
           every straight line -- while
           the concept planted orthogonal to it sits at chance.  Random directions are reported for context
           and are NOT the discriminator: where the score varies in one
           direction across the plane, a whole cone of directions scores as well as the
           right one, so the comparison that carries information is
           between the two concepts.
[B-fig]    The figure: scatter of both classes, the network's curved
           boundary, the CAV hyperplane, and the CAV itself.  Written to
           cav_points.csv, cav_boundary.csv, cav_cavline.csv and cav.png.

Outputs
-------
cav_points.csv   : z1, z2, class, concept for every activation vector
cav_boundary.csv : the network's decision boundary in the plane
cav_cavline.csv  : the concept hyperplane and the CAV arrow
cav.png          : preview (checking only)
"""

import numpy as np
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

report = []                         # collects (check name, pass/fail) pairs


def check(name, ok):                # records and prints one verification
    report.append((name, bool(ok)))
    print(f"  [{'ok' if ok else 'FAIL'}] {name}")


rng = np.random.default_rng(20260819)
M = 400                             # activation vectors drawn
  400 activation vectors, 182 in class 1, 164 carrying the concept

B-plane

The plane of activations: two classes whose boundary curves, and a concept that occupies a half-plane. A linear classifier fitted to the CLASS labels does poorly, which is what makes the network's boundary nonlinear rather than a line in disguise.

# The plane is spanned by the activations of two neurons of one hidden layer.
Z = rng.uniform(-3.0, 3.0, size=(M, 2))
z1, z2 = Z[:, 0], Z[:, 1]

# The network's score in this plane. Its zero set is the decision boundary
# drawn in the entry's figure: a curve, not a line.
def net_score(a, b):
    return 1.6 * np.sin(1.15 * a) + 0.9 * b - 0.35 * a - 0.25 * b ** 2 + 0.4


cls = (net_score(z1, z2) > 0).astype(int)          # the network's two classes

# A concept occupies a half-plane: it is a DIRECTION in activation space,
# which is the assumption a CAV rests on. Two concepts are planted. The first
# lies along the direction the network's score actually varies in, averaged
# over the points of class 1; the second lies orthogonal to it, and the
# network's score barely moves along it. A test that could not tell these
# apart would say nothing.
slope = np.stack([1.6 * 1.15 * np.cos(1.15 * z1) - 0.35, 0.9 - 0.5 * z2], 1)
g = slope[net_score(z1, z2) > 0].mean(axis=0)
w_used = g / np.linalg.norm(g)                     # the direction the net uses
w_idle = np.array([-w_used[1], w_used[0]])         # orthogonal to it
concept = ((Z @ w_used + rng.normal(0, 0.28, M)) > 0.35).astype(int)
concept_idle = ((Z @ w_idle + rng.normal(0, 0.28, M)) > 0.35).astype(int)

print(f"  {M} activation vectors, {cls.sum()} in class 1, "
      f"{concept.sum()} carrying the concept")
check("[B-plane] both classes and both concept sets are populated",
      80 < cls.sum() < M - 80 and 80 < concept.sum() < M - 80)


def fit_linear(X, y, steps=4000, lrate=0.35):
    """Logistic regression by gradient descent; returns (weights, bias)."""
    w, b = np.zeros(X.shape[1]), 0.0
    for _ in range(steps):
        p = 1.0 / (1.0 + np.exp(-(X @ w + b)))
        w -= lrate * X.T @ (p - y) / len(y)
        b -= lrate * (p - y).mean()
    return w, b


acc = lambda w, b, X, y: float((((X @ w + b) > 0) == (y > 0.5)).mean())

w_cls, b_cls = fit_linear(Z, cls.astype(float))
print(f"  a straight line fitted to the CLASS labels reaches accuracy "
      f"{acc(w_cls, b_cls, Z, cls):.2f}")
check("[B-plane] the network's boundary is not a line in disguise",
      acc(w_cls, b_cls, Z, cls) < 0.85)
  [ok] [B-plane] both classes and both concept sets are populated
  a straight line fitted to the CLASS labels reaches accuracy 0.82
  [ok] [B-plane] the network's boundary is not a line in disguise
  concept classifier accuracy 0.95; the CAV points along (-0.86, 0.51), cos to the planted direction 0.999

B-cav

The CAV: a binary linear classifier separating concept from non-concept activations, fitted to the supplied examples. It reaches high accuracy, and its weight vector -- the CAV -- is the normal of the separating hyperplane.

w_cav, b_cav = fit_linear(Z, concept.astype(float))
cav = w_cav / np.linalg.norm(w_cav)                # the CAV, unit length
acc_cav = acc(w_cav, b_cav, Z, concept)
cos = float(cav @ w_used)
print(f"  concept classifier accuracy {acc_cav:.2f}; the CAV points along "
      f"({cav[0]:.2f}, {cav[1]:.2f}), cos to the planted direction {cos:.3f}")
check("[B-cav] concept and non-concept activations are linearly separable",
      acc_cav > 0.92)
check("[B-cav] the CAV recovers the direction the concept was planted along",
      cos > 0.98)
w_cav_idle, b_cav_idle = fit_linear(Z, concept_idle.astype(float))
cav_idle = w_cav_idle / np.linalg.norm(w_cav_idle)
  [ok] [B-cav] concept and non-concept activations are linearly separable
  [ok] [B-cav] the CAV recovers the direction the concept was planted along
  moving along the CAV of the used concept raises the score at 65% of the class-1 points, along the CAV of the other concept at 52%; random directions 49% on average, and 18% of them do as well

B-tcav

Conceptual sensitivity: the directional derivative of the network's score along the unit CAV, averaged over the data points of one class. The concept the network uses raises the score at about two thirds of them -- 73% is the ceiling for any direction here, since a curved boundary bends away from every straight line -- while the concept planted orthogonal to it sits at chance. Random directions are reported for context and are NOT the discriminator: where the score varies in one direction across the plane, a whole cone of directions scores as well as the right one, so the comparison that carries information is between the two concepts.

# Conceptual sensitivity: the directional derivative of the network's score
# along the unit CAV, evaluated at the data points of class 1.
EPS = 1e-4


def directional_derivative(direction, points):
    d = direction / np.linalg.norm(direction)
    ahead = net_score(points[:, 0] + EPS * d[0], points[:, 1] + EPS * d[1])
    here = net_score(points[:, 0], points[:, 1])
    return (ahead - here) / EPS


pts = Z[cls == 1]
frac = lambda v: float((directional_derivative(v, pts) > 0).mean())
frac_used, frac_idle = frac(cav), frac(cav_idle)
rand_frac = np.array([frac(rng.normal(size=2)) for _ in range(200)])
print(f"  moving along the CAV of the used concept raises the score at "
      f"{frac_used:.0%} of the class-1 points, along the CAV of the other "
      f"concept at {frac_idle:.0%}; random directions {rand_frac.mean():.0%} "
      f"on average, and {(rand_frac >= frac_used).mean():.0%} of them do as "
      f"well")
# 73% is what the best direction reaches here: the boundary is curved, so
# the direction the score rises in turns across the plane and none raises it
# everywhere. A demo tuned until this number was near 1 would have a nearly
# straight boundary, which is not what a deep net's looks like.
check("[B-tcav] the used concept raises the score at most class-1 points",
      frac_used > 0.6)
check("[B-tcav] the concept the network does not use scores near chance",
      abs(frac_idle - 0.5) < 0.2)
  [ok] [B-tcav] the used concept raises the score at most class-1 points
  [ok] [B-tcav] the concept the network does not use scores near chance
  wrote pythondemos/cav_points.csv, cav_boundary.csv, cav_cavline.csv, cav_arrow.csv
  wrote pythondemos/cav.png

6/6 checks passed

B-fig

The figure: scatter of both classes, the network's curved boundary, the CAV hyperplane, and the CAV itself. Written to cav_points.csv, cav_boundary.csv, cav_cavline.csv and cav.png.

with open("pythondemos/cav_points.csv", "w") as fh:
    # grp joins the two labels into one symbolic column, which is what
    # pgfplots' scatter/classes needs to give each kind its own marker
    fh.write("z1,z2,class,concept,grp\n")
    for (a, b), c, k in zip(Z, cls, concept):
        fh.write(f"{a:.4f},{b:.4f},{c},{k},c{c}k{k}\n")

# the network's boundary, traced as z2 for each z1 where the score vanishes
grid1 = np.linspace(-3, 3, 241)
with open("pythondemos/cav_boundary.csv", "w") as fh:
    fh.write("z1,z2\n")
    for a in grid1:
        bs = np.linspace(-3, 3, 2001)
        s = net_score(np.full_like(bs, a), bs)
        sign = np.where(np.diff(np.sign(s)))[0]
        if len(sign):
            fh.write(f"{a:.4f},{bs[sign[0]]:.4f}\n")

# the concept hyperplane w . z + b = 0, and the CAV drawn from a point on it
with open("pythondemos/cav_cavline.csv", "w") as fh:
    fh.write("z1,z2\n")
    for a in np.linspace(-3, 3, 61):
        fh.write(f"{a:.4f},{(-b_cav - w_cav[0] * a) / w_cav[1]:.4f}\n")
foot = np.array([0.0, -b_cav / w_cav[1]])
with open("pythondemos/cav_arrow.csv", "w") as fh:
    fh.write("z1,z2\n")
    fh.write(f"{foot[0]:.4f},{foot[1]:.4f}\n")
    fh.write(f"{foot[0] + 1.3 * cav[0]:.4f},{foot[1] + 1.3 * cav[1]:.4f}\n")
print("  wrote pythondemos/cav_points.csv, cav_boundary.csv, "
      "cav_cavline.csv, cav_arrow.csv")

fig, ax = plt.subplots(figsize=(5.6, 5.6))
for c, mark, lbl in ((0, "o", "class 0"), (1, "s", "class 1")):
    for k, fill in ((0, "none"), (1, "k")):
        sel = (cls == c) & (concept == k)
        ax.plot(Z[sel, 0], Z[sel, 1], mark, ms=4, mfc=fill, mec="k", lw=0,
                label=f"{lbl}, {'concept' if k else 'no concept'}")
bnd = np.loadtxt("pythondemos/cav_boundary.csv", delimiter=",", skiprows=1)
ax.plot(bnd[:, 0], bnd[:, 1], "k-", lw=2.0,
        label="decision boundary of the deep net")
line = np.loadtxt("pythondemos/cav_cavline.csv", delimiter=",", skiprows=1)
ax.plot(line[:, 0], line[:, 1], "k--", lw=2.0,
        label="concept hyperplane (linear classifier)")
ax.annotate("", xy=foot + 1.3 * cav, xytext=foot,
            arrowprops=dict(arrowstyle="-|>", lw=2.0, color="k"))
ax.annotate("CAV", foot + 1.5 * cav + np.array([0.12, 0.10]), fontsize=10)
ax.set_xlim(-3.1, 3.1)
ax.set_ylim(-3.1, 3.1)
ax.set_xlabel("activation of neuron 1")
ax.set_ylabel("activation of neuron 2")
ax.set_title("a concept is a direction; the class boundary is a curve",
             fontsize=10)
# below the axes: the plane is populated everywhere, so any in-axes
# placement covers data
ax.legend(frameon=False, fontsize=7.5, loc="upper center",
          bbox_to_anchor=(0.5, -0.13), ncol=2)
fig.tight_layout()
fig.savefig("pythondemos/cav.png", dpi=110)
print("  wrote pythondemos/cav.png")

bad = [n for n, ok in report if not ok]
print(f"\n{len(report) - len(bad)}/{len(report)} checks passed"
      + (f"; FAILED: {bad}" if bad else ""))
Preview figure produced by cav.py
The preview figure the block B-fig writes when the script runs