mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-08-18 17:07:11 +02:00
240 lines
8.8 KiB
Python
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,
|
|
}
|