Code / src/modelmap/probes/linear.py
src/modelmap/probes/linear.py
109 lines
# =============================================================================
# Project : modelmap
# File : src/modelmap/probes/linear.py
# Purpose : Linear probes with the mandatory controls doctrine (expA core)
# Author : Simon-Pierre Boucher
# Contact : contact@spboucher.ai
# Website : https://modelmap.io
# Created : 2026-08-12
# Modified : 2026-08-12
# Platform : macOS / Apple Silicon (arm64)
# License : All rights reserved (research code)
# =============================================================================
"""Linear probing with controls baked in, never optional (notes §4.1/§4.8).
Every probe run returns task accuracy AND its shuffled-label control on the
same split, so selectivity is always computable. Multinomial logistic
regression via plain NumPy gradient descent — no sklearn dependency, fully
deterministic given a seed, runs on any Mac.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass
class ProbeResult:
task_accuracy: float
control_accuracy: float # shuffled-label control, same capacity/split
selectivity: float
n_train: int
n_test: int
seed: int
def _train_logreg(
x: np.ndarray, y: np.ndarray, n_classes: int,
l2: float, lr: float, epochs: int, rng: np.random.Generator,
) -> np.ndarray:
"""Multinomial logistic regression, full-batch GD. Returns (d+1, C)."""
n, d = x.shape
xb = np.hstack([x, np.ones((n, 1), dtype=x.dtype)])
w = rng.normal(0, 0.01, size=(d + 1, n_classes)).astype(np.float64)
onehot = np.eye(n_classes)[y]
for _ in range(epochs):
logits = xb @ w
logits -= logits.max(axis=1, keepdims=True)
p = np.exp(logits)
p /= p.sum(axis=1, keepdims=True)
grad = xb.T @ (p - onehot) / n + l2 * w
w -= lr * grad
return w
def _accuracy(w: np.ndarray, x: np.ndarray, y: np.ndarray) -> float:
xb = np.hstack([x, np.ones((x.shape[0], 1), dtype=x.dtype)])
return float((np.argmax(xb @ w, axis=1) == y).mean())
def probe_with_control(
activations: np.ndarray,
labels: np.ndarray,
seed: int,
train_frac: float = 0.8,
l2: float = 1e-3,
lr: float = 0.5,
epochs: int = 300,
) -> ProbeResult:
"""Train task probe + shuffled-label control with identical capacity/split.
The control shuffles TRAINING labels only (a probe that can still score
high is memorizing, not reading structure); control test accuracy is
evaluated against the true test labels, per Hewitt & Liang.
"""
x = np.asarray(activations, dtype=np.float64)
y = np.asarray(labels)
classes, y_idx = np.unique(y, return_inverse=True)
n_classes = len(classes)
if n_classes < 2:
raise ValueError("probe needs >=2 classes")
rng = np.random.default_rng(seed)
# standardize features on train split only
perm = rng.permutation(len(y_idx))
n_train = int(train_frac * len(y_idx))
tr, te = perm[:n_train], perm[n_train:]
mu, sd = x[tr].mean(0), x[tr].std(0) + 1e-8
xs = (x - mu) / sd
w_task = _train_logreg(xs[tr], y_idx[tr], n_classes, l2, lr, epochs, rng)
task_acc = _accuracy(w_task, xs[te], y_idx[te])
# NB: y_shuffled[tr] would be a fancy-indexed COPY — shuffle explicitly.
shuffled_train = y_idx[tr].copy()
rng.shuffle(shuffled_train)
w_ctrl = _train_logreg(xs[tr], shuffled_train, n_classes, l2, lr, epochs, rng)
ctrl_acc = _accuracy(w_ctrl, xs[te], y_idx[te])
return ProbeResult(
task_accuracy=task_acc,
control_accuracy=ctrl_acc,
selectivity=task_acc - ctrl_acc,
n_train=len(tr),
n_test=len(te),
seed=seed,
)