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

764 lines
29 KiB
Python

"""Sparse Autoencoder (SAE) Feature-Level Abliteration.
Standard abliteration projects weight matrices along refusal directions
extracted from raw activation space. But the refusal direction in
activation space is a *linear combination* of many underlying features,
some safety-related and some capability-related. This cross-talk means
that projecting out the refusal direction also damages overlapping
capabilities.
Sparse Autoencoders decompose activations into an overcomplete set of
interpretable features. In this higher-dimensional feature space, refusal
may be represented by a small number of specific features that are
cleanly separable from capability features. By identifying and ablating
only the refusal features, we achieve more precise removal with less
collateral damage.
Approach:
1. Train a lightweight SAE on residual stream activations at key layers
2. Encode harmful and harmless activations through the SAE
3. Identify features with large activation differences (refusal features)
4. Convert refusal features back to hidden-space directions via the
decoder weight columns
5. Use these SAE-derived directions as more precise projection targets
The SAE-derived directions complement SVD-extracted directions by
operating in a disentangled feature space rather than raw activation
space.
References:
- Cunningham et al. (2023): Sparse Autoencoders Find Highly Interpretable Features
- Bricken et al. (2023): Towards Monosemanticity (Anthropic)
- Templeton et al. (2024): Scaling Monosemanticity
- EMNLP 2025: Understanding Refusal with Sparse Autoencoders
"""
from __future__ import annotations
from dataclasses import dataclass
import torch
import torch.nn as nn
from obliteratus import device as dev
@dataclass
class SAERefusalFeatures:
"""Result of SAE-based refusal feature identification."""
layer_idx: int
n_features_total: int
n_refusal_features: int
refusal_feature_indices: list[int]
refusal_scores: torch.Tensor # per-feature refusal score
sae_directions: torch.Tensor # (n_refusal, hidden_dim) — directions in hidden space
variance_explained: float # how much refusal variance these features capture
reconstruction_loss: float # SAE reconstruction quality
class SparseAutoencoder(nn.Module):
"""Lightweight sparse autoencoder for refusal feature extraction.
Architecture: hidden_dim → expansion * hidden_dim → hidden_dim
with ReLU activation for sparsity and L1 penalty on the latent.
The decoder columns of identified refusal features give directions
in hidden space that can be used for more precise abliteration.
"""
def __init__(self, hidden_dim: int, expansion: int = 4, tied_weights: bool = True):
super().__init__()
self.hidden_dim = hidden_dim
self.n_features = expansion * hidden_dim
self.tied_weights = tied_weights
# Encoder: hidden → features (overcomplete)
self.encoder = nn.Linear(hidden_dim, self.n_features, bias=True)
# Decoder: features → hidden (reconstruct)
if tied_weights:
# Tied weights: decoder uses encoder.weight.T directly (no separate param).
# We only need the decoder bias as a learnable parameter.
self.decoder_bias = nn.Parameter(torch.zeros(hidden_dim))
else:
self.decoder = nn.Linear(self.n_features, hidden_dim, bias=True)
# Initialize with Kaiming for ReLU
nn.init.kaiming_uniform_(self.encoder.weight, nonlinearity="relu")
nn.init.zeros_(self.encoder.bias)
if not tied_weights:
nn.init.zeros_(self.decoder.bias)
def encode(self, x: torch.Tensor) -> torch.Tensor:
"""Encode to sparse feature activations."""
return torch.relu(self.encoder(x))
@property
def decoder_weight(self) -> torch.Tensor:
"""Return the decoder weight matrix (hidden_dim x n_features for untied, or encoder.weight.T)."""
if self.tied_weights:
return self.encoder.weight.T
return self.decoder.weight
def decode(self, z: torch.Tensor) -> torch.Tensor:
"""Decode from features back to hidden space."""
if self.tied_weights:
return z @ self.encoder.weight + self.decoder_bias
return self.decoder(z)
def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""Forward pass returning reconstruction and latent features."""
z = self.encode(x)
x_hat = self.decode(z)
return x_hat, z
def _auto_detect_device(device: str | None = None) -> str:
"""Auto-detect the best available device for SAE training.
When device is ``None`` or ``"auto"``, selects CUDA if available
and sufficient free memory exists (>512 MB), otherwise falls back
to CPU.
"""
if device is not None and device not in ("auto",):
return device
if dev.is_gpu_available():
try:
free_mb = dev.get_total_free_gb() * 1024
if free_mb > 512:
return dev.get_device()
except Exception:
pass
return "cpu"
def train_sae(
activations: list[torch.Tensor],
hidden_dim: int,
expansion: int = 4,
n_epochs: int = 50,
lr: float = 3e-4,
sparsity_coef: float = 1e-3,
batch_size: int = 32,
device: str | None = None,
test_fraction: float = 0.2,
patience: int = 5,
quality_threshold: float = 0.1,
) -> SparseAutoencoder:
"""Train a sparse autoencoder on collected activations.
Uses reconstruction loss + L1 sparsity penalty with train/test split,
early stopping on held-out loss, and a reconstruction quality gate.
Args:
activations: List of activation tensors (each shape: (hidden_dim,) or (1, hidden_dim))
hidden_dim: Model hidden dimension
expansion: Feature expansion factor (features = expansion * hidden_dim)
n_epochs: Training epochs
lr: Learning rate
sparsity_coef: L1 sparsity penalty weight
batch_size: Mini-batch size
device: Training device. ``None`` or ``"auto"`` to auto-detect
(CUDA when available with sufficient free memory, else CPU).
test_fraction: Fraction of data reserved for held-out validation
patience: Early stopping patience (epochs without improvement)
quality_threshold: Maximum acceptable held-out reconstruction MSE.
If the final test loss exceeds this, a warning is emitted
indicating the SAE directions may be unreliable.
"""
import warnings
device = _auto_detect_device(device)
# Stack and normalize activations
X = torch.stack([a.squeeze() for a in activations]).float().to(device)
mean = X.mean(dim=0, keepdim=True)
X = X - mean # center activations
# ── Train/test split ───────────────────────────────────────────
n_samples = X.shape[0]
n_test = max(1, int(n_samples * test_fraction))
n_train = n_samples - n_test
perm = torch.randperm(n_samples, device=device)
X_train = X[perm[:n_train]]
X_test = X[perm[n_train:]]
sae = SparseAutoencoder(hidden_dim, expansion).to(device)
optimizer = torch.optim.Adam(sae.parameters(), lr=lr)
best_test_loss = float("inf")
best_state = None
epochs_without_improvement = 0
for epoch in range(n_epochs):
# ── Training ───────────────────────────────────────────────
sae.train()
train_perm = torch.randperm(n_train, device=device)
X_shuffled = X_train[train_perm]
epoch_loss = 0.0
n_batches = 0
for i in range(0, n_train, batch_size):
batch = X_shuffled[i : i + batch_size]
x_hat, z = sae(batch)
recon_loss = (batch - x_hat).pow(2).mean()
sparsity_loss = z.abs().mean()
loss = recon_loss + sparsity_coef * sparsity_loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Normalize decoder columns to unit norm (prevents feature collapse).
with torch.no_grad():
if sae.tied_weights:
row_norms = sae.encoder.weight.data.norm(dim=1, keepdim=True).clamp(min=1e-8)
sae.encoder.weight.data.div_(row_norms)
else:
norms = sae.decoder.weight.data.norm(dim=0, keepdim=True).clamp(min=1e-8)
sae.decoder.weight.data.div_(norms)
epoch_loss += loss.item()
n_batches += 1
# ── Held-out validation ────────────────────────────────────
sae.eval()
with torch.no_grad():
x_hat_test, z_test = sae(X_test)
test_recon = (X_test - x_hat_test).pow(2).mean().item()
test_sparsity = z_test.abs().mean().item()
test_loss = test_recon + sparsity_coef * test_sparsity
# ── Early stopping ─────────────────────────────────────────
if test_loss < best_test_loss:
best_test_loss = test_loss
best_state = {k: v.clone() for k, v in sae.state_dict().items()}
epochs_without_improvement = 0
else:
epochs_without_improvement += 1
if epochs_without_improvement >= patience:
break
# Restore best checkpoint
if best_state is not None:
sae.load_state_dict(best_state)
sae.eval()
# ── Quality gate ───────────────────────────────────────────────
with torch.no_grad():
x_hat_final, _ = sae(X_test)
final_test_mse = (X_test - x_hat_final).pow(2).mean().item()
if final_test_mse > quality_threshold:
warnings.warn(
f"SAE held-out reconstruction MSE ({final_test_mse:.4f}) exceeds "
f"quality threshold ({quality_threshold}). SAE-derived refusal "
f"directions may be unreliable due to overfitting or insufficient "
f"training data ({n_train} train / {n_test} test samples). "
f"Consider increasing prompt count or reducing expansion factor.",
stacklevel=2,
)
return sae
def identify_refusal_features(
sae: SparseAutoencoder,
harmful_acts: list[torch.Tensor],
harmless_acts: list[torch.Tensor],
layer_idx: int,
top_k: int = 16,
device: str | None = None,
) -> SAERefusalFeatures:
"""Identify SAE features that encode refusal behavior.
Compares feature activations between harmful and harmless prompts.
Features with large positive differences (more active for harmful)
are candidates for refusal encoding.
Args:
sae: Trained sparse autoencoder
harmful_acts: Activations from harmful prompts
harmless_acts: Activations from harmless prompts
layer_idx: Which layer these activations are from
top_k: Number of top refusal features to return
device: Computation device. ``None`` or ``"auto"`` to auto-detect.
"""
device = _auto_detect_device(device)
sae = sae.to(device)
with torch.no_grad():
# Encode both sets — center inputs to match train_sae preprocessing
X_harm = torch.stack([a.squeeze() for a in harmful_acts]).float().to(device)
X_safe = torch.stack([a.squeeze() for a in harmless_acts]).float().to(device)
# Center using pooled mean (same centering used in train_sae)
X_all = torch.cat([X_harm, X_safe], dim=0)
mean = X_all.mean(dim=0, keepdim=True)
X_harm = X_harm - mean
X_safe = X_safe - mean
z_harm = sae.encode(X_harm) # (n_harmful, n_features)
z_safe = sae.encode(X_safe) # (n_harmless, n_features)
# Per-feature mean activation difference
mean_harm = z_harm.mean(dim=0) # (n_features,)
mean_safe = z_safe.mean(dim=0) # (n_features,)
diff = mean_harm - mean_safe # positive = more active for harmful
# Z-score normalization: use pooled std for significance
pooled = torch.cat([z_harm, z_safe], dim=0)
std = pooled.std(dim=0).clamp(min=1e-8)
z_scores = diff / std
# Select top-k features by POSITIVE z-score only.
# Positive z = more active for harmful prompts = refusal features.
# Using abs() would also select anti-refusal features (negative z),
# and projecting those out would INCREASE refusal.
top_k = min(top_k, z_scores.shape[0])
_, top_indices = z_scores.topk(top_k)
refusal_indices = top_indices.cpu().tolist()
# Extract directions from decoder columns
# Each decoder column is the hidden-space direction for a feature.
# decoder_weight shape is always (hidden_dim, n_features) regardless
# of tied/untied mode.
dec_w = sae.decoder_weight.data # (hidden_dim, n_features)
directions = dec_w[:, top_indices].T # (top_k, hidden_dim)
directions = directions / directions.norm(dim=1, keepdim=True).clamp(min=1e-8)
# Compute variance explained
# Project harmful-harmless diff onto SAE directions
raw_diff = (X_harm.mean(0) - X_safe.mean(0))
raw_diff_norm = raw_diff.norm().item()
if raw_diff_norm > 0:
# How much of the raw difference is captured by SAE directions
projected = torch.zeros_like(raw_diff)
for d in directions:
comp = (raw_diff @ d) * d
projected = projected + comp
var_explained = projected.norm().item() / raw_diff_norm
else:
var_explained = 0.0
# Reconstruction quality
x_hat_harm, _ = sae(X_harm)
recon_loss = (X_harm - x_hat_harm).pow(2).mean().item()
return SAERefusalFeatures(
layer_idx=layer_idx,
n_features_total=sae.n_features,
n_refusal_features=top_k,
refusal_feature_indices=refusal_indices,
refusal_scores=z_scores[top_indices].cpu(),
sae_directions=directions.cpu(),
variance_explained=min(var_explained, 1.0),
reconstruction_loss=recon_loss,
)
# ---------------------------------------------------------------------------
# Enhanced SAE Decomposition Pipeline
# ---------------------------------------------------------------------------
@dataclass
class FeatureClusterResult:
"""Result of clustering SAE features into semantic groups."""
n_clusters: int
cluster_labels: list[int] # cluster assignment per refusal feature
cluster_directions: torch.Tensor # (n_clusters, hidden_dim) mean directions
cluster_strengths: list[float] # per-cluster mean refusal score
silhouette_score: float # clustering quality (-1 to 1)
@dataclass
class SAEDecompositionResult:
"""Full decomposition pipeline result."""
layer_idx: int
sae: SparseAutoencoder
refusal_features: SAERefusalFeatures
# Feature characterization
feature_sparsity: list[float] # L0 sparsity per refusal feature
feature_monosemanticity: list[float] # activation consistency scores
feature_clusters: FeatureClusterResult | None
# Ablation simulation
per_feature_refusal_reduction: list[float] # estimated refusal drop per feature
cumulative_refusal_reduction: list[float] # cumulative as features are added
# Comparison with raw direction
raw_direction_overlap: float # cosine with diff-in-means direction
sae_improvement_estimate: float # estimated precision improvement
class SAEDecompositionPipeline:
"""Full SAE decomposition pipeline following Anthropic's methodology.
Extends the basic train-and-identify workflow with:
1. Feature sparsity and monosemanticity analysis
2. Feature clustering into semantic groups
3. Greedy feature ablation simulation
4. Comparison with raw-direction methods
References:
- Bricken et al. (2023): Towards Monosemanticity
- Cunningham et al. (2023): Sparse Autoencoders Find Interpretable Features
- Templeton et al. (2024): Scaling Monosemanticity
"""
def __init__(
self,
expansion: int = 4,
n_epochs: int = 50,
lr: float = 3e-4,
sparsity_coef: float = 1e-3,
top_k_features: int = 16,
n_clusters: int = 4,
):
self.expansion = expansion
self.n_epochs = n_epochs
self.lr = lr
self.sparsity_coef = sparsity_coef
self.top_k_features = top_k_features
self.n_clusters = n_clusters
def run(
self,
harmful_acts: list[torch.Tensor],
harmless_acts: list[torch.Tensor],
layer_idx: int = 0,
device: str | None = None,
) -> SAEDecompositionResult:
"""Run the full decomposition pipeline.
Args:
harmful_acts: Activations from harmful prompts.
harmless_acts: Activations from harmless prompts.
layer_idx: Layer index for metadata.
device: Computation device. ``None`` or ``"auto"`` to auto-detect.
Returns:
SAEDecompositionResult with comprehensive feature analysis.
"""
device = _auto_detect_device(device)
all_acts = harmful_acts + harmless_acts
hidden_dim = harmful_acts[0].squeeze().shape[0]
# Step 1: Train SAE
sae = train_sae(
all_acts, hidden_dim,
expansion=self.expansion,
n_epochs=self.n_epochs,
lr=self.lr,
sparsity_coef=self.sparsity_coef,
device=device,
)
# Step 2: Identify refusal features
refusal_features = identify_refusal_features(
sae, harmful_acts, harmless_acts, layer_idx,
top_k=self.top_k_features, device=device,
)
# Step 3: Compute feature sparsity and monosemanticity
sparsity, monosemanticity = self._analyze_features(
sae, harmful_acts, harmless_acts,
refusal_features.refusal_feature_indices, device,
)
# Step 4: Cluster features
clusters = self._cluster_features(refusal_features)
# Step 5: Ablation simulation
per_feat_reduction, cumul_reduction = self._ablation_simulation(
sae, harmful_acts, harmless_acts,
refusal_features.refusal_feature_indices, device,
)
# Step 6: Compare with raw direction
raw_overlap = self._compare_raw_direction(
harmful_acts, harmless_acts, refusal_features.sae_directions,
)
# Estimate improvement: higher variance explained with sparser intervention
improvement = refusal_features.variance_explained * (1.0 - raw_overlap)
return SAEDecompositionResult(
layer_idx=layer_idx,
sae=sae,
refusal_features=refusal_features,
feature_sparsity=sparsity,
feature_monosemanticity=monosemanticity,
feature_clusters=clusters,
per_feature_refusal_reduction=per_feat_reduction,
cumulative_refusal_reduction=cumul_reduction,
raw_direction_overlap=raw_overlap,
sae_improvement_estimate=improvement,
)
def _analyze_features(
self,
sae: SparseAutoencoder,
harmful_acts: list[torch.Tensor],
harmless_acts: list[torch.Tensor],
feature_indices: list[int],
device: str,
) -> tuple[list[float], list[float]]:
"""Compute per-feature sparsity and monosemanticity scores."""
all_acts = harmful_acts + harmless_acts
X = torch.stack([a.squeeze() for a in all_acts]).float().to(device)
with torch.no_grad():
z = sae.encode(X) # (n_samples, n_features)
sparsity_scores = []
mono_scores = []
for idx in feature_indices:
feat_acts = z[:, idx] # (n_samples,)
# L0 sparsity: fraction of samples where feature is active
l0 = (feat_acts > 0.01).float().mean().item()
sparsity_scores.append(l0)
# Monosemanticity: how consistently the feature activates
# for one class vs the other
n_harm = len(harmful_acts)
harm_acts = feat_acts[:n_harm]
safe_acts = feat_acts[n_harm:]
harm_mean = harm_acts.mean().item()
safe_mean = safe_acts.mean().item()
# Monosemanticity = |harm_mean - safe_mean| / (pooled_std + eps)
pooled_std = feat_acts.std().item() + 1e-8
mono = abs(harm_mean - safe_mean) / pooled_std
mono_scores.append(min(mono, 5.0)) # cap at 5
return sparsity_scores, mono_scores
def _cluster_features(
self, refusal_features: SAERefusalFeatures,
) -> FeatureClusterResult | None:
"""Cluster refusal features by direction similarity."""
directions = refusal_features.sae_directions # (k, hidden_dim)
k = directions.shape[0]
if k < 2:
return None
n_clusters = min(self.n_clusters, k)
# Cosine similarity matrix
cos_sim = directions @ directions.T # (k, k)
# Simple k-means-like clustering in direction space
# Initialize centroids from most dissimilar features
labels = [0] * k
centroids = [directions[0]]
for c in range(1, n_clusters):
# Pick the feature most dissimilar to existing centroids
min_sims = []
for i in range(k):
max_sim = max(
abs((directions[i] @ cent).item())
for cent in centroids
)
min_sims.append(max_sim)
new_idx = min(range(k), key=lambda i: min_sims[i])
centroids.append(directions[new_idx])
# Assign features to nearest centroid (5 iterations)
for _ in range(5):
centroid_stack = torch.stack(centroids) # (n_clusters, hidden_dim)
sims = (directions @ centroid_stack.T).abs() # (k, n_clusters)
labels = sims.argmax(dim=1).tolist()
# Recompute centroids
new_centroids = []
for c in range(n_clusters):
members = [i for i, lbl in enumerate(labels) if lbl == c]
if members:
cent = directions[members].mean(dim=0)
cent = cent / cent.norm().clamp(min=1e-8)
new_centroids.append(cent)
else:
new_centroids.append(centroids[c])
centroids = new_centroids
cluster_dirs = torch.stack(centroids)
cluster_strengths = []
for c in range(n_clusters):
members = [i for i, lbl in enumerate(labels) if lbl == c]
if members:
strength = refusal_features.refusal_scores[members].abs().mean().item()
else:
strength = 0.0
cluster_strengths.append(strength)
# Silhouette score approximation
sil = self._silhouette_approx(cos_sim, labels, n_clusters)
return FeatureClusterResult(
n_clusters=n_clusters,
cluster_labels=labels,
cluster_directions=cluster_dirs,
cluster_strengths=cluster_strengths,
silhouette_score=sil,
)
def _silhouette_approx(
self, cos_sim: torch.Tensor, labels: list[int], n_clusters: int,
) -> float:
"""Approximate silhouette score from cosine similarity matrix."""
k = cos_sim.shape[0]
if k < 2 or n_clusters < 2:
return 0.0
scores = []
for i in range(k):
# Intra-cluster similarity
same = [j for j in range(k) if labels[j] == labels[i] and j != i]
if same:
a_i = 1.0 - cos_sim[i, same].abs().mean().item() # distance
else:
a_i = 0.0
# Nearest other cluster distance
b_i = float('inf')
for c in range(n_clusters):
if c == labels[i]:
continue
others = [j for j in range(k) if labels[j] == c]
if others:
dist = 1.0 - cos_sim[i, others].abs().mean().item()
b_i = min(b_i, dist)
if b_i == float('inf'):
b_i = 0.0
denom = max(a_i, b_i)
if denom > 0:
scores.append((b_i - a_i) / denom)
else:
scores.append(0.0)
return sum(scores) / len(scores)
def _ablation_simulation(
self,
sae: SparseAutoencoder,
harmful_acts: list[torch.Tensor],
harmless_acts: list[torch.Tensor],
feature_indices: list[int],
device: str,
) -> tuple[list[float], list[float]]:
"""Simulate ablating refusal features one at a time."""
X_harm = torch.stack([a.squeeze() for a in harmful_acts]).float().to(device)
X_safe = torch.stack([a.squeeze() for a in harmless_acts]).float().to(device)
with torch.no_grad():
z_harm = sae.encode(X_harm)
z_safe = sae.encode(X_safe)
# Baseline refusal signal in feature space
diff_baseline = (z_harm.mean(0) - z_safe.mean(0))
baseline_signal = diff_baseline.norm().item()
per_feat = []
cumulative = []
ablated_indices = set()
for idx in feature_indices:
with torch.no_grad():
# Zero out this feature
z_harm_mod = z_harm.clone()
z_harm_mod[:, idx] = 0.0
diff_mod = (z_harm_mod.mean(0) - z_safe.mean(0))
mod_signal = diff_mod.norm().item()
reduction = (baseline_signal - mod_signal) / max(baseline_signal, 1e-10)
per_feat.append(max(0.0, reduction))
ablated_indices.add(idx)
with torch.no_grad():
z_harm_cumul = z_harm.clone()
for ai in ablated_indices:
z_harm_cumul[:, ai] = 0.0
diff_cumul = (z_harm_cumul.mean(0) - z_safe.mean(0))
cumul_signal = diff_cumul.norm().item()
cumul_reduction = (baseline_signal - cumul_signal) / max(baseline_signal, 1e-10)
cumulative.append(max(0.0, cumul_reduction))
return per_feat, cumulative
def _compare_raw_direction(
self,
harmful_acts: list[torch.Tensor],
harmless_acts: list[torch.Tensor],
sae_directions: torch.Tensor,
) -> float:
"""Compare SAE-derived directions with the raw diff-in-means direction."""
H = torch.stack([a.squeeze() for a in harmful_acts]).float()
B = torch.stack([a.squeeze() for a in harmless_acts]).float()
raw_diff = H.mean(0) - B.mean(0)
raw_dir = raw_diff / raw_diff.norm().clamp(min=1e-8)
# Max cosine similarity between raw direction and any SAE direction
if sae_directions.shape[0] == 0:
return 0.0
cosines = (sae_directions @ raw_dir).abs()
return cosines.max().item()
@staticmethod
def format_report(result: SAEDecompositionResult) -> str:
"""Format full decomposition pipeline results."""
lines = []
lines.append("SAE Feature Decomposition Pipeline")
lines.append("=" * 36)
lines.append("")
rf = result.refusal_features
lines.append(f"Layer: {result.layer_idx}")
lines.append(f"Total SAE features: {rf.n_features_total}")
lines.append(f"Refusal features identified: {rf.n_refusal_features}")
lines.append(f"Variance explained: {rf.variance_explained:.1%}")
lines.append(f"Reconstruction loss: {rf.reconstruction_loss:.6f}")
lines.append(f"Raw direction overlap: {result.raw_direction_overlap:.3f}")
lines.append(f"Estimated improvement: {result.sae_improvement_estimate:.3f}")
lines.append("")
# Per-feature analysis
lines.append("Top refusal features:")
for i, idx in enumerate(rf.refusal_feature_indices[:10]):
score = rf.refusal_scores[i].item()
sp = result.feature_sparsity[i] if i < len(result.feature_sparsity) else 0
mono = result.feature_monosemanticity[i] if i < len(result.feature_monosemanticity) else 0
red = result.per_feature_refusal_reduction[i] if i < len(result.per_feature_refusal_reduction) else 0
lines.append(
f" Feature {idx:5d}: score={score:+.3f} "
f"sparsity={sp:.2f} mono={mono:.2f} "
f"reduction={red:.1%}"
)
if result.cumulative_refusal_reduction:
lines.append("")
lines.append(f"Cumulative refusal reduction (all {rf.n_refusal_features} features): "
f"{result.cumulative_refusal_reduction[-1]:.1%}")
if result.feature_clusters:
fc = result.feature_clusters
lines.append("")
lines.append(f"Feature clusters: {fc.n_clusters} (silhouette={fc.silhouette_score:.3f})")
for c in range(fc.n_clusters):
n_members = sum(1 for lbl in fc.cluster_labels if lbl == c)
lines.append(f" Cluster {c}: {n_members} features, strength={fc.cluster_strengths[c]:.3f}")
return "\n".join(lines)