mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-08-18 08:57:17 +02:00
345 lines
12 KiB
Python
345 lines
12 KiB
Python
"""Wasserstein-optimal refusal direction extraction.
|
|
|
|
Standard abliteration selects r to maximize the harmful-vs-harmless mean
|
|
shift (r^T d)^2. But this ignores the distributional cost: projecting out
|
|
a direction that has high variance in the harmless distribution causes
|
|
large distortion even for harmless inputs.
|
|
|
|
The Wasserstein-optimal direction minimizes the ratio of distributional
|
|
cost to refusal removal effectiveness:
|
|
|
|
r* = argmin_{||r||=1} [W_2^2(mu_harmless, mu_projected)] / [(r^T d)^2]
|
|
|
|
where W_2^2 decomposes into a mean-shift term and a Bures divergence term
|
|
(Theorem A.5 in the paper, Appendix A.2).
|
|
|
|
This reduces to a generalized eigenvalue problem:
|
|
|
|
r* = argmin_{||r||=1} [(r^T m)^2 + r^T Sigma r] / [(r^T d)^2]
|
|
|
|
where m is the harmless mean, Sigma is the harmless covariance, and d is
|
|
the harmful-harmless mean difference.
|
|
|
|
The solution is the eigenvector corresponding to the smallest eigenvalue of:
|
|
(m m^T + Sigma) r = lambda (d d^T) r
|
|
|
|
In practice, since d d^T is rank-1, we use a Rayleigh quotient approach.
|
|
|
|
Comparison with other methods:
|
|
- Difference-in-means: maximizes (r^T d)^2 only
|
|
- Whitened SVD (Fisher): maximizes (r^T d)^2 / (r^T Sigma r)
|
|
- Wasserstein-optimal: minimizes [(r^T m)^2 + r^T Sigma r] / (r^T d)^2
|
|
(accounts for both mean shift AND covariance distortion)
|
|
|
|
The Wasserstein direction should produce lower KL divergence on harmless
|
|
prompts than Fisher-optimal, at the cost of slightly weaker refusal removal.
|
|
|
|
References:
|
|
- Dowson & Landau (1982): The Frechet distance between multivariate normals
|
|
- Givens & Shortt (1984): A class of Wasserstein metrics
|
|
- OBLITERATUS paper Appendix A.2, Corollary A.2
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
import torch
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class WassersteinDirectionResult:
|
|
"""Result of Wasserstein-optimal direction extraction for one layer."""
|
|
|
|
layer_idx: int
|
|
direction: torch.Tensor # (hidden_dim,) optimal direction
|
|
wasserstein_cost: float # W_2^2 cost for this direction
|
|
mean_shift_component: float # (r^T m)^2 portion
|
|
bures_component: float # r^T Sigma r portion (exact when r is eigenvector of Sigma, lower bound otherwise)
|
|
refusal_projection: float # (r^T d)^2
|
|
cost_effectiveness_ratio: float # W_2^2 / (r^T d)^2
|
|
|
|
|
|
@dataclass
|
|
class WassersteinComparisonResult:
|
|
"""Comparison of Wasserstein-optimal vs other directions."""
|
|
|
|
layer_idx: int
|
|
wasserstein_direction: torch.Tensor
|
|
fisher_direction: torch.Tensor | None
|
|
dim_direction: torch.Tensor | None # difference-in-means
|
|
|
|
wasserstein_cost_ratio: float
|
|
fisher_cost_ratio: float | None
|
|
dim_cost_ratio: float | None
|
|
|
|
cosine_wasserstein_fisher: float | None
|
|
cosine_wasserstein_dim: float | None
|
|
|
|
improvement_over_fisher: float | None # % reduction in cost ratio
|
|
improvement_over_dim: float | None
|
|
|
|
|
|
@dataclass
|
|
class MultiLayerWassersteinResult:
|
|
"""Aggregated Wasserstein-optimal results across layers."""
|
|
|
|
per_layer: dict[int, WassersteinDirectionResult]
|
|
best_layer: int
|
|
mean_cost_ratio: float
|
|
comparison: dict[int, WassersteinComparisonResult] | None
|
|
|
|
|
|
class WassersteinOptimalExtractor:
|
|
"""Extract Wasserstein-optimal refusal directions.
|
|
|
|
Solves the generalized eigenvalue problem that minimizes the 2-Wasserstein
|
|
cost of abliteration on harmless inputs per unit of refusal removed.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
regularization_eps: float = 1e-4,
|
|
n_candidates: int = 100,
|
|
):
|
|
"""
|
|
Args:
|
|
regularization_eps: Regularization for covariance matrix.
|
|
n_candidates: Number of candidate directions to evaluate when
|
|
the generalized eigenvalue problem is ill-conditioned.
|
|
"""
|
|
self.regularization_eps = regularization_eps
|
|
self.n_candidates = n_candidates
|
|
|
|
def extract(
|
|
self,
|
|
harmful_activations: list[torch.Tensor],
|
|
harmless_activations: list[torch.Tensor],
|
|
layer_idx: int = 0,
|
|
) -> WassersteinDirectionResult:
|
|
"""Extract the Wasserstein-optimal refusal direction for one layer.
|
|
|
|
Args:
|
|
harmful_activations: List of (hidden_dim,) tensors from harmful prompts.
|
|
harmless_activations: List of (hidden_dim,) tensors from harmless prompts.
|
|
layer_idx: Index of the layer.
|
|
|
|
Returns:
|
|
WassersteinDirectionResult with the optimal direction and cost analysis.
|
|
"""
|
|
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_b, d = B.shape
|
|
|
|
# Compute statistics
|
|
mu_h = H.mean(dim=0) # harmful mean
|
|
mu_b = B.mean(dim=0) # harmless mean (m in the formulation)
|
|
diff = mu_h - mu_b # d in the formulation
|
|
|
|
# Harmless covariance
|
|
B_centered = B - mu_b.unsqueeze(0)
|
|
Sigma = (B_centered.T @ B_centered) / max(n_b - 1, 1)
|
|
Sigma = Sigma + self.regularization_eps * torch.eye(d, device=Sigma.device)
|
|
|
|
# Cost matrix: C = m m^T + Sigma
|
|
# This is the numerator of our objective
|
|
cost_matrix = mu_b.unsqueeze(1) @ mu_b.unsqueeze(0) + Sigma # (d, d)
|
|
|
|
# Effectiveness matrix: E = d d^T (rank-1)
|
|
# This is the denominator
|
|
|
|
# The generalized eigenvalue problem: C r = lambda E r
|
|
# Since E = d d^T is rank-1, we can solve this analytically.
|
|
#
|
|
# For any r, the Rayleigh quotient is:
|
|
# Q(r) = (r^T C r) / (r^T d)^2
|
|
#
|
|
# The minimum over all r with r^T d != 0 is achieved by:
|
|
# r* = C^{-1} d / ||C^{-1} d||
|
|
#
|
|
# (This is the standard result for rank-1 denominator GEP)
|
|
|
|
# Solve: C^{-1} d
|
|
try:
|
|
C_inv_d = torch.linalg.solve(cost_matrix, diff)
|
|
except RuntimeError:
|
|
# Fallback: use pseudoinverse
|
|
logger.warning("Cost matrix singular, using pseudoinverse at layer %d", layer_idx)
|
|
C_inv_d = torch.linalg.lstsq(cost_matrix, diff.unsqueeze(1)).solution.squeeze(1)
|
|
|
|
# Normalize to unit vector
|
|
r_opt = C_inv_d / C_inv_d.norm().clamp(min=1e-10)
|
|
|
|
# Compute cost components
|
|
mean_shift = (r_opt @ mu_b).item() ** 2
|
|
bures = (r_opt @ Sigma @ r_opt).item()
|
|
wasserstein_cost = mean_shift + bures
|
|
refusal_proj = (r_opt @ diff).item() ** 2
|
|
cost_ratio = wasserstein_cost / max(refusal_proj, 1e-12)
|
|
|
|
return WassersteinDirectionResult(
|
|
layer_idx=layer_idx,
|
|
direction=r_opt,
|
|
wasserstein_cost=wasserstein_cost,
|
|
mean_shift_component=mean_shift,
|
|
bures_component=bures,
|
|
refusal_projection=refusal_proj,
|
|
cost_effectiveness_ratio=cost_ratio,
|
|
)
|
|
|
|
def extract_all_layers(
|
|
self,
|
|
harmful_acts: dict[int, list[torch.Tensor]],
|
|
harmless_acts: dict[int, list[torch.Tensor]],
|
|
) -> MultiLayerWassersteinResult:
|
|
"""Extract Wasserstein-optimal directions for all layers.
|
|
|
|
Args:
|
|
harmful_acts: {layer_idx: [activations]} from harmful prompts.
|
|
harmless_acts: {layer_idx: [activations]} from harmless prompts.
|
|
|
|
Returns:
|
|
MultiLayerWassersteinResult with per-layer results.
|
|
"""
|
|
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,
|
|
)
|
|
|
|
if not results:
|
|
return MultiLayerWassersteinResult(
|
|
per_layer={}, best_layer=0, mean_cost_ratio=0.0, comparison=None,
|
|
)
|
|
|
|
best = min(results.items(), key=lambda x: x[1].cost_effectiveness_ratio)
|
|
mean_ratio = sum(r.cost_effectiveness_ratio for r in results.values()) / len(results)
|
|
|
|
return MultiLayerWassersteinResult(
|
|
per_layer=results,
|
|
best_layer=best[0],
|
|
mean_cost_ratio=mean_ratio,
|
|
comparison=None,
|
|
)
|
|
|
|
def compare_with_alternatives(
|
|
self,
|
|
wasserstein_result: WassersteinDirectionResult,
|
|
harmful_activations: list[torch.Tensor],
|
|
harmless_activations: list[torch.Tensor],
|
|
fisher_direction: torch.Tensor | None = None,
|
|
dim_direction: torch.Tensor | None = None,
|
|
) -> WassersteinComparisonResult:
|
|
"""Compare Wasserstein-optimal direction with Fisher and diff-in-means.
|
|
|
|
Args:
|
|
wasserstein_result: Result from extract().
|
|
harmful_activations: Harmful prompt activations.
|
|
harmless_activations: Harmless prompt activations.
|
|
fisher_direction: Direction from whitened SVD (Fisher-optimal).
|
|
dim_direction: Direction from difference-in-means.
|
|
|
|
Returns:
|
|
WassersteinComparisonResult with head-to-head comparison.
|
|
"""
|
|
H = torch.stack(harmful_activations).float()
|
|
B = torch.stack(harmless_activations).float()
|
|
if H.dim() == 3:
|
|
H = H.squeeze(1)
|
|
if B.dim() == 3:
|
|
B = B.squeeze(1)
|
|
|
|
mu_b = B.mean(dim=0)
|
|
mu_h = H.mean(dim=0)
|
|
diff = mu_h - mu_b
|
|
n_b = B.shape[0]
|
|
B_c = B - mu_b.unsqueeze(0)
|
|
Sigma = (B_c.T @ B_c) / max(n_b - 1, 1) + self.regularization_eps * torch.eye(B.shape[1])
|
|
|
|
w_dir = wasserstein_result.direction
|
|
|
|
def cost_ratio(r):
|
|
r = r.float().squeeze()
|
|
r = r / r.norm().clamp(min=1e-10)
|
|
ms = (r @ mu_b).item() ** 2
|
|
bur = (r @ Sigma @ r).item()
|
|
rp = (r @ diff).item() ** 2
|
|
return (ms + bur) / max(rp, 1e-12)
|
|
|
|
w_ratio = wasserstein_result.cost_effectiveness_ratio
|
|
|
|
fisher_ratio = None
|
|
cos_wf = None
|
|
imp_fisher = None
|
|
if fisher_direction is not None:
|
|
f = fisher_direction.float().squeeze()
|
|
f = f / f.norm().clamp(min=1e-10)
|
|
fisher_ratio = cost_ratio(f)
|
|
cos_wf = abs((w_dir @ f).item())
|
|
if fisher_ratio > 0:
|
|
imp_fisher = (fisher_ratio - w_ratio) / fisher_ratio * 100
|
|
|
|
dim_ratio = None
|
|
cos_wd = None
|
|
imp_dim = None
|
|
if dim_direction is not None:
|
|
dm = dim_direction.float().squeeze()
|
|
dm = dm / dm.norm().clamp(min=1e-10)
|
|
dim_ratio = cost_ratio(dm)
|
|
cos_wd = abs((w_dir @ dm).item())
|
|
if dim_ratio > 0:
|
|
imp_dim = (dim_ratio - w_ratio) / dim_ratio * 100
|
|
|
|
return WassersteinComparisonResult(
|
|
layer_idx=wasserstein_result.layer_idx,
|
|
wasserstein_direction=w_dir,
|
|
fisher_direction=fisher_direction,
|
|
dim_direction=dim_direction,
|
|
wasserstein_cost_ratio=w_ratio,
|
|
fisher_cost_ratio=fisher_ratio,
|
|
dim_cost_ratio=dim_ratio,
|
|
cosine_wasserstein_fisher=cos_wf,
|
|
cosine_wasserstein_dim=cos_wd,
|
|
improvement_over_fisher=imp_fisher,
|
|
improvement_over_dim=imp_dim,
|
|
)
|
|
|
|
@staticmethod
|
|
def format_report(result: MultiLayerWassersteinResult) -> str:
|
|
"""Format Wasserstein-optimal extraction results."""
|
|
lines = []
|
|
lines.append("Wasserstein-Optimal Refusal Direction Extraction")
|
|
lines.append("=" * 50)
|
|
lines.append("")
|
|
|
|
if not result.per_layer:
|
|
lines.append("No layers analyzed.")
|
|
return "\n".join(lines)
|
|
|
|
lines.append(f"Best layer (lowest cost ratio): {result.best_layer}")
|
|
lines.append(f"Mean cost-effectiveness ratio: {result.mean_cost_ratio:.6f}")
|
|
lines.append("")
|
|
|
|
for idx in sorted(result.per_layer.keys()):
|
|
r = result.per_layer[idx]
|
|
lines.append(f"Layer {idx}:")
|
|
lines.append(f" W2 cost: {r.wasserstein_cost:.6f}")
|
|
lines.append(f" Mean shift: {r.mean_shift_component:.6f}")
|
|
lines.append(f" Bures: {r.bures_component:.6f}")
|
|
lines.append(f" Refusal projection: {r.refusal_projection:.6f}")
|
|
lines.append(f" Cost ratio: {r.cost_effectiveness_ratio:.6f}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|