| """ |
| SHARED UTILITIES FOR ADVERSARIAL SAE TRAINING |
| |
| ============================================================================= |
| DO NOT RUN THIS FILE AS A TRAINING SCRIPT. |
| ============================================================================= |
| |
| This module provides shared utilities used by: |
| - finetune_adv_gen.py (generation-time feature suppression training) |
| - finetune_adv_gen_resume.py (resumable version of the above) |
| |
| The prefill-only training loop has been removed. All actual training should use |
| finetune_adv_gen.py or finetune_adv_gen_resume.py. |
| |
| Exported utilities: |
| Classes: |
| - FrozenSAEEncoder: Inference-only SAE encoder (frozen weights) |
| - LayerProbes: Per-layer linear probes for SAE features |
| - HiddenStateCapture: Hook-based activation capture context manager |
| |
| Loss functions: |
| - suppression_loss: Push probe outputs toward target on suppress rows |
| - probe_bce_loss_logits: BCE loss for probe training |
| - probe_present_loss_logits: Encourage probe to fire on present rows |
| - group_lasso_lora_by_layer: L1-over-L2 regularization on LoRA params |
| - activation_retain_mse: MSE between current and base activations |
| - kl_batchmean_masked: KL divergence with row masking |
| - probe_labels: Compute probe targets from scene/object masks |
| |
| Helpers: |
| - load_probe_checkpoint: Load probe weights from various checkpoint formats |
| - count_lm_layers: Count decoder layers in a model |
| - get_sae_features: Extract SAE features from captured activations |
| - parse_args: CLI argument parser for training scripts |
| - _Tee: Stream tee utility for logging |
| - _stable_target_probs: Numerically stable probability normalization |
| - _decoder_layers: Locate decoder layers in various model architectures |
| """ |
|
|
| from __future__ import annotations |
|
|
| import re |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| |
| |
| |
|
|
|
|
| class FrozenSAEEncoder(nn.Module): |
| """Inference-only mirror of BatchTopKSAE.encode(use_threshold=True). |
| |
| Per-row: standardize -> linear(x - pre_encoder_bias) -> JumpReLU(threshold). |
| Returns sparse latents matching what the SAE was trained to produce; gradient |
| flows through the active features only (the boolean gate stops grad on |
| inactive features). |
| """ |
|
|
| def __init__(self, d_in: int, d_sae: int, standardize: bool = True): |
| super().__init__() |
| self.encoder = nn.Linear(d_in, d_sae, bias=False) |
| self.pre_encoder_bias = nn.Parameter(torch.zeros(d_in)) |
| self.register_buffer("threshold", torch.zeros(())) |
| self.standardize = standardize |
| for p in self.parameters(): |
| p.requires_grad_(False) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| if self.standardize: |
| mu = x.mean(dim=-1, keepdim=True) |
| x = x - mu |
| std = x.std(dim=-1, keepdim=True) |
| x = x / (std + 1e-5) |
| hidden_pre = self.encoder(x - self.pre_encoder_bias) |
| gate = (F.relu(hidden_pre) > self.threshold).to(hidden_pre.dtype) |
| return hidden_pre * gate |
|
|
| @classmethod |
| def from_checkpoint(cls, ckpt_path: str, device: torch.device) -> "FrozenSAEEncoder": |
| ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False) |
| sd = ckpt["state_dict"] |
| enc_weight = sd["autoencoder.encoder.weight"] |
| d_sae, d_in = enc_weight.shape |
| hp = ckpt.get("hyper_parameters") or ckpt.get("hyperparameters") or {} |
| if isinstance(hp, dict): |
| standardize = bool(hp.get("standardize", True)) |
| else: |
| standardize = bool(getattr(hp, "standardize", True)) |
| enc = cls(d_in, d_sae, standardize=standardize) |
| enc.encoder.weight.data.copy_(enc_weight) |
| enc.pre_encoder_bias.data.copy_(sd["autoencoder.pre_encoder_bias"]) |
| if "autoencoder.threshold" in sd: |
| enc.threshold.data.copy_(sd["autoencoder.threshold"].to(enc.threshold.dtype)) |
| return enc.to(device) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class LayerProbes(nn.Module): |
| """One linear probe per monitored layer: sigma(Linear(f)); train probe with logits + BCEWithLogits.""" |
|
|
| def __init__(self, layer_indices: list[int], d_sae: int, spectral_norm: bool = False): |
| super().__init__() |
| self.layer_indices = layer_indices |
| if spectral_norm: |
| self.probes = nn.ModuleList([ |
| nn.utils.spectral_norm(nn.Linear(d_sae, 1)) for _ in layer_indices |
| ]) |
| else: |
| self.probes = nn.ModuleList([nn.Linear(d_sae, 1) for _ in layer_indices]) |
| self._idx = {l: i for i, l in enumerate(layer_indices)} |
|
|
| def forward_logits(self, features: dict[int, torch.Tensor]) -> list[torch.Tensor]: |
| return [self.probes[self._idx[l]](features[l]).squeeze(-1) for l in self.layer_indices] |
|
|
| def forward(self, features: dict[int, torch.Tensor]) -> list[torch.Tensor]: |
| return [torch.sigmoid(z) for z in self.forward_logits(features)] |
|
|
|
|
| def layer_probes_from_checkpoint( |
| path: str, |
| layer_indices: list[int], |
| d_sae: int, |
| device: torch.device | str | None = None, |
| ) -> LayerProbes: |
| """Load probes.pt from training. Detects spectral-norm probes (weight_orig/weight_u/weight_v). |
| |
| Always loads weights on CPU then moves the module to device so parameters match the activations' device |
| (avoids CPU weights + CUDA features after load_state_dict). |
| """ |
| sd = torch.load(path, map_location="cpu", weights_only=True) |
| if any(k.startswith("module.") for k in sd): |
| sd = {k.replace("module.", "", 1): v for k, v in sd.items()} |
| use_sn = any("weight_orig" in k for k in sd) |
| probes = LayerProbes(layer_indices, d_sae, spectral_norm=use_sn) |
| probes.load_state_dict(sd, strict=True) |
| if device is not None: |
| dev = device if isinstance(device, torch.device) else torch.device(device) |
| probes = probes.to(dev) |
| return probes |
|
|
|
|
| def load_probe_checkpoint(probes: LayerProbes, path: str) -> None: |
| """Load weights from LayerProbes state_dict or Train_Probe_SAE LinearProbe (fc.*).""" |
| sd = torch.load(path, map_location="cpu", weights_only=True) |
| if any(k.startswith("module.") for k in sd): |
| sd = {k.replace("module.", "", 1): v for k, v in sd.items()} |
| if any(k.startswith("probes.") for k in sd): |
| probes.load_state_dict(sd, strict=True) |
| return |
| if "fc.weight" in sd: |
| w = sd["fc.weight"] |
| b = sd.get("fc.bias") |
| if b is None: |
| b = torch.zeros(w.shape[0], device=w.device, dtype=w.dtype) |
| for lin in probes.probes: |
| lin.weight.data.copy_(w) |
| lin.bias.data.copy_(b) |
| return |
| raise ValueError( |
| f"Unrecognised probe checkpoint {path}: expected LayerProbes keys or fc.weight/fc.bias" |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _decoder_layers(raw_model) -> nn.ModuleList: |
| """LLaVA / Llama: locate the LlamaDecoderLayer ModuleList. |
| |
| Transformers has moved the language model around across versions: |
| - older: raw_model.language_model.{model.layers|layers} |
| - newer: raw_model.model.language_model.{layers|model.layers} |
| """ |
| lm = getattr(raw_model, "language_model", None) |
| if lm is None: |
| inner_model = getattr(raw_model, "model", None) |
| if inner_model is not None: |
| lm = getattr(inner_model, "language_model", None) |
| if lm is None: |
| raise AttributeError( |
| "Cannot locate language_model under raw_model " |
| "(checked raw_model.language_model and raw_model.model.language_model)" |
| ) |
| inner = getattr(lm, "model", None) |
| if inner is not None and hasattr(inner, "layers"): |
| return inner.layers |
| if hasattr(lm, "layers"): |
| return lm.layers |
| raise AttributeError( |
| "Cannot find Llama decoder layers: expected language_model.model.layers " |
| "or language_model.layers" |
| ) |
|
|
|
|
| class HiddenStateCapture: |
| def __init__(self, raw_model, layer_indices: list[int]): |
| self._layers = _decoder_layers(raw_model) |
| self.layer_indices = layer_indices |
| self.hidden_states: dict[int, torch.Tensor] = {} |
| self._hooks: list = [] |
|
|
| def __enter__(self): |
| self.hidden_states.clear() |
| for l in self.layer_indices: |
| def _hook(mod, inp, out, idx=l): |
| h = out[0] if isinstance(out, tuple) else out |
| self.hidden_states[idx] = h |
|
|
| self._hooks.append(self._layers[l].register_forward_hook(_hook)) |
| return self |
|
|
| def __exit__(self, *_): |
| for h in self._hooks: |
| h.remove() |
| self._hooks.clear() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _stable_target_probs(p: torch.Tensor) -> torch.Tensor: |
| """Clamp + renormalize so kl_div does not get 0 * log 0 -> NaN.""" |
| p = p.float().clamp(min=1e-8) |
| return p / p.sum(dim=-1, keepdim=True) |
|
|
|
|
| def suppression_loss( |
| probs_list: list[torch.Tensor], |
| suppress_mask: torch.Tensor, |
| mode: str = "union", |
| ) -> torch.Tensor: |
| """Suppression loss on suppress_mask (D_{A,B}), mean over layers. |
| |
| union mode (probe label = 1 for scene-only): probe was trained to fire on the A->B prior. |
| Target = p=0.5 (max entropy): the model erases the prior so the probe is fully uncertain. |
| Loss = -H(p) = p*log(p) + (1-p)*log(1-p), minimised at p=0.5. |
| |
| object_only mode (probe label = 0 for scene-only): probe was trained to detect actual B features. |
| Target = p=0 (BCE toward 0): the model erases B-correlated features so the probe correctly |
| sees no B information. Pushing to 0.5 would make activations ambiguous (worse, not better). |
| Loss = BCE(p, 0) = -log(1-p). |
| """ |
| if not suppress_mask.any(): |
| return probs_list[0].sum() * 0.0 |
| eps = 1e-6 |
| total = 0.0 |
| for probs in probs_list: |
| p = probs[suppress_mask].float().clamp(eps, 1 - eps) |
| if mode == "union": |
| h = -(p * p.log() + (1 - p) * (1 - p).log()) |
| total = total + (-h.mean()) |
| else: |
| total = total + (-((1 - p).log()).mean()) |
| return total / len(probs_list) |
|
|
|
|
| def probe_bce_loss_logits( |
| logits_list: list[torch.Tensor], |
| labels: torch.Tensor, |
| label_smoothing: float = 0.0, |
| ) -> torch.Tensor: |
| labels = labels.float() |
| if label_smoothing > 0.0: |
| labels = labels * (1.0 - label_smoothing) + 0.5 * label_smoothing |
| return sum( |
| F.binary_cross_entropy_with_logits(z.float(), labels, reduction="mean") |
| for z in logits_list |
| ) / len(logits_list) |
|
|
|
|
| def probe_present_loss_logits( |
| logits_list: list[torch.Tensor], |
| present_mask: torch.Tensor, |
| target: float = 1.0, |
| ) -> torch.Tensor: |
| """Encourage probe to predict 'present' on D_B (has_object==1).""" |
| if not present_mask.any(): |
| return logits_list[0].sum() * 0.0 |
| y = logits_list[0].new_full((int(present_mask.sum().item()),), float(target)) |
| return sum( |
| F.binary_cross_entropy_with_logits(z[present_mask].float(), y, reduction="mean") |
| for z in logits_list |
| ) / len(logits_list) |
|
|
|
|
| _LAYER_RE = re.compile(r"\.layers\.(\d+)\.") |
|
|
|
|
| def group_lasso_lora_by_layer(trainable_named_params) -> torch.Tensor: |
| """L1(L2(DeltaW)) across layers: sum_l sqrt(sum_{p in layer l} ||p||_2^2). |
| |
| Uses parameter-name heuristic to map LoRA params to decoder layers. |
| """ |
| per_layer_sq = {} |
| device = None |
| for name, p in trainable_named_params: |
| if not p.requires_grad: |
| continue |
| m = _LAYER_RE.search(name) |
| if m is None: |
| continue |
| l = int(m.group(1)) |
| if device is None: |
| device = p.device |
| per_layer_sq[l] = per_layer_sq.get(l, 0.0) + (p.float().pow(2).sum()) |
| if not per_layer_sq: |
| return torch.zeros((), device=device if device is not None else None) |
| return torch.stack([(v + 1e-12).sqrt() for _, v in sorted(per_layer_sq.items())]).sum() |
|
|
|
|
| def activation_retain_mse( |
| h_current: dict[int, torch.Tensor], |
| h_base: dict[int, torch.Tensor], |
| retain_mask: torch.Tensor, |
| ) -> torch.Tensor: |
| if not retain_mask.any(): |
| any_h = next(iter(h_current.values())) |
| return any_h.sum() * 0.0 |
| total = 0.0 |
| n = 0 |
| for l, hc in h_current.items(): |
| hb = h_base.get(l) |
| if hb is None: |
| continue |
| |
| dc = hc.mean(dim=1)[retain_mask].float() |
| db = hb.mean(dim=1)[retain_mask].float() |
| total = total + F.mse_loss(dc, db, reduction="mean") |
| n += 1 |
| if n == 0: |
| any_h = next(iter(h_current.values())) |
| return any_h.sum() * 0.0 |
| return total / n |
|
|
|
|
| def kl_batchmean_masked( |
| current_logits: torch.Tensor, |
| p_base: torch.Tensor, |
| sample_mask: torch.Tensor, |
| ) -> torch.Tensor: |
| """Same KL as L_task but averaged only over batch rows with sample_mask.""" |
| if not sample_mask.any(): |
| return current_logits.sum() * 0.0 |
| log_q = F.log_softmax(current_logits[sample_mask].float(), dim=-1) |
| pb = _stable_target_probs(p_base[sample_mask]) |
| return F.kl_div(log_q, pb, reduction="batchmean") |
|
|
|
|
| def get_sae_features( |
| capture: HiddenStateCapture, |
| sae: FrozenSAEEncoder, |
| ) -> dict[int, torch.Tensor]: |
| return {l: sae(h.mean(dim=1)) for l, h in capture.hidden_states.items()} |
|
|
|
|
| def count_lm_layers(model) -> int: |
| try: |
| return len(_decoder_layers(model)) |
| except AttributeError: |
| pass |
| for _, module in model.named_modules(): |
| if hasattr(module, "layers") and isinstance(module.layers, nn.ModuleList): |
| return len(module.layers) |
| return 32 |
|
|
|
|
| def probe_labels( |
| is_scene: torch.Tensor, |
| has_object: torch.Tensor, |
| mode: str, |
| ) -> torch.Tensor: |
| if mode == "union": |
| return ((is_scene + has_object) > 0).float() |
| if mode == "object_only": |
| return has_object.float() |
| if mode == "scene_only": |
| return is_scene.float() |
| raise ValueError(f"Unknown probe_label_mode: {mode}") |
|
|
|
|
| |
| |
| |
|
|
|
|
| class _Tee: |
| """Stream tee utility for logging to both stdout and file.""" |
| def __init__(self, *streams): |
| self._streams = streams |
|
|
| def write(self, data): |
| for s in self._streams: |
| s.write(data) |
| s.flush() |
|
|
| def flush(self): |
| for s in self._streams: |
| s.flush() |
|
|
| def isatty(self) -> bool: |
| return any(getattr(s, "isatty", lambda: False)() for s in self._streams) |
|
|
| def fileno(self): |
| return self._streams[0].fileno() |
|
|
|
|
| def parse_args(): |
| """CLI argument parser for training scripts.""" |
| import argparse |
| parser = argparse.ArgumentParser(description="Adversarial SAE representation suppression") |
| parser.add_argument("--config", type=str, required=True) |
| parser.add_argument("--relation", type=str, default=None) |
| args, unknown = parser.parse_known_args() |
| overrides = {} |
| i = 0 |
| while i < len(unknown): |
| key = unknown[i].lstrip("-") |
| if i + 1 < len(unknown) and not unknown[i + 1].startswith("--"): |
| overrides[key] = unknown[i + 1] |
| i += 2 |
| else: |
| overrides[key] = "true" |
| i += 1 |
| return args, overrides |