Files
OBLITERATUS/obliteratus/analysis/leace.py
T
2026-03-08 12:07:56 -07:00

240 lines
8.8 KiB
Python

"""LEACE-inspired direction extraction for refusal concept erasure.
This module implements Fisher's Linear Discriminant (FLD) direction for
concept erasure, inspired by LEACE (Belrose et al. 2023).
IMPORTANT: This is NOT a faithful implementation of LEACE as described in
the paper. Key difference:
- **True LEACE** uses the *total* covariance Sigma_X for whitening:
P* = I - W^{-1} P_{W Sigma_XZ} W where W = Sigma_X^{-1/2}
For binary concepts, this yields: v = Sigma_X^{-1} delta
- **This implementation** uses *within-class* covariance S_w:
v = S_w^{-1} delta
This is Fisher's Linear Discriminant direction, which maximizes
class separability relative to within-class spread.
For binary concepts, Sigma_X = S_w + p(1-p) * delta @ delta^T,
so the two directions differ when the between-class scatter is
non-negligible relative to within-class scatter. In high-dimensional
settings (d >> 1) with moderate class separation, the difference
is typically small but non-zero.
The FLD direction is still a strong choice for refusal erasure — it
handles rogue dimensions (high-variance but non-discriminative) better
than plain diff-of-means, and is a closed-form solution with no
iterative optimization.
Advantages over SVD:
- Within-class normalization prevents high-variance but
non-discriminative dimensions from dominating
- No hyperparameters beyond regularization epsilon
- Closed-form solution (no iterative optimization)
References:
- Belrose et al. (2023): LEACE: Perfect linear concept erasure in
closed form. NeurIPS 2023.
- Ravfogel et al. (2022): RLACE: Adversarial concept erasure
(iterative precursor to LEACE).
- Fisher (1936): The use of multiple measurements in taxonomic
problems. Annals of Eugenics.
"""
from __future__ import annotations
from dataclasses import dataclass
import torch
@dataclass
class LEACEResult:
"""Result of LEACE direction extraction for a single layer."""
layer_idx: int
direction: torch.Tensor # (hidden_dim,) unit vector
generalized_eigenvalue: float # lambda from GEP (discriminability)
within_class_condition: float # condition number of S_w
mean_diff_norm: float # ||mu_1 - mu_0||
erasure_loss: float # expected squared distortion from erasure
class LEACEExtractor:
"""Extract refusal directions via Fisher's Linear Discriminant.
Finds the direction that maximally separates harmful from harmless
activations relative to within-class variance (v = S_w^{-1} delta).
See module docstring for how this relates to true LEACE.
"""
def __init__(
self,
regularization_eps: float = 1e-4,
shrinkage: float = 0.0,
):
"""
Args:
regularization_eps: Tikhonov regularization for S_w inversion.
Larger values produce more conservative (but stable) results.
shrinkage: Ledoit-Wolf shrinkage toward identity (0..1).
0 = no shrinkage, 1 = full shrinkage to scaled identity.
Useful when n_samples < hidden_dim.
"""
self.regularization_eps = regularization_eps
self.shrinkage = shrinkage
def extract(
self,
harmful_activations: list[torch.Tensor],
harmless_activations: list[torch.Tensor],
layer_idx: int = 0,
) -> LEACEResult:
"""Extract the LEACE direction for a single layer.
Args:
harmful_activations: List of (hidden_dim,) tensors from harmful prompts.
harmless_activations: List of (hidden_dim,) tensors from harmless prompts.
layer_idx: Layer index (for metadata).
Returns:
LEACEResult with the optimal erasure direction.
"""
H = torch.stack(harmful_activations).float() # (n_h, d)
B = torch.stack(harmless_activations).float() # (n_b, d)
if H.dim() == 3:
H = H.squeeze(1)
if B.dim() == 3:
B = B.squeeze(1)
n_h, d = H.shape
n_b = B.shape[0]
# Class-conditional means
mu_h = H.mean(dim=0) # (d,)
mu_b = B.mean(dim=0) # (d,)
# Mean difference (between-class direction)
delta = mu_h - mu_b # (d,)
delta_norm = delta.norm().item()
# Within-class covariance: S_w = (S_h + S_b) / 2
# where S_h = (H - mu_h)^T (H - mu_h) / (n_h - 1) etc.
H_centered = H - mu_h.unsqueeze(0)
B_centered = B - mu_b.unsqueeze(0)
S_h = (H_centered.T @ H_centered) / max(n_h - 1, 1)
S_b = (B_centered.T @ B_centered) / max(n_b - 1, 1)
S_w = (S_h + S_b) / 2.0 # (d, d)
# Apply Ledoit-Wolf shrinkage if requested
if self.shrinkage > 0:
trace_S_w = S_w.trace().item()
S_w = (1 - self.shrinkage) * S_w + self.shrinkage * (trace_S_w / d) * torch.eye(d, device=S_w.device)
# Regularize S_w for numerical stability
S_w_reg = S_w + self.regularization_eps * torch.eye(d, device=S_w.device)
# Condition number of S_w (for diagnostics)
try:
eigs_w = torch.linalg.eigvalsh(S_w_reg)
eigs_w = eigs_w.clamp(min=0)
pos_eigs = eigs_w[eigs_w > eigs_w.max() * 1e-10]
condition = (pos_eigs.max() / pos_eigs.min()).item() if pos_eigs.numel() > 0 else float('inf')
except Exception:
condition = float('inf')
# LEACE direction via S_w^{-1} @ delta
# The generalized eigenvector for rank-1 S_between = delta @ delta^T
# reduces to: v = S_w^{-1} @ delta (up to normalization)
try:
# Use solve for numerical stability (avoids explicit inverse)
v = torch.linalg.solve(S_w_reg, delta) # (d,)
except torch.linalg.LinAlgError:
# Fallback: pseudoinverse
v = torch.linalg.lstsq(S_w_reg, delta.unsqueeze(1)).solution.squeeze(1)
# Normalize to unit length
v_norm = v.norm()
if v_norm > 1e-8:
direction = v / v_norm
else:
# Degenerate case: fall back to normalized mean difference
direction = delta / max(delta_norm, 1e-8)
# Generalized eigenvalue: lambda = delta^T @ S_w^{-1} @ delta
# This measures how discriminable the classes are after whitening
gen_eigenvalue = (delta @ v).item()
# Erasure loss: expected squared distortion E[||x - x'||^2]
# For rank-1 projection: loss = v^T @ S_total @ v where S_total
# is the total (pooled) covariance
all_acts = torch.cat([H, B], dim=0)
mu_total = all_acts.mean(dim=0)
centered_total = all_acts - mu_total.unsqueeze(0)
S_total = (centered_total.T @ centered_total) / max(all_acts.shape[0] - 1, 1)
erasure_loss = (direction @ S_total @ direction).item()
return LEACEResult(
layer_idx=layer_idx,
direction=direction,
generalized_eigenvalue=gen_eigenvalue,
within_class_condition=condition,
mean_diff_norm=delta_norm,
erasure_loss=erasure_loss,
)
def extract_all_layers(
self,
harmful_acts: dict[int, list[torch.Tensor]],
harmless_acts: dict[int, list[torch.Tensor]],
) -> dict[int, LEACEResult]:
"""Extract LEACE directions for all layers.
Args:
harmful_acts: {layer_idx: [activations]} from activation collection.
harmless_acts: {layer_idx: [activations]} from activation collection.
Returns:
{layer_idx: LEACEResult} for each layer.
"""
results = {}
for idx in sorted(harmful_acts.keys()):
if idx not in harmless_acts:
continue
results[idx] = self.extract(
harmful_acts[idx],
harmless_acts[idx],
layer_idx=idx,
)
return results
@staticmethod
def compare_with_diff_of_means(
leace_result: LEACEResult,
harmful_mean: torch.Tensor,
harmless_mean: torch.Tensor,
) -> dict[str, float]:
"""Compare LEACE direction with simple diff-of-means.
Returns cosine similarity and diagnostic metrics showing how much
the within-class normalization rotates the direction.
"""
diff = harmful_mean.squeeze() - harmless_mean.squeeze()
diff_norm = diff.norm()
if diff_norm > 1e-8:
diff_normalized = diff / diff_norm
else:
diff_normalized = diff
cosine_sim = (leace_result.direction @ diff_normalized).abs().item()
return {
"cosine_similarity": cosine_sim,
"leace_eigenvalue": leace_result.generalized_eigenvalue,
"leace_erasure_loss": leace_result.erasure_loss,
"within_class_condition": leace_result.within_class_condition,
"mean_diff_norm": leace_result.mean_diff_norm,
}