Files
OBLITERATUS/obliteratus/analysis/whitened_svd.py

304 lines
13 KiB
Python

"""Whitened SVD direction extraction for refusal subspace identification.
Standard SVD on the difference matrix extracts directions that maximize
absolute variance in the harmful-vs-harmless difference. However, some of
this variance may simply reflect the natural anisotropy of the model's
activation space (rogue dimensions with high variance across all inputs).
Whitened SVD normalizes by the harmless covariance matrix first, so the
extracted directions maximize variance *relative to the model's baseline
activation distribution*. This produces cleaner refusal directions that
are less contaminated by general-purpose high-variance dimensions.
Mathematical formulation:
Given harmful activations H and harmless activations B (both n x d):
1. Compute harmless covariance: C_B = (B - mu_B)^T (B - mu_B) / (n-1)
2. Regularize: C_reg = C_B + eps * I (for numerical stability)
3. Whitening transform: W = C_reg^{-1/2}
4. Whiten both sets: H_w = (H - mu_B) @ W, B_w = (B - mu_B) @ W
5. Compute whitened difference: D_w = H_w - B_w
6. SVD on D_w to extract principal whitened refusal directions
7. Un-whiten to get directions in original activation space
References:
- Oursland (2024): Whitened activation analysis for LLMs
- Kessy et al. (2018): Optimal whitening and decorrelation
"""
from __future__ import annotations
from dataclasses import dataclass
import torch
from obliteratus.analysis.numerical_contracts import (
validate_whitened_parameters,
validate_whitened_request,
)
@dataclass
class WhitenedSVDResult:
"""Result of whitened SVD extraction for a single layer."""
layer_idx: int
directions: torch.Tensor # (k, hidden_dim) in original space
whitened_directions: torch.Tensor # (k, hidden_dim) in whitened space
singular_values: torch.Tensor # (k,)
variance_explained: float # fraction of total variance
condition_number: float # condition number of covariance
effective_rank: float # effective rank of covariance
class WhitenedSVDExtractor:
"""Extract refusal directions using covariance-whitened SVD.
This produces directions that are unusual *relative to* the model's
baseline activation variance, rather than directions that simply have
high absolute variance.
"""
def __init__(
self,
regularization_eps: float = 1e-4,
min_variance_ratio: float = 0.01,
):
"""
Args:
regularization_eps: Tikhonov regularization added to covariance
diagonal for numerical stability. Larger values produce more
conservative whitening.
min_variance_ratio: Minimum eigenvalue ratio (relative to max)
below which dimensions are truncated. Prevents amplifying
noise in near-degenerate dimensions.
"""
self.regularization_eps, self.min_variance_ratio = validate_whitened_parameters(
regularization_eps,
min_variance_ratio,
)
@staticmethod
def _stack_activation_pair(
harmful_activations: list[torch.Tensor],
harmless_activations: list[torch.Tensor],
) -> tuple[torch.Tensor, torch.Tensor]:
"""Validate and stack paired activation samples into two 2D tensors."""
def normalize(samples: list[torch.Tensor], name: str) -> torch.Tensor:
normalized = []
width = None
device = None
for index, sample in enumerate(samples):
if not isinstance(sample, torch.Tensor):
raise ValueError(f"{name} activation {index} must be a tensor")
if sample.dim() == 2 and sample.shape[0] == 1:
sample = sample.squeeze(0)
if sample.dim() != 1 or sample.numel() == 0:
raise ValueError(
f"{name} activation {index} must be a non-empty vector "
"or a single-row matrix",
)
if not torch.isfinite(sample).all():
raise ValueError(f"{name} activations must contain only finite values")
if width is None:
width = sample.shape[0]
device = sample.device
elif sample.shape[0] != width:
raise ValueError(f"{name} activations must have a consistent width")
elif sample.device != device:
raise ValueError(f"{name} activations must be on a single device")
normalized.append(sample)
return torch.stack(normalized).float()
harmful = normalize(harmful_activations, "harmful")
harmless = normalize(harmless_activations, "harmless")
if harmful.shape[1] != harmless.shape[1]:
raise ValueError(
"harmful and harmless activations must have the same width, got "
f"{harmful.shape[1]} and {harmless.shape[1]}",
)
if harmful.device != harmless.device:
raise ValueError("harmful and harmless activations must be on the same device")
return harmful, harmless
def extract(
self,
harmful_activations: list[torch.Tensor],
harmless_activations: list[torch.Tensor],
n_directions: int = 4,
layer_idx: int = 0,
) -> WhitenedSVDResult:
"""Extract whitened refusal directions for a single layer.
Args:
harmful_activations: List of (hidden_dim,) tensors, one per prompt.
harmless_activations: List of (hidden_dim,) tensors, one per prompt.
n_directions: Number of refusal directions to extract.
layer_idx: Index of the layer (for metadata).
Returns:
WhitenedSVDResult with directions in original activation space.
"""
n_directions = validate_whitened_request(
len(harmful_activations),
len(harmless_activations),
n_directions,
)
H, B = self._stack_activation_pair(harmful_activations, harmless_activations)
n_samples, d = B.shape
# Step 1: Compute harmless covariance with centering
mu_B = B.mean(dim=0, keepdim=True)
B_centered = B - mu_B
cov_B = (B_centered.T @ B_centered) / max(n_samples - 1, 1)
# Step 2: Eigendecompose covariance for whitening
eigenvalues, eigenvectors = torch.linalg.eigh(cov_B)
eigenvalues = eigenvalues.clamp(min=0) # numerical safety
# Compute condition number using only valid (positive) eigenvalues.
# After clamping, min_eig is often 0.0 (from numerical noise), which
# gives a meaningless condition number of ~1e15. Use eigenvalues above
# a small threshold instead.
max_eig = eigenvalues.max().item()
positive_eigs = eigenvalues[eigenvalues > max_eig * 1e-10]
min_eig = positive_eigs.min().item() if positive_eigs.numel() > 0 else 1e-12
condition_number = max_eig / max(min_eig, 1e-12)
# Effective rank via Shannon entropy of normalized eigenvalues
eig_normalized = eigenvalues / eigenvalues.sum().clamp(min=1e-12)
eig_nonzero = eig_normalized[eig_normalized > 1e-12]
effective_rank = torch.exp(-(eig_nonzero * eig_nonzero.log()).sum()).item()
# Step 3: Truncate near-degenerate dimensions
threshold = max_eig * self.min_variance_ratio
valid_mask = eigenvalues >= threshold
eigenvalues_valid = eigenvalues[valid_mask]
eigenvectors_valid = eigenvectors[:, valid_mask]
# Step 4: Compute whitening transform W = V @ diag(1/sqrt(lam + eps)) @ V^T
# But we work in the truncated eigenspace for efficiency
inv_sqrt_eig = 1.0 / torch.sqrt(eigenvalues_valid + self.regularization_eps)
# Whitening projection: x_whitened = (x - mu) @ V_valid @ diag(inv_sqrt)
whiten_proj = eigenvectors_valid * inv_sqrt_eig.unsqueeze(0) # (d, k_valid)
# Step 5: Whiten both activation sets (centered on harmless mean)
H_centered = H - mu_B
H_whitened = H_centered @ whiten_proj # (n, k_valid)
B_whitened = B_centered @ whiten_proj # (n, k_valid)
# Step 6: Compute whitened difference and SVD
D_whitened = H_whitened - B_whitened # (n, k_valid)
if torch.linalg.vector_norm(D_whitened) <= 1e-12:
raise ValueError("cannot extract a refusal direction without activation difference")
k = min(n_directions, D_whitened.shape[0], D_whitened.shape[1])
U, S, Vh = torch.linalg.svd(D_whitened, full_matrices=False)
whitened_dirs = Vh[:k] # (k, k_valid) in whitened space
singular_vals = S[:k]
# Step 7: Un-whiten to get directions in original activation space
# x_whitened = x_orig @ whiten_proj, where whiten_proj = V * 1/sqrt(lam)
# To map a direction v_w from whitened space back to original space,
# we need the INVERSE whitening: unwhiten_proj = V * sqrt(lam)
# Then: v_orig = v_w @ unwhiten_proj.T
unwhiten_proj = eigenvectors_valid * torch.sqrt(
eigenvalues_valid + self.regularization_eps
).unsqueeze(0)
original_dirs = whitened_dirs @ unwhiten_proj.T # (k, d)
# Normalize each direction to unit length
norms = original_dirs.norm(dim=-1, keepdim=True).clamp(min=1e-8)
original_dirs = original_dirs / norms
# Also normalize whitened directions
w_norms = whitened_dirs.norm(dim=-1, keepdim=True).clamp(min=1e-8)
whitened_dirs = whitened_dirs / w_norms
# Variance explained (use S^2: variance is proportional to sigma^2)
total_var = (S ** 2).sum().item()
top_k_var = (singular_vals ** 2).sum().item()
var_explained = top_k_var / max(total_var, 1e-12)
return WhitenedSVDResult(
layer_idx=layer_idx,
directions=original_dirs,
whitened_directions=whitened_dirs,
singular_values=singular_vals,
variance_explained=var_explained,
condition_number=condition_number,
effective_rank=effective_rank,
)
def extract_all_layers(
self,
harmful_acts: dict[int, list[torch.Tensor]],
harmless_acts: dict[int, list[torch.Tensor]],
n_directions: int = 4,
) -> dict[int, WhitenedSVDResult]:
"""Extract whitened refusal directions for all layers.
Args:
harmful_acts: {layer_idx: [activations]} from activation collection.
harmless_acts: {layer_idx: [activations]} from activation collection.
n_directions: Number of directions to extract per layer.
Returns:
{layer_idx: WhitenedSVDResult} 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],
n_directions=n_directions,
layer_idx=idx,
)
return results
@staticmethod
def compare_with_standard(
whitened_result: WhitenedSVDResult,
standard_direction: torch.Tensor,
) -> dict[str, float]:
"""Compare whitened vs standard SVD directions.
Returns cosine similarities between the whitened and standard
directions, revealing how much the whitening transformation
rotates the extracted refusal subspace.
"""
if standard_direction.dim() == 1:
standard_direction = standard_direction.unsqueeze(0)
# Ensure unit vectors
std_norm = standard_direction / standard_direction.norm(dim=-1, keepdim=True).clamp(min=1e-8)
wht_dirs = whitened_result.directions
# Primary direction alignment
primary_cos = (wht_dirs[0] @ std_norm[0]).abs().item()
# Subspace overlap: average max cosine sim for each whitened dir
n_w = wht_dirs.shape[0]
n_s = std_norm.shape[0]
cos_matrix = (wht_dirs @ std_norm.T).abs() # (n_w, n_s)
avg_max_cos = cos_matrix.max(dim=-1).values.mean().item()
# Subspace principal angle (smallest angle between subspaces)
if n_w > 1 and n_s > 1:
_, S_overlap, _ = torch.linalg.svd(wht_dirs @ std_norm.T)
principal_cos = S_overlap[0].clamp(max=1.0).item()
else:
principal_cos = primary_cos
return {
"primary_direction_cosine": primary_cos,
"avg_max_direction_cosine": avg_max_cos,
"subspace_principal_cosine": principal_cos,
"whitened_condition_number": whitened_result.condition_number,
"whitened_effective_rank": whitened_result.effective_rank,
}