Spaces:
Running on Zero
Running on Zero
| from __future__ import annotations | |
| import importlib | |
| import math | |
| import os | |
| import re | |
| from functools import partial | |
| from typing import Any, Dict | |
| import torch | |
| from omegaconf import DictConfig, OmegaConf | |
| import torch.nn.functional as F | |
| from steerable_retrieval.callbacks.energy import BridgeScoreCallback, get_dictionary_from_lightningsae, load_state_dict_any | |
| from steerable_retrieval.experiments.common import ensure_dir, read_vocab_lines | |
| from steerable_retrieval.utils import RankedLogger | |
| log = RankedLogger(__name__, rank_zero_only=True) | |
| def _slugify(value: str) -> str: | |
| slug = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-") | |
| return slug or "na" | |
| def _flatten_params(prefix: str, value: Any, items: list[tuple[str, str]]) -> None: | |
| if isinstance(value, dict): | |
| for key in sorted(value.keys()): | |
| child_prefix = f"{prefix}.{key}" if prefix else str(key) | |
| _flatten_params(child_prefix, value[key], items) | |
| return | |
| items.append((prefix or "param", str(value))) | |
| def _auto_experiment_id(cfg: DictConfig) -> str: | |
| extractor_cfg = cfg.concepts_experiment.get("extractor") | |
| params_cfg = extractor_cfg.get("params", {}) if extractor_cfg is not None else {} | |
| params = OmegaConf.to_container(params_cfg, resolve=True) if params_cfg is not None else {} | |
| if not isinstance(params, dict): | |
| params = {"value": params} | |
| method = params.get("selection_method") | |
| if method is None: | |
| target = str(extractor_cfg.get("target", "extract")) if extractor_cfg is not None else "extract" | |
| method = target.rsplit(".", 1)[-1] | |
| method_slug = _slugify(method) | |
| flat_items: list[tuple[str, str]] = [] | |
| _flatten_params("", params, flat_items) | |
| parts = [f"{_slugify(k)}-{_slugify(v)}" for k, v in flat_items if str(v) != ""] | |
| suffix = "__".join(parts) | |
| out = f"{method_slug}__{suffix}" if suffix else method_slug | |
| return out[:180] | |
| def _resolve_output_dir(cfg: DictConfig, output_dir: str, source_run_id: str) -> tuple[str, str, str]: | |
| run_id = cfg.get("model_source", {}).get("run_id") or source_run_id or "unknown_run" | |
| run_slug = _slugify(run_id) | |
| experiment_id_cfg = cfg.get("experiment_id") | |
| experiment_id = _slugify(experiment_id_cfg) if experiment_id_cfg else _auto_experiment_id(cfg) | |
| full_output_dir = ensure_dir(os.path.join(output_dir, run_slug, experiment_id)) | |
| return full_output_dir, run_slug, experiment_id | |
| def _sort_unique_by_abs_desc(indices: torch.Tensor, values: torch.Tensor) -> torch.Tensor: | |
| if indices.numel() == 0: | |
| return indices.to(dtype=torch.long) | |
| uniq = torch.unique(indices.to(dtype=torch.long)) | |
| abs_vals = values[uniq].abs() | |
| order = torch.argsort(abs_vals, descending=True) | |
| return uniq[order] | |
| def _cap_indices(indices: torch.Tensor, max_selected: int | None) -> torch.Tensor: | |
| if max_selected is None: | |
| return indices | |
| if max_selected <= 0: | |
| return indices[:0] | |
| return indices[: int(max_selected)] | |
| def get_selected_indices_abs_mass( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| eta: float = 0.95, | |
| max_selected: int | None = None, | |
| ): | |
| abs_cosine = cosine_similarities.abs() | |
| selected_indices = torch.empty(0, dtype=torch.long, device=cosine_similarities.device) | |
| k95 = 0 | |
| if abs_cosine.numel() > 0: | |
| sorted_abs_values, sorted_abs_indices = torch.sort(abs_cosine, descending=True) | |
| total_abs_mass = sorted_abs_values.sum() | |
| if float(total_abs_mass.item()) > 0.0: | |
| cumsum_abs = torch.cumsum(sorted_abs_values, dim=0) | |
| threshold = eta * total_abs_mass | |
| cutoff_candidates = torch.nonzero(cumsum_abs >= threshold, as_tuple=False) | |
| cutoff = int(cutoff_candidates[0].item()) + 1 if cutoff_candidates.numel() > 0 else sorted_abs_values.numel() | |
| else: | |
| cutoff = 0 | |
| selected_indices = sorted_abs_indices[:cutoff] | |
| k95 = cutoff | |
| selected_indices = _cap_indices(selected_indices, max_selected=max_selected) | |
| return selected_indices, { | |
| "method": "abs_mass", | |
| "k95": int(k95), | |
| "eta": float(eta), | |
| "num_selected": int(selected_indices.numel()), | |
| } | |
| def get_selected_indices_zscore( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| tau: float = 2.5, | |
| max_selected: int | None = None, | |
| ): | |
| x = cosine_similarities | |
| if x.numel() == 0: | |
| return x.new_empty(0, dtype=torch.long), {"method": "zscore", "tau": float(tau), "num_selected": 0} | |
| mu = x.mean() | |
| sigma = x.std(unbiased=False) | |
| sigma_val = float(sigma.item()) | |
| if sigma_val <= 0.0: | |
| return x.new_empty(0, dtype=torch.long), { | |
| "method": "zscore", | |
| "tau": float(tau), | |
| "mu": float(mu.item()), | |
| "sigma": sigma_val, | |
| "num_selected": 0, | |
| "num_pos": 0, | |
| } | |
| upper = mu + tau * sigma | |
| lower = mu - tau * sigma | |
| pos_idx = torch.nonzero(x >= upper, as_tuple=False).flatten() | |
| # if zero, select the max positive value | |
| if pos_idx.numel() == 0: | |
| pos_idx = torch.argmax(x) | |
| selected = _sort_unique_by_abs_desc(pos_idx, x) | |
| selected = _cap_indices(selected, max_selected=max_selected) | |
| return selected, { | |
| "method": "zscore", | |
| "tau": float(tau), | |
| "mu": float(mu.item()), | |
| "sigma": sigma_val, | |
| "threshold_upper": float(upper.item()), | |
| "threshold_lower": float(lower.item()), | |
| "num_pos": int(pos_idx.numel()), | |
| "num_selected": int(selected.numel()), | |
| } | |
| def get_selected_indices_fdr( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| q0: float = 0.05, | |
| max_selected: int | None = None, | |
| ): | |
| x = cosine_similarities | |
| if x.numel() == 0: | |
| return x.new_empty(0, dtype=torch.long), {"method": "fdr", "q0": float(q0), "num_selected": 0} | |
| mu = x.mean() | |
| sigma = x.std(unbiased=False).clamp_min(1e-12) | |
| z = (x - mu) / sigma | |
| # Two-sided p-values under normal null. | |
| pvals = torch.erfc(z.abs() / math.sqrt(2.0)).clamp_min(1e-12).clamp_max(1.0) | |
| m = int(pvals.numel()) | |
| sorted_pvals, sorted_idx = torch.sort(pvals, descending=False) | |
| ranks = torch.arange(1, m + 1, device=x.device, dtype=x.dtype) | |
| thresholds = (q0 * ranks) / float(m) | |
| passed = sorted_pvals <= thresholds | |
| if not bool(passed.any()): | |
| selected = x.new_empty(0, dtype=torch.long) | |
| p_cut = None | |
| else: | |
| k = int(torch.nonzero(passed, as_tuple=False)[-1].item()) + 1 | |
| p_cut = float(sorted_pvals[k - 1].item()) | |
| selected = sorted_idx[:k] | |
| selected = _sort_unique_by_abs_desc(selected, x) | |
| selected = _cap_indices(selected, max_selected=max_selected) | |
| return selected, { | |
| "method": "fdr", | |
| "q0": float(q0), | |
| "mu": float(mu.item()), | |
| "sigma": float(sigma.item()), | |
| "p_cut": p_cut, | |
| "num_selected": int(selected.numel()), | |
| "num_tests": m, | |
| } | |
| def get_selected_indices_gmm( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| posterior_threshold: float = 0.8, | |
| max_selected: int | None = None, | |
| max_iter: int = 50, | |
| ): | |
| x = cosine_similarities | |
| if x.numel() == 0: | |
| return x.new_empty(0, dtype=torch.long), {"method": "gmm", "num_selected": 0} | |
| x1 = x.reshape(-1, 1) | |
| n = x1.shape[0] | |
| mu = torch.quantile(x, torch.tensor([0.25, 0.75], device=x.device, dtype=x.dtype)).reshape(2, 1) | |
| var0 = x.var(unbiased=False).clamp_min(1e-6) | |
| var = torch.full((2, 1), var0, device=x.device, dtype=x.dtype) | |
| pi = torch.full((2, 1), 0.5, device=x.device, dtype=x.dtype) | |
| two_pi = torch.tensor(2.0 * math.pi, device=x.device, dtype=x.dtype) | |
| for _ in range(max_iter): | |
| # E-step | |
| norm = (1.0 / torch.sqrt(two_pi * var)) * torch.exp(-0.5 * ((x1 - mu) ** 2) / var) | |
| weighted = pi * norm | |
| denom = weighted.sum(dim=0, keepdim=True).clamp_min(1e-12) | |
| resp = weighted / denom | |
| # M-step | |
| Nk = resp.sum(dim=1, keepdim=True).clamp_min(1e-12) | |
| pi = Nk / float(n) | |
| mu = (resp @ x1) / Nk | |
| centered = x1.unsqueeze(0) - mu.unsqueeze(1) | |
| var = ((resp.unsqueeze(2) * (centered**2)).sum(dim=1) / Nk).clamp_min(1e-6) | |
| # "Relevant" component: larger absolute mean. | |
| relevant_idx = int(torch.argmax(mu.abs()).item()) | |
| posterior_relevant = resp[relevant_idx] | |
| selected = torch.nonzero(posterior_relevant > posterior_threshold, as_tuple=False).flatten() | |
| selected = _sort_unique_by_abs_desc(selected, x) | |
| selected = _cap_indices(selected, max_selected=max_selected) | |
| return selected, { | |
| "method": "gmm", | |
| "posterior_threshold": float(posterior_threshold), | |
| "means": [float(mu[0].item()), float(mu[1].item())], | |
| "stds": [float(torch.sqrt(var[0]).item()), float(torch.sqrt(var[1]).item())], | |
| "weights": [float(pi[0].item()), float(pi[1].item())], | |
| "relevant_component": relevant_idx, | |
| "num_selected": int(selected.numel()), | |
| } | |
| def get_selected_indices_sparse_pursuit( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| target_k: int | None = None, | |
| l1_lambda: float = 0.0, | |
| max_selected: int | None = None, | |
| ): | |
| x = cosine_similarities | |
| if x.numel() == 0: | |
| return x.new_empty(0, dtype=torch.long), {"method": "sparse_pursuit", "num_selected": 0} | |
| if target_k is not None and target_k > 0: | |
| k = min(int(target_k), int(x.numel())) | |
| selected = torch.topk(x.abs(), k=k).indices | |
| mode = "topk_abs" | |
| threshold = float(torch.topk(x.abs(), k=k).values[-1].item()) if k > 0 else 0.0 | |
| else: | |
| lam = float(l1_lambda) | |
| selected = torch.nonzero(x.abs() >= lam, as_tuple=False).flatten() | |
| mode = "l1_threshold" | |
| threshold = lam | |
| selected = _sort_unique_by_abs_desc(selected, x) | |
| selected = _cap_indices(selected, max_selected=max_selected) | |
| return selected, { | |
| "method": "sparse_pursuit", | |
| "mode": mode, | |
| "threshold": threshold, | |
| "target_k": None if target_k is None else int(target_k), | |
| "l1_lambda": float(l1_lambda), | |
| "num_selected": int(selected.numel()), | |
| } | |
| def get_selected_indices_quantile( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| q: float = 0.05, | |
| positive_only: bool = True, | |
| max_selected: int | None = None, | |
| ): | |
| x = cosine_similarities | |
| if x.numel() == 0: | |
| return x.new_empty(0, dtype=torch.long), {"method": "quantile", "q": float(q), "num_selected": 0} | |
| q = float(q) | |
| if not (0.0 < q < 1.0): | |
| raise ValueError(f"quantile selection expects q in (0, 1), got {q}") | |
| if positive_only: | |
| pool = x[x > 0] | |
| if pool.numel() == 0: | |
| return x.new_empty(0, dtype=torch.long), { | |
| "method": "quantile", | |
| "q": q, | |
| "positive_only": True, | |
| "threshold": None, | |
| "num_selected": 0, | |
| } | |
| threshold = torch.quantile(pool, 1.0 - q) | |
| selected = torch.nonzero(x >= threshold, as_tuple=False).flatten() | |
| selected = selected[x[selected] > 0] | |
| else: | |
| threshold = torch.quantile(x.abs(), 1.0 - q) | |
| selected = torch.nonzero(x.abs() >= threshold, as_tuple=False).flatten() | |
| selected = _sort_unique_by_abs_desc(selected, x) | |
| selected = _cap_indices(selected, max_selected=max_selected) | |
| return selected, { | |
| "method": "quantile", | |
| "q": q, | |
| "positive_only": bool(positive_only), | |
| "threshold": float(threshold.item()) if threshold is not None else None, | |
| "num_selected": int(selected.numel()), | |
| } | |
| def get_selected_indices( | |
| cosine_similarities: torch.Tensor, | |
| *, | |
| method: str = "abs_mass", | |
| **kwargs, | |
| ): | |
| method_key = str(method).lower() | |
| if method_key in {"abs_mass", "cosine_mass", "k95"}: | |
| return get_selected_indices_abs_mass(cosine_similarities, **kwargs) | |
| if method_key in {"zscore", "z_score"}: | |
| return get_selected_indices_zscore(cosine_similarities, **kwargs) | |
| if method_key in {"fdr", "bh"}: | |
| return get_selected_indices_fdr(cosine_similarities, **kwargs) | |
| if method_key in {"gmm", "mixture"}: | |
| return get_selected_indices_gmm(cosine_similarities, **kwargs) | |
| if method_key in {"sparse_pursuit", "lasso", "topk"}: | |
| return get_selected_indices_sparse_pursuit(cosine_similarities, **kwargs) | |
| if method_key in {"quantile", "q"}: | |
| return get_selected_indices_quantile(cosine_similarities, **kwargs) | |
| raise ValueError(f"Unknown selection method '{method}'.") | |
| def attention(q, k, v): | |
| return torch.nn.functional.softmax(q @ k.T / math.sqrt(q.shape[-1]), dim=-1) @ v | |
| def _attention_over_neurons( | |
| query: torch.Tensor, | |
| keys: torch.Tensor, | |
| tau: float = 0.07, | |
| normalize: bool = True, | |
| ) -> torch.Tensor: | |
| """ | |
| Attention over neuron prototypes. | |
| Args | |
| ---- | |
| query: [1, d] or [d] | |
| keys: [K, d] | |
| tau: temperature (smaller => peakier) | |
| normalize: if True, use cosine logits via L2-normalization | |
| Returns | |
| ------- | |
| alpha: [K] attention weights over neurons | |
| """ | |
| if query.dim() == 1: | |
| query = query.unsqueeze(0) # [1, d] | |
| assert query.dim() == 2 and keys.dim() == 2, (query.shape, keys.shape) | |
| assert query.shape[-1] == keys.shape[-1], (query.shape, keys.shape) | |
| if normalize: | |
| q = F.normalize(query, dim=-1) # [1, d] | |
| k = F.normalize(keys, dim=-1) # [K, d] | |
| logits = (q @ k.t()).squeeze(0) # [K], cosine logits | |
| else: | |
| # scaled dot-product in raw space | |
| logits = (query @ keys.t()).squeeze(0) / math.sqrt(query.shape[-1]) # [K] | |
| alpha = F.softmax(logits / tau, dim=-1) # [K] | |
| return alpha | |
| def _raw_cosine_for_concept( | |
| *, | |
| model, | |
| concept: str, | |
| device: torch.device, | |
| text_encoder=None, | |
| basis_chunk_size: int = 256, | |
| attention: bool = False, | |
| attention_tau: float = 0.07, | |
| attention_normalize: bool = True, | |
| attention_score_mode: str = "alpha", | |
| ): | |
| """Compute raw cosine-like scores for one concept with optional attention reweighting.""" | |
| encoder = text_encoder if text_encoder is not None else getattr(model, "text_encoder", None) | |
| if encoder is None: | |
| raise ValueError("A text encoder must be provided when model.text_encoder is not available.") | |
| text_embedding = encoder([concept]) # [1, d] | |
| sae_text_out = model.sae_encoder(text_embedding) | |
| if isinstance(sae_text_out, (tuple, list)): | |
| sae_text_activations = sae_text_out[1] if len(sae_text_out) > 1 else sae_text_out[0] | |
| else: | |
| sae_text_activations = sae_text_out | |
| dict_size = int(model.sae_decoder.W_dec.shape[0]) | |
| if basis_chunk_size <= 0: | |
| basis_chunk_size = dict_size | |
| basis = [] | |
| text_vec = text_embedding | |
| for start in range(0, dict_size, basis_chunk_size): | |
| end = min(start + basis_chunk_size, dict_size) | |
| chunk = end - start | |
| eye_chunk = torch.zeros((chunk, dict_size), device=device, dtype=text_vec.dtype) | |
| eye_chunk[torch.arange(chunk, device=device), torch.arange(start, end, device=device)] = 1 | |
| basis_chunk = model.sae_decoder(eye_chunk) | |
| basis.append(basis_chunk) | |
| basis = torch.cat(basis, dim=0) # [K, d] | |
| if attention: | |
| alpha = _attention_over_neurons( | |
| query=text_vec, keys=basis, tau=attention_tau, normalize=attention_normalize | |
| ) | |
| cosine_similarities = F.cosine_similarity(text_vec.expand_as(basis), basis, dim=-1) | |
| if attention_score_mode == "alpha": | |
| cosine_similarities = alpha | |
| elif attention_score_mode == "alpha_cos": | |
| cosine_similarities = alpha * cosine_similarities | |
| elif attention_score_mode == "cos": | |
| pass | |
| else: | |
| raise ValueError( | |
| f"Unknown attention_score_mode={attention_score_mode}. " | |
| "Use one of {'alpha','alpha_cos','cos'}." | |
| ) | |
| else: | |
| cosine_similarities = F.cosine_similarity(text_vec.expand_as(basis), basis, dim=-1) | |
| return cosine_similarities, sae_text_activations | |
| def _cosine_probe_vector_and_stats( | |
| *, | |
| model, | |
| concept: str, | |
| concept_index: int, | |
| device: torch.device, | |
| selection_method: str = "abs_mass", | |
| selection_kwargs: Dict[str, Any] | None = None, | |
| basis_chunk_size: int = 256, | |
| attention: bool = False, | |
| attention_tau: float = 0.07, | |
| attention_normalize: bool = True, | |
| attention_score_mode: str = "alpha", # {"alpha", "alpha_cos", "cos"} | |
| ): | |
| del concept_index # Kept for extractor signature consistency. | |
| cosine_similarities, sae_text_activations = _raw_cosine_for_concept( | |
| model=model, | |
| concept=concept, | |
| device=device, | |
| basis_chunk_size=basis_chunk_size, | |
| attention=attention, | |
| attention_tau=attention_tau, | |
| attention_normalize=attention_normalize, | |
| attention_score_mode=attention_score_mode, | |
| ) | |
| # --- Selection (unchanged) --- | |
| selection_kwargs = dict(selection_kwargs or {}) | |
| selected_indices, selection_stats = get_selected_indices( | |
| cosine_similarities, | |
| method=selection_method, | |
| **selection_kwargs, | |
| ) | |
| mask = torch.zeros_like(cosine_similarities) | |
| if selected_indices.numel() > 0: | |
| mask[selected_indices] = 1 | |
| masked_cosine = cosine_similarities * mask | |
| probs = torch.softmax(cosine_similarities.abs(), dim=0) | |
| entropy = float((-(probs * torch.log(probs.clamp_min(1e-12))).sum()).item()) | |
| stats = dict(selection_stats) | |
| stats["entropy"] = entropy | |
| return { | |
| "raw_cosine_similarities": cosine_similarities, | |
| "masked_cosine": masked_cosine, | |
| "sae_text_activations": sae_text_activations, | |
| "stats": stats, | |
| } | |
| def cosine_with_tfidf( | |
| *, | |
| model, | |
| concepts: list[str], | |
| device: torch.device, | |
| tau: float = 4.0, | |
| basis_chunk_size: int = 256, | |
| attention: bool = False, | |
| attention_tau: float = 0.07, | |
| attention_normalize: bool = True, | |
| attention_score_mode: str = "alpha", | |
| ): | |
| """ | |
| Two-pass cosine extraction with global TF-IDF neuron reweighting. | |
| Steps: | |
| 1) raw cosine per concept | |
| 2) z-score mask on raw cosine | |
| 3) compute global neuron TF-IDF weights from first-pass masks | |
| 4) apply weights to raw cosine | |
| 5) z-score mask again on weighted cosine | |
| """ | |
| if not concepts: | |
| return {} | |
| raw_by_concept: Dict[str, torch.Tensor] = {} | |
| sae_by_concept: Dict[str, torch.Tensor] = {} | |
| first_mask_by_concept: Dict[str, torch.Tensor] = {} | |
| # Pass 1: raw cosine + first z-score mask | |
| for concept in concepts: | |
| raw_cos, sae_text_acts = _raw_cosine_for_concept( | |
| model=model, | |
| concept=concept, | |
| device=device, | |
| basis_chunk_size=basis_chunk_size, | |
| attention=attention, | |
| attention_tau=attention_tau, | |
| attention_normalize=attention_normalize, | |
| attention_score_mode=attention_score_mode, | |
| ) | |
| idx1, stats1 = get_selected_indices_zscore(raw_cos, tau=tau) | |
| mask1 = torch.zeros_like(raw_cos) | |
| if idx1.numel() > 0: | |
| mask1[idx1] = 1 | |
| raw_by_concept[concept] = raw_cos | |
| sae_by_concept[concept] = sae_text_acts | |
| first_mask_by_concept[concept] = mask1 | |
| # Global TF-IDF weights over neurons from first-pass masks. | |
| first_mask_matrix = torch.stack([first_mask_by_concept[c] for c in concepts], dim=0) # [C, K] | |
| raw_matrix = torch.stack([raw_by_concept[c] for c in concepts], dim=0) # [C, K] | |
| n_concepts = first_mask_matrix.shape[0] | |
| doc_freq = first_mask_matrix.sum(dim=0) # [K] | |
| tf = (raw_matrix.abs() * first_mask_matrix).sum(dim=0) / max(n_concepts, 1) # [K] | |
| idf = torch.log((n_concepts + 1.0) / (doc_freq + 1.0)) + 1.0 # [K] | |
| neuron_weights = tf * idf | |
| # Normalize to keep scales comparable across runs. | |
| neuron_weights = neuron_weights / neuron_weights.mean().clamp_min(1e-12) | |
| out = {} | |
| # Pass 2: reweight raw cosine + second z-score mask | |
| for concept in concepts: | |
| raw_cos = raw_by_concept[concept] | |
| weighted_cos = raw_cos * neuron_weights | |
| idx2, stats2 = get_selected_indices_zscore(weighted_cos, tau=tau) | |
| mask2 = torch.zeros_like(weighted_cos) | |
| if idx2.numel() > 0: | |
| mask2[idx2] = 1 | |
| masked_weighted = weighted_cos * mask2 | |
| probs = torch.softmax(weighted_cos.abs(), dim=0) | |
| entropy = float((-(probs * torch.log(probs.clamp_min(1e-12))).sum()).item()) | |
| stats = dict(stats2) | |
| stats["method"] = "cosine_with_tfidf" | |
| stats["entropy"] = entropy | |
| stats["tau"] = float(tau) | |
| stats["tfidf_weight_mean"] = float(neuron_weights.mean().item()) | |
| stats["tfidf_weight_max"] = float(neuron_weights.max().item()) | |
| stats["initial_num_selected"] = int(first_mask_by_concept[concept].sum().item()) | |
| out[concept] = { | |
| # Save weighted cosine as the primary "raw_cosine_similarities" output | |
| # for downstream compatibility with existing plotting scripts. | |
| "raw_cosine_similarities": weighted_cos, | |
| # Keep original pre-TF-IDF cosine values for debugging/ablation. | |
| "original_raw_cosine_similarities": raw_cos, | |
| "weighted_cosine_similarities": weighted_cos, | |
| "masked_cosine": masked_weighted, | |
| "sae_text_activations": sae_by_concept[concept], | |
| "stats": stats, | |
| } | |
| return out | |
| def build_concept_extractor(extractor_cfg: DictConfig): | |
| """Resolve extractor callable from config and apply partial kwargs.""" | |
| if extractor_cfg is None: | |
| raise ValueError("Missing concepts_experiment extractor config.") | |
| target = extractor_cfg.get("target") | |
| if target is None: | |
| # Backward compatibility with older config format. | |
| target = extractor_cfg.get("_extract_concept_distribution") | |
| if not target: | |
| raise ValueError( | |
| "Extractor target missing. Set concepts_experiment.extractor.target " | |
| "(or legacy methods._extract_concept_distribution)." | |
| ) | |
| module_name, fn_name = str(target).rsplit(".", 1) | |
| fn = getattr(importlib.import_module(module_name), fn_name) | |
| if not callable(fn): | |
| raise TypeError(f"Resolved extractor is not callable: {target}") | |
| params_cfg = extractor_cfg.get("params", {}) | |
| params = dict(params_cfg) if params_cfg is not None else {} | |
| return partial(fn, **params) | |
| def _assert_checkpoint_weights_loaded(model) -> None: | |
| report = getattr(model, "_checkpoint_load_report", None) | |
| if report is None: | |
| raise RuntimeError( | |
| "Checkpoint load report missing on model. " | |
| "Ensure model is created via instantiate_model_and_load()." | |
| ) | |
| if int(report.get("matched_keys", 0)) <= 0: | |
| raise RuntimeError("Checkpoint weights do not match model parameters (0 matched keys).") | |
| log.info( | |
| "Checkpoint load verification passed: " | |
| f"matched={report['matched_keys']}/{report['model_keys']}, " | |
| f"missing={report['missing_keys']}, unexpected={report['unexpected_keys']}." | |
| ) | |
| def concept_from_bridge_score( | |
| *, | |
| model, | |
| concept: str, | |
| concept_index: int, | |
| device: torch.device, | |
| bridge_checkpoint_path: str = None, | |
| bridge_checkpoint_key: str = "bridges", | |
| dataloader=None, | |
| ) -> torch.Tensor: | |
| del concept_index # kept for a stable extractor callable signature | |
| def _extract_bridge_from_checkpoint(path: str, key: str): | |
| checkpoint = load_state_dict_any(path, map_location="cpu") | |
| state_dict = checkpoint.get("state_dict", checkpoint) if isinstance(checkpoint, dict) else checkpoint | |
| if not isinstance(state_dict, dict): | |
| return None | |
| bridge_obj = state_dict.get(key) | |
| if bridge_obj is None: | |
| return None | |
| if isinstance(bridge_obj, torch.Tensor): | |
| return bridge_obj | |
| if isinstance(bridge_obj, dict): | |
| bridge_tensors = [] | |
| for _, val in sorted(bridge_obj.items(), key=lambda kv: str(kv[0])): | |
| # New structure: bridges[dataset_name] = {"align": ..., "coact": ..., "B": ...} | |
| if isinstance(val, dict): | |
| b_val = val.get("B") | |
| if isinstance(b_val, torch.Tensor): | |
| bridge_tensors.append(b_val) | |
| # Fallback: dict values are directly bridge tensors. | |
| elif isinstance(val, torch.Tensor): | |
| bridge_tensors.append(val) | |
| if bridge_tensors: | |
| if len(bridge_tensors) == 1: | |
| return bridge_tensors[0] | |
| return torch.stack(bridge_tensors, dim=0).mean(dim=0) | |
| return None | |
| def _compute_bridge_from_dataloader(dataloader_obj) -> torch.Tensor: | |
| za_chunks = [] | |
| zt_chunks = [] | |
| for batch in dataloader_obj: | |
| audio = batch.get("audio") if isinstance(batch, dict) else None | |
| text = batch.get("prompt") if isinstance(batch, dict) else None | |
| if audio is None or text is None: | |
| continue | |
| audio = audio.to(device) | |
| encoded_audio = ( | |
| model.audio_encoder(audio) if not getattr(model, "preextracted_features", False) else audio | |
| ) | |
| encoded_text = model.text_encoder(text) | |
| _, z_audio, _, _ = model(encoded_audio) | |
| _, z_text, _, _ = model(encoded_text) | |
| n = min(z_audio.shape[0], z_text.shape[0]) | |
| if n <= 0: | |
| continue | |
| za_chunks.append(z_audio[:n].detach()) | |
| zt_chunks.append(z_text[:n].detach()) | |
| if not za_chunks or not zt_chunks: | |
| raise ValueError( | |
| "Could not compute bridge from dataloader: no batches contained both audio and prompt." | |
| ) | |
| za = torch.cat(za_chunks, dim=0) | |
| zt = torch.cat(zt_chunks, dim=0) | |
| w_dec = get_dictionary_from_lightningsae(model).to(device) | |
| return BridgeScoreCallback._compute(za, zt, w_dec)["B"] | |
| bridge_B = None | |
| if bridge_checkpoint_path: | |
| bridge_B = _extract_bridge_from_checkpoint(bridge_checkpoint_path, bridge_checkpoint_key) | |
| if bridge_B is None: | |
| if dataloader is None: | |
| raise ValueError( | |
| "bridges not found in checkpoint (or checkpoint not provided). " | |
| "Provide dataloader to compute bridge scores on the fly." | |
| ) | |
| bridge_B = _compute_bridge_from_dataloader(dataloader) | |
| bridge_B = bridge_B.to(device) | |
| text_embedding = model.text_encoder([concept]) | |
| activations = model.sae_encoder(text_embedding) | |
| bridged_activations = bridge_B @ activations | |
| return bridged_activations.detach().cpu() | |
| def run_extract_experiment( | |
| *, | |
| cfg: DictConfig, | |
| model, | |
| device: torch.device, | |
| output_dir: str, | |
| source_run_id: str, | |
| ckpt_path: str, | |
| ) -> Dict[str, Any]: | |
| extractor_cfg = cfg.concepts_experiment.get("extractor") | |
| if extractor_cfg is None: | |
| extractor_cfg = cfg.concepts_experiment.get("methods", {}) | |
| extractor = build_concept_extractor(extractor_cfg) | |
| extractor_target = str(extractor_cfg.get("target", "")) | |
| concepts = read_vocab_lines(str(cfg.concepts.vocab_path)) | |
| _assert_checkpoint_weights_loaded(model) | |
| out_ = {} | |
| if extractor_target.endswith("cosine_with_tfidf"): | |
| # Batch method needs all concepts to compute global neuron TF-IDF weights. | |
| tfidf_results = extractor( | |
| model=model, | |
| concepts=concepts, | |
| device=device, | |
| ) | |
| for concept in concepts: | |
| results = tfidf_results[concept] | |
| masked_cosine = results["masked_cosine"].detach().cpu() | |
| raw_cosine = results["raw_cosine_similarities"].detach().cpu() | |
| weighted_cosine = results.get("weighted_cosine_similarities") | |
| if isinstance(weighted_cosine, torch.Tensor): | |
| weighted_cosine = weighted_cosine.detach().cpu() | |
| sae_acts = results["sae_text_activations"] | |
| if isinstance(sae_acts, torch.Tensor): | |
| sae_acts = sae_acts.detach().cpu() | |
| out_[concept] = { | |
| "masked_cosine": masked_cosine, | |
| "stats": results["stats"], | |
| "raw_cosine_similarities": raw_cosine, | |
| "weighted_cosine_similarities": weighted_cosine, | |
| "sae_text_activations": sae_acts, | |
| } | |
| else: | |
| for idx, concept in enumerate(concepts): | |
| results = extractor( | |
| model=model, | |
| concept=concept, | |
| concept_index=idx, | |
| device=device, | |
| ) | |
| masked_cosine = results["masked_cosine"].detach().cpu() | |
| raw_cosine = results["raw_cosine_similarities"].detach().cpu() | |
| sae_acts = results["sae_text_activations"] | |
| if isinstance(sae_acts, torch.Tensor): | |
| sae_acts = sae_acts.detach().cpu() | |
| out_[concept] = { | |
| "masked_cosine": masked_cosine, | |
| "stats": results["stats"], | |
| "raw_cosine_similarities": raw_cosine, | |
| "sae_text_activations": sae_acts, | |
| } | |
| # Print a compact activation summary for quick inspection. | |
| # "Active" means non-zero entries in the extracted activation tensor. | |
| width = 40 | |
| topk_width = 20 | |
| summary_rows = [] | |
| total_active = 0 | |
| total_values = 0 | |
| global_max = None | |
| for concept, values in out_.items(): | |
| tensor = values['masked_cosine'].detach().cpu() | |
| if tensor.numel() == 0: | |
| active = 0 | |
| size = 0 | |
| frac = 0.0 | |
| max_val = 0.0 | |
| topk_pairs = [] | |
| else: | |
| active = int((tensor != 0).sum().item()) | |
| size = int(tensor.numel()) | |
| frac = active / max(size, 1) | |
| max_val = float(tensor.max().item()) | |
| flat = tensor.flatten() | |
| k = min(5, flat.numel()) | |
| top_vals = torch.topk(flat, k=k).values | |
| denom = max(abs(max_val), 1e-12) | |
| topk_pairs = [] | |
| for v in top_vals: | |
| v_float = float(v.item()) | |
| rel = max(0.0, min(1.0, v_float / denom)) | |
| filled_top = int(round(rel * topk_width)) | |
| top_bar = "#" * filled_top + "-" * (topk_width - filled_top) | |
| topk_pairs.append((v_float, top_bar)) | |
| if global_max is None: | |
| global_max = max_val | |
| else: | |
| global_max = max(global_max, max_val) | |
| total_active += active | |
| total_values += size | |
| filled = int(round(frac * width)) | |
| bar = "#" * filled + "-" * (width - filled) | |
| summary_rows.append((concept, active, size, frac, max_val, bar, topk_pairs)) | |
| log.info("Activation summary per concept (active/total | active_frac | max):") | |
| for concept, active, size, frac, max_val, bar, topk_pairs in summary_rows: | |
| log.info( | |
| f"- {concept:>24s} | {active:>7d}/{size:<7d} | {frac:6.2%} | " | |
| f"max={max_val:>9.4f} | [{bar}]" | |
| ) | |
| if topk_pairs: | |
| topk_str = " | ".join([f"{val:9.4f} [{top_bar}]" for val, top_bar in topk_pairs]) | |
| log.info(f" top5: {topk_str}") | |
| else: | |
| log.info(" top5: n/a") | |
| global_frac = (total_active / total_values) if total_values > 0 else 0.0 | |
| if global_max is None: | |
| global_max = 0.0 | |
| log.info( | |
| "Activation summary total: " | |
| f"{total_active}/{total_values} active ({global_frac:.2%}), global max={global_max:.4f}." | |
| ) | |
| if out_: | |
| for concept, values in out_.items(): | |
| stats = values['stats'] | |
| k95 = int(stats.get("k95", 0)) | |
| entropy = float(stats.get("entropy", 0.0)) | |
| log.info( | |
| f"{concept:24s} | {k95:4d} | {entropy:9.4f}" | |
| ) | |
| # Resolve output directory: <output_dir>/<run_id>/<experiment_id> | |
| output_dir, run_id_slug, experiment_id = _resolve_output_dir(cfg, output_dir, source_run_id) | |
| # Save activations under concepts.pt | |
| activations_path = os.path.join(output_dir, "concepts.pt") | |
| torch.save(out_, activations_path) | |
| log.info(f"Saved concept activation dictionary to {activations_path}") | |
| # Save config | |
| config_path = os.path.join(output_dir, "config.yaml") | |
| with open(config_path, "w") as f: | |
| OmegaConf.save(cfg, f) | |
| log.info(f"Saved concept activation dictionary to {activations_path}") | |
| out_dict = { | |
| "experiment": "concept_isolation", | |
| "concepts_experiment": str(cfg.concepts_experiment.name), | |
| "config_source": source_run_id, | |
| "run_id": run_id_slug, | |
| "experiment_id": experiment_id, | |
| "checkpoint_path": ckpt_path, | |
| "num_concepts": len(concepts), | |
| "concept_activations_path": activations_path, | |
| } | |
| # Save JSON summary output | |
| json_output_path = os.path.join(output_dir, "output.json") | |
| import json | |
| with open(json_output_path, "w") as f: | |
| json.dump(out_dict, f) | |
| return out_dict | |