mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-09-22 01:10:49 +02:00
304 lines
13 KiB
Python
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,
|
|
}
|