Pliploop commited on
Commit
bda104d
·
verified ·
1 Parent(s): e230b8f

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +32 -7
  2. app.py +142 -0
  3. core.py +98 -0
  4. requirements.txt +15 -0
  5. steerable_retrieval/__init__.py +8 -0
  6. steerable_retrieval/assets/__init__.py +1 -0
  7. steerable_retrieval/assets/muq_mulan_music4all_prior.npz +3 -0
  8. steerable_retrieval/callbacks/__init__.py +19 -0
  9. steerable_retrieval/callbacks/alignment.py +253 -0
  10. steerable_retrieval/callbacks/energy.py +1543 -0
  11. steerable_retrieval/callbacks/save.py +129 -0
  12. steerable_retrieval/callbacks/utils.py +36 -0
  13. steerable_retrieval/dataloading/__init__.py +1 -0
  14. steerable_retrieval/dataloading/dataloaders.py +319 -0
  15. steerable_retrieval/dataloading/datasets.py +437 -0
  16. steerable_retrieval/dataloading/loading_utils.py +131 -0
  17. steerable_retrieval/experiments/README.md +35 -0
  18. steerable_retrieval/experiments/__init__.py +2 -0
  19. steerable_retrieval/experiments/common.py +343 -0
  20. steerable_retrieval/experiments/concepts/__init__.py +2 -0
  21. steerable_retrieval/experiments/concepts/extract.py +899 -0
  22. steerable_retrieval/experiments/concepts/get_concepts.py +497 -0
  23. steerable_retrieval/experiments/notebook_cache.py +90 -0
  24. steerable_retrieval/experiments/run_concept_isolation.py +71 -0
  25. steerable_retrieval/experiments/run_stability.py +152 -0
  26. steerable_retrieval/extract_dataset.py +295 -0
  27. steerable_retrieval/models/__init__.py +2 -0
  28. steerable_retrieval/models/base.py +219 -0
  29. steerable_retrieval/models/encoders/__init__.py +11 -0
  30. steerable_retrieval/models/encoders/clap.py +158 -0
  31. steerable_retrieval/models/encoders/muq.py +120 -0
  32. steerable_retrieval/models/sae/__init__.py +46 -0
  33. steerable_retrieval/models/sae/decoders.py +83 -0
  34. steerable_retrieval/models/sae/encoders.py +360 -0
  35. steerable_retrieval/models/sae/penalties.py +152 -0
  36. steerable_retrieval/models/sae/sae.py +346 -0
  37. steerable_retrieval/models/utils/__init__.py +19 -0
  38. steerable_retrieval/models/utils/losses.py +398 -0
  39. steerable_retrieval/models/utils/schedulers.py +44 -0
  40. steerable_retrieval/steer/__init__.py +31 -0
  41. steerable_retrieval/steer/inversion.py +389 -0
  42. steerable_retrieval/steer/loading.py +131 -0
  43. steerable_retrieval/steer/prior_fit.py +95 -0
  44. steerable_retrieval/steer/retrieval.py +33 -0
  45. steerable_retrieval/steer/slider.py +245 -0
  46. steerable_retrieval/steer/steering.py +98 -0
  47. steerable_retrieval/train.py +243 -0
  48. steerable_retrieval/utils/__init__.py +6 -0
  49. steerable_retrieval/utils/copy.py +67 -0
  50. steerable_retrieval/utils/ema.py +0 -0
README.md CHANGED
@@ -1,13 +1,38 @@
1
  ---
2
- title: Steerable Retrieval
3
- emoji: 🔥
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Sparse Steerable Retrieval
3
+ emoji: 🎚️
4
+ colorFrom: purple
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 4.44.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: Steer dense music retrieval along a free-form concept
12
  ---
13
 
14
+ # Sparse Steerable Retrieval live demo
15
+
16
+ Open-vocabulary concept control for dense music retrieval. Pick a seed track, type a
17
+ free-form concept, and steer: the query embedding is edited along the concept axis via
18
+ sparse inversion in a trained BatchTopK SAE (MuQ-MuLan / music4all), then nearest
19
+ neighbours are retrieved. No audio is hosted — playback is via Spotify embeds.
20
+
21
+ Runs on **ZeroGPU**: the corpus + MuQ weights are fetched at startup; the model is built
22
+ on the GPU lazily inside the first request and cached.
23
+
24
+ ## Configuration (Space variables / secrets)
25
+
26
+ - `SSR_CHECKPOINT` — HF model repo id, e.g. `Pliploop/steerable-retrieval-sae` (variable)
27
+ - `SSR_L0` — subfolder to load, e.g. `L0-20` (variable)
28
+ - `SSR_CORPUS_REPO` — HF **dataset** repo id with `corpus.npz` + `meta.json` (variable)
29
+ - `SSR_DEVICE` — `cuda` (variable)
30
+ - `HF_TOKEN` — a read token (secret) so the Space can read the private corpus dataset
31
+
32
+ ## Layout
33
+
34
+ - `app.py` — the Gradio ZeroGPU app.
35
+ - `core.py` — engine: load model + corpus, steer + retrieve.
36
+ - `steerable_retrieval/` — vendored package (steering API + SAE + MuQ encoder).
37
+
38
+ This Space is published by `hf/push_all.py` in the project repo.
app.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sparse Steerable Retrieval — live demo (Hugging Face ZeroGPU Space).
2
+
3
+ Pick a seed track, type a free-form concept, and steer: the query embedding is edited
4
+ along the concept axis via sparse inversion in a trained SAE, then nearest neighbours are
5
+ retrieved over a music4all corpus. No audio is hosted — playback is via Spotify embeds.
6
+
7
+ ZeroGPU: the corpus + MuQ weights are fetched at startup (CPU); the model is built on the
8
+ GPU lazily inside the first @spaces.GPU call and cached for subsequent requests.
9
+ """
10
+ import os
11
+ import random
12
+
13
+ import gradio as gr
14
+
15
+ # `spaces` exists only on HF ZeroGPU; shim to a no-op decorator elsewhere.
16
+ try:
17
+ import spaces
18
+ except Exception: # pragma: no cover
19
+ class _Spaces:
20
+ def GPU(self, *a, **k):
21
+ def deco(fn):
22
+ return fn
23
+ return deco if not (a and callable(a[0])) else a[0]
24
+ spaces = _Spaces()
25
+
26
+ from core import DemoEngine, load_corpus
27
+
28
+ K = 8
29
+ EXAMPLE_CONCEPTS = ["piano", "distorted guitar", "female vocals", "aggressive and intense",
30
+ "dreamy and ethereal", "warm and intimate", "hip hop beats"]
31
+
32
+ # --- startup (CPU): pre-cache weights so the first GPU call stays within budget -------- #
33
+ for _repo in ("OpenMuQ/MuQ-MuLan-large", "OpenMuQ/MuQ-large-msd-iter"):
34
+ try:
35
+ from huggingface_hub import snapshot_download
36
+ snapshot_download(_repo)
37
+ except Exception:
38
+ pass
39
+
40
+ CORPUS = load_corpus()
41
+ print(f"corpus loaded: {len(CORPUS[1])} tracks")
42
+
43
+ _ENGINE = None
44
+
45
+
46
+ def ensure_engine():
47
+ global _ENGINE
48
+ if _ENGINE is None:
49
+ _ENGINE = DemoEngine(corpus=CORPUS, dev="cuda")
50
+ return _ENGINE
51
+
52
+
53
+ # --- rendering ------------------------------------------------------------------------ #
54
+ def _spotify(sid, height=80):
55
+ return (
56
+ f'<iframe style="border-radius:12px" src="https://open.spotify.com/embed/track/{sid}?theme=0" '
57
+ f'width="100%" height="{height}" frameBorder="0" loading="lazy" '
58
+ f'allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"></iframe>'
59
+ )
60
+
61
+
62
+ def _meta(track_id):
63
+ m = CORPUS[2].get(track_id, {})
64
+ return m.get("title", ""), m.get("artist", ""), m.get("genre", ""), m.get("spotify", "")
65
+
66
+
67
+ def seed_card(track_id):
68
+ t, a, g, sid = _meta(track_id)
69
+ return f'<div class="ssr-seed"><div class="ssr-title">{t}</div><div class="ssr-sub">{a} · {g}</div>{_spotify(sid, 152)}</div>'
70
+
71
+
72
+ def results_html(results):
73
+ cards = []
74
+ for i, r in enumerate(results, 1):
75
+ cards.append(
76
+ f'<div class="ssr-card"><div class="ssr-row"><span class="ssr-rank">{i}</span>'
77
+ f'<span class="ssr-title">{r["title"]}</span> <span class="ssr-sub">{r["artist"]}</span>'
78
+ f'<span class="ssr-aff">{r["affinity"]:.2f}</span></div>{_spotify(r["spotify"])}</div>'
79
+ )
80
+ return '<div class="ssr-results">' + "".join(cards) + "</div>"
81
+
82
+
83
+ def random_seed():
84
+ tid = random.choice(CORPUS[1])
85
+ return tid, seed_card(tid)
86
+
87
+
88
+ @spaces.GPU(duration=120)
89
+ def steer(seed_track_id, concept, alpha):
90
+ if not seed_track_id:
91
+ return "<em>Pick a seed track first.</em>", ""
92
+ if not concept or not concept.strip():
93
+ return "<em>Type a concept to steer toward.</em>", ""
94
+ eng = ensure_engine()
95
+ concept = concept.strip()
96
+ results = eng.steer_and_retrieve(seed_track_id, concept, alpha=float(alpha), k=K)
97
+ support = len(eng._slider(concept))
98
+ verb = "amplifying" if alpha >= 0 else "suppressing"
99
+ note = f"{verb} <b>{concept}</b> (α={float(alpha):+.1f}) · concept support = {support} SAE features"
100
+ return note, results_html(results)
101
+
102
+
103
+ CSS = """
104
+ .gradio-container {max-width: 1100px !important}
105
+ .ssr-seed .ssr-title, .ssr-card .ssr-title {font-weight:600}
106
+ .ssr-sub {color:#8a8a8a; font-size:13px}
107
+ .ssr-results {display:flex; flex-direction:column; gap:10px}
108
+ .ssr-card {padding:8px 10px; border-radius:14px; background:rgba(123,63,242,0.05)}
109
+ .ssr-row {display:flex; align-items:center; gap:8px; margin-bottom:6px}
110
+ .ssr-rank {display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px;
111
+ border-radius:999px; background:#7B3FF2; color:#fff; font-size:11px; font-weight:600; flex:none}
112
+ .ssr-aff {margin-left:auto; color:#aaa; font-size:12px}
113
+ """
114
+
115
+ with gr.Blocks(title="Sparse Steerable Retrieval", css=CSS, theme=gr.themes.Soft(primary_hue="purple")) as demo:
116
+ gr.Markdown(
117
+ "# 🎚️ Sparse Steerable Retrieval\n"
118
+ "Steer a seed track along a **free-form concept**, then retrieve. A sparse autoencoder "
119
+ "factorises the MuQ-MuLan embedding into concept features; sparse inversion finds the ones "
120
+ "your concept maps to, so they can be amplified (**α > 0**) or suppressed (**α < 0**) before "
121
+ "nearest-neighbour search over ~109k music4all tracks. *No audio is hosted — playback via Spotify.*"
122
+ )
123
+ seed_state = gr.State()
124
+ with gr.Row():
125
+ with gr.Column(scale=1):
126
+ seed_html = gr.HTML()
127
+ random_btn = gr.Button("🎲 Random seed track", variant="secondary")
128
+ concept = gr.Textbox(label="Concept", value="piano", placeholder="e.g. distorted guitar, dreamy, aggressive")
129
+ gr.Examples(EXAMPLE_CONCEPTS, inputs=concept, label="Try a concept")
130
+ alpha = gr.Slider(-3.0, 3.0, value=1.5, step=0.1, label="α (– suppress · + amplify)")
131
+ go = gr.Button("Steer & retrieve", variant="primary")
132
+ note = gr.HTML()
133
+ with gr.Column(scale=1):
134
+ results_out = gr.HTML()
135
+
136
+ random_btn.click(random_seed, outputs=[seed_state, seed_html])
137
+ go.click(steer, inputs=[seed_state, concept, alpha], outputs=[note, results_out])
138
+ demo.load(random_seed, outputs=[seed_state, seed_html])
139
+
140
+
141
+ if __name__ == "__main__":
142
+ demo.launch()
core.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared engine for the demo Space and website example generation.
2
+
3
+ Loads a trained BatchTopK SAE + MuQ text tower and a music4all corpus, then steers a
4
+ seed track toward a free-form concept and retrieves nearest neighbours. Corpus load
5
+ (CPU, cheap) is separated from model load (GPU, MuQ) so the Space can fetch the corpus at
6
+ startup and build the model lazily inside a ZeroGPU call.
7
+
8
+ Configuration (env):
9
+ SSR_CHECKPOINT local .ckpt path, or a HF model repo id (default: local L0=20 run)
10
+ SSR_L0 subfolder in the model repo to load, e.g. "L0-20" (Hub repos only)
11
+ SSR_CORPUS_REPO HF dataset repo id holding corpus.npz + meta.json (overrides local dir)
12
+ SSR_CORPUS_DIR local corpus dir (default: demo/corpus)
13
+ SSR_DEVICE "cuda" / "cpu" (auto-detected)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from functools import lru_cache
20
+
21
+ import numpy as np
22
+ import torch
23
+
24
+ from steerable_retrieval.steer import Slider
25
+ from steerable_retrieval.steer.loading import load_steerable_sae
26
+
27
+ HERE = os.path.dirname(__file__)
28
+ CORPUS_DIR = os.environ.get("SSR_CORPUS_DIR", os.path.join(HERE, "corpus"))
29
+ CORPUS_REPO = os.environ.get("SSR_CORPUS_REPO") # HF dataset repo id, if hosted
30
+ CHECKPOINT = os.environ.get("SSR_CHECKPOINT", os.path.join(HERE, os.pardir, "logs/xps/66a0caf9/checkpoints/last.ckpt"))
31
+ L0_SUBFOLDER = os.environ.get("SSR_L0") # e.g. "L0-20" when CHECKPOINT is a Hub repo
32
+ CONFIG = os.environ.get("SSR_CONFIG")
33
+
34
+
35
+ def device() -> str:
36
+ return os.environ.get("SSR_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
37
+
38
+
39
+ def load_corpus():
40
+ """Return (embeddings[np], track_ids[list], meta[dict]). Fetches from the HF dataset
41
+ repo if SSR_CORPUS_REPO is set, else reads the local corpus dir. CPU only."""
42
+ if CORPUS_REPO:
43
+ from huggingface_hub import hf_hub_download
44
+
45
+ npz_path = hf_hub_download(CORPUS_REPO, filename="corpus.npz", repo_type="dataset")
46
+ meta_path = hf_hub_download(CORPUS_REPO, filename="meta.json", repo_type="dataset")
47
+ else:
48
+ npz_path = os.path.join(CORPUS_DIR, "corpus.npz")
49
+ meta_path = os.path.join(CORPUS_DIR, "meta.json")
50
+ npz = np.load(npz_path, allow_pickle=True)
51
+ with open(meta_path) as fh:
52
+ meta = json.load(fh)
53
+ return npz["embeddings"].astype(np.float32), [str(t) for t in npz["track_ids"].tolist()], meta
54
+
55
+
56
+ class DemoEngine:
57
+ def __init__(self, corpus=None, dev: str | None = None):
58
+ self.device = dev or device()
59
+ embs, ids, meta = corpus if corpus is not None else load_corpus()
60
+ self.embeddings = torch.from_numpy(embs).to(self.device)
61
+ self.track_ids = ids
62
+ self.id_to_idx = {t: i for i, t in enumerate(ids)}
63
+ self.meta = meta
64
+ self.model = load_steerable_sae(CHECKPOINT, device=self.device, subfolder=L0_SUBFOLDER, config_path=CONFIG)
65
+
66
+ @lru_cache(maxsize=64)
67
+ def _slider(self, concept: str) -> Slider:
68
+ return Slider(concept, model=self.model, method="adam")
69
+
70
+ def track_meta(self, track_id: str) -> dict:
71
+ m = dict(self.meta.get(track_id, {}))
72
+ m["track_id"] = track_id
73
+ return m
74
+
75
+ def seed_embedding(self, track_id: str) -> torch.Tensor:
76
+ return self.embeddings[self.id_to_idx[track_id]]
77
+
78
+ def steer_and_retrieve(self, seed_track_id: str, concept: str, alpha: float = 1.0, k: int = 8) -> list[dict]:
79
+ slider = self._slider(concept)
80
+ z = self.seed_embedding(seed_track_id)
81
+ seed_idx = self.id_to_idx[seed_track_id]
82
+ idx, scores = slider.retrieve(z, self.embeddings, alpha=alpha, k=k, exclude_idx=seed_idx)
83
+ out = []
84
+ for i, s in zip(idx.tolist(), scores.tolist()):
85
+ m = self.track_meta(self.track_ids[i])
86
+ m["affinity"] = float(s)
87
+ out.append(m)
88
+ return out
89
+
90
+
91
+ _ENGINE: DemoEngine | None = None
92
+
93
+
94
+ def get_engine(corpus=None) -> DemoEngine:
95
+ global _ENGINE
96
+ if _ENGINE is None:
97
+ _ENGINE = DemoEngine(corpus=corpus)
98
+ return _ENGINE
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face ZeroGPU Space runtime deps. The steerable_retrieval package is vendored
2
+ # into the Space at push time (see hf/push_all.py), so it is importable without a pip install.
3
+ gradio>=4.44
4
+ spaces
5
+ torch
6
+ torchaudio
7
+ numpy
8
+ muq
9
+ transformers
10
+ hydra-core
11
+ omegaconf
12
+ huggingface_hub
13
+ einops
14
+ soundfile
15
+ scikit-learn
steerable_retrieval/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Sparse steerable retrieval.
2
+
3
+ Open-vocabulary concept control for dense music retrieval via sparse inversion in a
4
+ trained sparse autoencoder. See :mod:`steerable_retrieval.steer` for the public API.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
steerable_retrieval/assets/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Packaged data assets (e.g. the default Mahalanobis manifold prior)."""
steerable_retrieval/assets/muq_mulan_music4all_prior.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b37230f32cbe75f380d91a7d1226a2ec59ff87bde0a8c68c9a7c3787813adb11
3
+ size 970844
steerable_retrieval/callbacks/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Callbacks for training and evaluation."""
2
+
3
+ from steerable_retrieval.callbacks.energy import (
4
+ EnergyCallback,
5
+ ModalityScoreCallback,
6
+ SimLoggerCallback,
7
+ )
8
+ from steerable_retrieval.callbacks.save import SaveActivationsCallback
9
+ from steerable_retrieval.callbacks.alignment import CKNNACallback
10
+ from steerable_retrieval.callbacks.utils import BaseCallback
11
+
12
+ __all__ = [
13
+ "EnergyCallback",
14
+ "ModalityScoreCallback",
15
+ "SimLoggerCallback",
16
+ "SaveActivationsCallback",
17
+ "CKNNACallback",
18
+ "BaseCallback",
19
+ ]
steerable_retrieval/callbacks/alignment.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CKNNACallback — Centered Kernel Nearest-Neighbor Alignment between
3
+ SAE activations and input embeddings.
4
+
5
+ Logs CKNNA scores for four cross-space pairs:
6
+ - text activations <-> text embeddings
7
+ - audio activations <-> audio embeddings
8
+ - text activations <-> audio embeddings
9
+ - audio activations <-> text embeddings
10
+
11
+ Reads from pl_module.{val,test}_{activations,embeddings} populated by
12
+ LightningSAE._eval_step.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Dict, List, Optional
19
+
20
+ import torch
21
+ from lightning.pytorch import Trainer
22
+ from lightning.pytorch.core import LightningModule
23
+
24
+ from steerable_retrieval.callbacks.utils import BaseCallback
25
+ from steerable_retrieval.callbacks.energy import _get_dataset_name
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # HSIC helpers (self-contained, no external deps)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def _hsic_unbiased(K: torch.Tensor, L: torch.Tensor) -> torch.Tensor:
35
+ """Unbiased HSIC estimator (Song et al., JMLR 2012, Eq. 5)."""
36
+ m = K.shape[0]
37
+ K_tilde = K.clone().fill_diagonal_(0)
38
+ L_tilde = L.clone().fill_diagonal_(0)
39
+ hsic = (
40
+ (K_tilde * L_tilde.T).sum()
41
+ + K_tilde.sum() * L_tilde.sum() / ((m - 1) * (m - 2))
42
+ - 2 * (K_tilde @ L_tilde).sum() / (m - 2)
43
+ )
44
+ return hsic / (m * (m - 3))
45
+
46
+
47
+ def _hsic_biased(K: torch.Tensor, L: torch.Tensor) -> torch.Tensor:
48
+ """Biased HSIC (original CKA)."""
49
+ H = torch.eye(K.shape[0], dtype=K.dtype, device=K.device) - 1.0 / K.shape[0]
50
+ return torch.trace(K @ H @ L @ H)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # CKNNA metric
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def cknna(
58
+ feats_A: torch.Tensor,
59
+ feats_B: torch.Tensor,
60
+ topk: int,
61
+ distance_agnostic: bool = False,
62
+ unbiased: bool = True,
63
+ ) -> float:
64
+ """
65
+ Centered Kernel Nearest-Neighbor Alignment.
66
+
67
+ Args:
68
+ feats_A: [N, D_A] feature matrix.
69
+ feats_B: [N, D_B] feature matrix.
70
+ topk: number of nearest neighbors (>= 2).
71
+ distance_agnostic: if True, use binary neighbor overlap only.
72
+ unbiased: if True, use unbiased HSIC and exclude self-similarities.
73
+
74
+ Returns:
75
+ CKNNA score (float).
76
+ """
77
+ n = feats_A.shape[0]
78
+ if topk < 2:
79
+ raise ValueError("CKNNA requires topk >= 2")
80
+ topk = min(topk, n - 1) if unbiased else min(topk, n)
81
+
82
+ K = feats_A @ feats_A.T
83
+ L = feats_B @ feats_B.T
84
+ device = feats_A.device
85
+
86
+ hsic_fn = _hsic_unbiased if unbiased else _hsic_biased
87
+
88
+ def similarity(K_: torch.Tensor, L_: torch.Tensor, k: int) -> torch.Tensor:
89
+ if unbiased:
90
+ K_hat = K_.clone().fill_diagonal_(float("-inf"))
91
+ L_hat = L_.clone().fill_diagonal_(float("-inf"))
92
+ else:
93
+ K_hat, L_hat = K_, L_
94
+
95
+ _, topk_K_idx = torch.topk(K_hat, k, dim=1)
96
+ _, topk_L_idx = torch.topk(L_hat, k, dim=1)
97
+
98
+ mask_K = torch.zeros(n, n, device=device).scatter_(1, topk_K_idx, 1.0)
99
+ mask_L = torch.zeros(n, n, device=device).scatter_(1, topk_L_idx, 1.0)
100
+ mask = mask_K * mask_L
101
+
102
+ if distance_agnostic:
103
+ return (mask.sum()).float()
104
+ return hsic_fn(mask * K_, mask * L_)
105
+
106
+ sim_kl = similarity(K, L, topk)
107
+ sim_kk = similarity(K, K, topk)
108
+ sim_ll = similarity(L, L, topk)
109
+
110
+ denom = (torch.sqrt(sim_kk * sim_ll) + 1e-6).item()
111
+ return sim_kl.item() / denom
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Callback
116
+ # ---------------------------------------------------------------------------
117
+
118
+ class CKNNACallback(BaseCallback):
119
+ """
120
+ Computes CKNNA between SAE activations and input embeddings at the end of
121
+ each validation / test epoch.
122
+
123
+ Pairs logged:
124
+ - text_act <-> text_emb
125
+ - audio_act <-> audio_emb
126
+ - text_act <-> audio_emb
127
+ - audio_act <-> text_emb
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ enable_on_validation: bool = True,
133
+ enable_on_test: bool = True,
134
+ every_n_steps: int = None,
135
+ every_n_epochs: int = 1,
136
+ topk: int = 10,
137
+ max_samples: int = 2048,
138
+ distance_agnostic: bool = False,
139
+ unbiased: bool = True,
140
+ prefix: str = "CKNNA",
141
+ ):
142
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
143
+ self.enable_on_validation = enable_on_validation
144
+ self.enable_on_test = enable_on_test
145
+ self.topk = topk
146
+ self.max_samples = max_samples
147
+ self.distance_agnostic = distance_agnostic
148
+ self.unbiased = unbiased
149
+ self.prefix = prefix
150
+
151
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
152
+ if not self.enable_on_validation:
153
+ return
154
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
155
+ return
156
+ self._compute_and_log(trainer, pl_module, mode="val")
157
+
158
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
159
+ if not self.enable_on_test:
160
+ return
161
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
162
+ return
163
+ self._compute_and_log(trainer, pl_module, mode="test")
164
+
165
+ @staticmethod
166
+ @torch.no_grad()
167
+ def _compute(
168
+ Za: Optional[torch.Tensor],
169
+ Zt: Optional[torch.Tensor],
170
+ Ea: Optional[torch.Tensor],
171
+ Et: Optional[torch.Tensor],
172
+ topk: int = 10,
173
+ max_samples: int = 2048,
174
+ distance_agnostic: bool = False,
175
+ unbiased: bool = True,
176
+ ) -> Dict[str, float]:
177
+ """
178
+ Pure computation — no trainer / pl_module / logging.
179
+
180
+ Args:
181
+ Za: audio activations [N_a, C] or None
182
+ Zt: text activations [N_t, C] or None
183
+ Ea: audio embeddings [N_a, D] or None
184
+ Et: text embeddings [N_t, D] or None
185
+ topk: number of nearest neighbors for CKNNA
186
+ max_samples: subsample limit
187
+ distance_agnostic: binary neighbor overlap only
188
+ unbiased: use unbiased HSIC
189
+
190
+ Returns:
191
+ dict mapping pair label -> CKNNA score (float).
192
+ """
193
+ pairs = [
194
+ (Za, Ea, "audio_act__audio_emb"),
195
+ (Zt, Et, "text_act__text_emb"),
196
+ (Zt, Ea, "text_act__audio_emb"),
197
+ (Za, Et, "audio_act__text_emb"),
198
+ ]
199
+
200
+ scores = {}
201
+ for feats_A, feats_B, label in pairs:
202
+ if feats_A is None or feats_B is None:
203
+ continue
204
+
205
+ n = min(feats_A.size(0), feats_B.size(0))
206
+ fA, fB = feats_A[:n], feats_B[:n]
207
+
208
+ if n > max_samples:
209
+ idx = torch.randperm(n, device=fA.device)[:max_samples]
210
+ fA, fB = fA[idx], fB[idx]
211
+
212
+ try:
213
+ scores[label] = cknna(
214
+ fA.float(), fB.float(),
215
+ topk=topk,
216
+ distance_agnostic=distance_agnostic,
217
+ unbiased=unbiased,
218
+ )
219
+ except Exception as e:
220
+ log.warning(f"[CKNNACallback] Failed for {label}: {e}")
221
+
222
+ return scores
223
+
224
+ @torch.no_grad()
225
+ def _compute_and_log(self, trainer: Trainer, pl_module: LightningModule, mode: str):
226
+ """Gather data from module, call _compute, then log."""
227
+ from steerable_retrieval.callbacks.energy import _resolve_modality_tensors
228
+
229
+ all_acts = getattr(pl_module, f"{mode}_activations", {})
230
+ all_embs = getattr(pl_module, f"{mode}_embeddings", {})
231
+ device = pl_module.device
232
+
233
+ for dataloader_idx in all_acts:
234
+ acts_dl = all_acts.get(dataloader_idx, {})
235
+ embs_dl = all_embs.get(dataloader_idx, {})
236
+
237
+ Za = _resolve_modality_tensors(acts_dl, "audio", device, trainer)
238
+ Zt = _resolve_modality_tensors(acts_dl, "text", device, trainer)
239
+ Ea = _resolve_modality_tensors(embs_dl, "audio", device, trainer)
240
+ Et = _resolve_modality_tensors(embs_dl, "text", device, trainer)
241
+
242
+ scores = self._compute(
243
+ Za, Zt, Ea, Et,
244
+ topk=self.topk,
245
+ max_samples=self.max_samples,
246
+ distance_agnostic=self.distance_agnostic,
247
+ unbiased=self.unbiased,
248
+ )
249
+
250
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, mode)
251
+ log_prefix = f"{self.prefix}/{dataset_name}"
252
+ for label, score in scores.items():
253
+ pl_module.log(f"{log_prefix}/{label}", score, prog_bar=False, sync_dist=True)
steerable_retrieval/callbacks/energy.py ADDED
@@ -0,0 +1,1543 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Callbacks implementing Energy / Modality / Bridge / Cross-run Stability metrics
3
+ for LightningSAE (spamr) based on the PCA/USAE paper (2504.11695v4).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ import logging
10
+ import os
11
+ from typing import Dict, List, Optional
12
+
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from lightning.pytorch import Trainer
16
+ from lightning.pytorch.core import LightningModule
17
+
18
+ from steerable_retrieval.callbacks.utils import BaseCallback
19
+
20
+ import plotly.graph_objects as go
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ # Bar plot styling: normalized neuron index (0-1), fixed width, black outline
25
+ BAR_WIDTH = 0.005
26
+ BAR_MARKER_LINE = dict(width=1, color="black")
27
+
28
+
29
+ def _normalized_neuron_x(n: int):
30
+ """Return x-axis values in [0, 1] for n neurons."""
31
+ if n <= 1:
32
+ return [0.5] if n == 1 else []
33
+ return [i / (n - 1) for i in range(n)]
34
+
35
+
36
+ def _get_wandb_logger(trainer: Trainer):
37
+ """Get wandb logger from trainer if available."""
38
+ try:
39
+ from lightning.pytorch.loggers import WandbLogger
40
+ for logger in trainer.loggers:
41
+ if isinstance(logger, WandbLogger):
42
+ return logger
43
+ except ImportError:
44
+ pass
45
+ return None
46
+
47
+
48
+ def _get_dataset_name(trainer: Trainer, dataloader_idx: int, mode: str) -> str:
49
+ """Get dataset name for a dataloader from datamodule.names, or fallback to dataloader_{idx}."""
50
+ dm = getattr(trainer, "datamodule", None)
51
+ if dm is not None and hasattr(dm, "names"):
52
+ names = dm.names
53
+ if isinstance(names, dict) and mode in names:
54
+ name = names[mode].get(dataloader_idx)
55
+ if name is not None:
56
+ return str(name)
57
+ return f"dataloader_{dataloader_idx}"
58
+
59
+
60
+ def _log_plotly_to_wandb(trainer: Trainer, key: str, fig):
61
+ """Log a plotly figure to wandb if available."""
62
+ wandb_logger = _get_wandb_logger(trainer)
63
+ if wandb_logger is None:
64
+ return
65
+ try:
66
+ import wandb
67
+ if wandb.run is not None:
68
+ wandb.log({key: fig}, step=trainer.global_step)
69
+ except ImportError:
70
+ pass
71
+
72
+
73
+ def gather_tensor_if_distributed(tensor: torch.Tensor, trainer: Trainer) -> torch.Tensor:
74
+ """
75
+ Gather tensor from all processes if distributed training is enabled.
76
+ Uses PyTorch Lightning's strategy for distributed operations.
77
+ """
78
+ if trainer.world_size <= 1:
79
+ return tensor
80
+
81
+ try:
82
+ if hasattr(trainer.strategy, "all_gather"):
83
+ gathered = trainer.strategy.all_gather(tensor, sync_grads=False)
84
+ if isinstance(gathered, (list, tuple)):
85
+ gathered = torch.cat(gathered, dim=0)
86
+ elif isinstance(gathered, torch.Tensor):
87
+ if gathered.dim() > tensor.dim():
88
+ gathered = gathered.view(-1, *gathered.shape[2:])
89
+ return gathered
90
+ except (AttributeError, NotImplementedError):
91
+ pass
92
+
93
+ import torch.distributed as dist
94
+ if not dist.is_initialized():
95
+ return tensor
96
+
97
+ device = tensor.device
98
+
99
+ local_size = torch.tensor([tensor.shape[0]], device=device, dtype=torch.long)
100
+ sizes = [torch.zeros_like(local_size) for _ in range(trainer.world_size)]
101
+ dist.all_gather(sizes, local_size)
102
+ sizes = [s.item() for s in sizes]
103
+ max_size = max(sizes)
104
+
105
+ if tensor.shape[0] < max_size:
106
+ padding_shape = list(tensor.shape)
107
+ padding_shape[0] = max_size - tensor.shape[0]
108
+ padding = torch.zeros(padding_shape, device=device, dtype=tensor.dtype)
109
+ tensor = torch.cat([tensor, padding], dim=0)
110
+
111
+ gathered_tensors = [torch.zeros_like(tensor) for _ in range(trainer.world_size)]
112
+ dist.all_gather(gathered_tensors, tensor)
113
+
114
+ gathered_list = []
115
+ for i, gathered_tensor in enumerate(gathered_tensors):
116
+ gathered_list.append(gathered_tensor[:sizes[i]])
117
+
118
+ return torch.cat(gathered_list, dim=0)
119
+
120
+
121
+ def get_dictionary_from_lightningsae(pl_module: LightningModule) -> torch.Tensor:
122
+ """
123
+ Returns dictionary D as [C, d] where each row is a concept atom in embedding space.
124
+
125
+ The SAE decoder has W_dec of shape [dict_size, act_size] = [C, d].
126
+ """
127
+ if not hasattr(pl_module, "sae_decoder"):
128
+ raise AttributeError("Expected pl_module.sae_decoder to exist (LightningSAE).")
129
+
130
+ W = getattr(pl_module.sae_decoder, "W_dec", None)
131
+ if W is None or not torch.is_tensor(W):
132
+ raise AttributeError("Expected pl_module.sae_decoder.W_dec to be a Tensor/Parameter.")
133
+
134
+ return W.contiguous()
135
+
136
+
137
+ def load_state_dict_any(path: str, map_location="cpu") -> Dict[str, torch.Tensor]:
138
+ """
139
+ With torch_s3_connector installed, torch.load("s3://...") should work.
140
+
141
+ Supports:
142
+ - raw state_dict
143
+ - Lightning checkpoint dict containing 'state_dict'
144
+ """
145
+ if str(path).startswith("s3://"):
146
+ from urllib.parse import urlparse
147
+
148
+ import boto3
149
+
150
+ parsed = urlparse(str(path))
151
+ bucket = parsed.netloc
152
+ key = parsed.path.lstrip("/")
153
+ if not bucket or not key:
154
+ raise ValueError(f"Invalid S3 checkpoint URI: {path}")
155
+
156
+ cache_root = os.environ.get("SPAMR_CHECKPOINT_CACHE_DIR", os.path.expanduser("~/.cache/steerable_retrieval/checkpoints"))
157
+ cache_path = os.path.join(cache_root, bucket, key)
158
+ force_refresh = str(os.environ.get("SPAMR_CHECKPOINT_CACHE_REFRESH", "0")).lower() in {"1", "true", "yes"}
159
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
160
+
161
+ if force_refresh or not os.path.exists(cache_path):
162
+ boto3.client("s3").download_file(bucket, key, cache_path)
163
+ log.info(f"Downloaded checkpoint to local cache: {cache_path}")
164
+ else:
165
+ log.info(f"Using cached checkpoint: {cache_path}")
166
+
167
+ # Checkpoints in this project may include OmegaConf objects in metadata.
168
+ # PyTorch>=2.6 defaults to weights_only=True, which rejects those objects.
169
+ obj = torch.load(cache_path, map_location=map_location, weights_only=False)
170
+ else:
171
+ obj = torch.load(path, map_location=map_location, weights_only=False)
172
+ if isinstance(obj, dict) and "state_dict" in obj and isinstance(obj["state_dict"], dict):
173
+ return obj["state_dict"]
174
+ if isinstance(obj, dict):
175
+ return obj
176
+ raise ValueError(f"Unsupported checkpoint object at {path}: {type(obj)}")
177
+
178
+
179
+ def _safe_name(path: str) -> str:
180
+ base = path.rstrip("/").split("/")[-1]
181
+ base = base.replace(".", "_").replace("-", "_").replace("=", "_")
182
+ if len(base) > 48:
183
+ base = base[:48]
184
+ return base
185
+
186
+
187
+ def _cat_or_none(xs: List[torch.Tensor], device: torch.device) -> Optional[torch.Tensor]:
188
+ if not xs:
189
+ return None
190
+ return torch.cat([x.to(device) for x in xs], dim=0)
191
+
192
+
193
+ def _resolve_modality_tensors(
194
+ store: Dict,
195
+ modality: str,
196
+ device: torch.device,
197
+ trainer: Optional["Trainer"] = None,
198
+ ) -> Optional[torch.Tensor]:
199
+ """
200
+ Resolve a modality's data from a per-dataloader storage dict.
201
+ Handles both list-of-tensors and already-concatenated tensor formats.
202
+ Optionally gathers across ranks when trainer is provided.
203
+ """
204
+ data = store.get(modality)
205
+ if data is None:
206
+ return None
207
+ if isinstance(data, list):
208
+ t = _cat_or_none(data, device=device)
209
+ elif isinstance(data, torch.Tensor):
210
+ t = data.to(device)
211
+ else:
212
+ return None
213
+ if t is not None and trainer is not None:
214
+ t = gather_tensor_if_distributed(t, trainer)
215
+ return t
216
+
217
+
218
+ # -----------------------------------------------------------------------------
219
+ # EnergyCallback
220
+ # -----------------------------------------------------------------------------
221
+ class EnergyCallback(BaseCallback):
222
+ """
223
+ Energy_i = E[z_i] computed from logged SAE activations.
224
+
225
+ Logs:
226
+ - {mode}/energy_sum
227
+ - {mode}/energy_mean
228
+ - {mode}/energy_topK_frac (fraction of total energy contained in top-K concepts)
229
+
230
+ Also stores:
231
+ pl_module._last_energy = {'combined': E, 'audio': Ea or None, 'text': Et or None}
232
+ """
233
+
234
+ def __init__(
235
+ self,
236
+ enable_on_validation: bool = True,
237
+ enable_on_test: bool = True,
238
+ every_n_steps: int = None,
239
+ every_n_epochs: int = 1,
240
+ topk: int = 512,
241
+ prefix: str = "energy",
242
+ ):
243
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
244
+ self.enable_on_validation = enable_on_validation
245
+ self.enable_on_test = enable_on_test
246
+ self.topk = topk
247
+ self.prefix = prefix
248
+
249
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
250
+ if not self.enable_on_validation:
251
+ return
252
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
253
+ return
254
+ self._compute_and_log(trainer, pl_module, mode="val")
255
+
256
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
257
+ if not self.enable_on_test:
258
+ return
259
+ self._compute_and_log(trainer, pl_module, mode="test")
260
+
261
+ @staticmethod
262
+ @torch.no_grad()
263
+ def _compute(
264
+ Za: Optional[torch.Tensor],
265
+ Zt: Optional[torch.Tensor],
266
+ topk: int = 512,
267
+ ) -> Optional[Dict]:
268
+ """
269
+ Pure computation — no trainer / pl_module / logging.
270
+
271
+ Args:
272
+ Za: audio activations [N_a, C] or None
273
+ Zt: text activations [N_t, C] or None
274
+ topk: number of top concepts for energy fraction
275
+
276
+ Returns:
277
+ dict with keys: E, Ea, Et, total, frac_topk, k, Za, Zt
278
+ or None if both inputs are None.
279
+ """
280
+ if Za is None and Zt is None:
281
+ return None
282
+
283
+ Ea = Za.mean(dim=0) if Za is not None else None
284
+ Et = Zt.mean(dim=0) if Zt is not None else None
285
+
286
+ if Ea is not None and Et is not None:
287
+ E = 0.5 * (Ea + Et)
288
+ else:
289
+ E = Ea if Ea is not None else Et
290
+
291
+ total = E.sum().clamp_min(1e-12)
292
+ k = min(topk, E.numel())
293
+ frac_topk = (torch.topk(E, k=k).values.sum() / total).item()
294
+
295
+ return dict(E=E, Ea=Ea, Et=Et, total=total, frac_topk=frac_topk, k=k, Za=Za, Zt=Zt)
296
+
297
+ @torch.no_grad()
298
+ def _compute_and_log(self, trainer: Trainer, pl_module: LightningModule, mode: str):
299
+ """Gather data from module, call _compute, then log."""
300
+ all_acts = pl_module.val_activations if mode == "val" else pl_module.test_activations
301
+ device = pl_module.device
302
+
303
+ for dataloader_idx, acts in all_acts.items():
304
+ Za = _resolve_modality_tensors(acts, "audio", device, trainer)
305
+ Zt = _resolve_modality_tensors(acts, "text", device, trainer)
306
+
307
+ result = self._compute(Za, Zt, topk=self.topk)
308
+ if result is None:
309
+ log.warning(f"[EnergyCallback] No activations found for mode={mode}, dataloader_idx={dataloader_idx}.")
310
+ continue
311
+
312
+ E, Ea, Et = result["E"], result["Ea"], result["Et"]
313
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, mode)
314
+ log_prefix = f"Energy/{dataset_name}"
315
+ pl_module.log(f"{log_prefix}/sum", result["total"].item(), sync_dist=True)
316
+ pl_module.log(f"{log_prefix}/mean", E.mean().item(), sync_dist=True)
317
+ pl_module.log(f"{log_prefix}/top{result['k']}_frac", result["frac_topk"], prog_bar=True, sync_dist=True)
318
+
319
+ if dataloader_idx == 0 or len(all_acts) == 1:
320
+ pl_module._last_energy = {
321
+ "combined": E.detach(),
322
+ "audio": Ea.detach() if Ea is not None else None,
323
+ "text": Et.detach() if Et is not None else None,
324
+ }
325
+
326
+ if Za is not None and Zt is not None:
327
+ self._log_energy_distribution_combined(trainer, Za, Zt, mode, dataset_name)
328
+ for activations, name in zip([Za, Zt], ["audio", "text"]):
329
+ if activations is not None:
330
+ self._log_energy_distribution(trainer, activations, name, mode, dataset_name)
331
+
332
+ self._log_cumulative_energy_plot(trainer, pl_module, E, mode, dataset_name)
333
+
334
+ def _log_energy_distribution_combined(
335
+ self,
336
+ trainer: Trainer,
337
+ Za: torch.Tensor,
338
+ Zt: torch.Tensor,
339
+ mode: str,
340
+ dataset_name: str,
341
+ ):
342
+ """Stacked bar: mean activation per neuron from audio (blue) and text (red)."""
343
+ try:
344
+ import plotly.graph_objects as go
345
+ except ImportError:
346
+ log.debug("plotly not available, skipping energy distribution combined plot")
347
+ return
348
+
349
+ mean_a = Za.detach().cpu().mean(dim=0)
350
+ mean_t = Zt.detach().cpu().mean(dim=0)
351
+ n_concepts = mean_a.shape[0]
352
+ x_norm = _normalized_neuron_x(n_concepts)
353
+
354
+ fig = go.Figure()
355
+ fig.add_trace(go.Bar(
356
+ x=x_norm,
357
+ y=mean_a.numpy().tolist(),
358
+ name="audio",
359
+ width=BAR_WIDTH,
360
+ marker=dict(color="blue", line=BAR_MARKER_LINE),
361
+ hovertemplate="Neuron: %{x:.4f}<br>Audio: %{y:.4f}<extra></extra>",
362
+ ))
363
+ fig.add_trace(go.Bar(
364
+ x=x_norm,
365
+ y=mean_t.numpy().tolist(),
366
+ name="text",
367
+ width=BAR_WIDTH,
368
+ marker=dict(color="red", line=BAR_MARKER_LINE),
369
+ hovertemplate="Neuron: %{x:.4f}<br>Text: %{y:.4f}<extra></extra>",
370
+ ))
371
+ fig.update_layout(
372
+ barmode="stack",
373
+ title=f"Energy Distribution — combined ({mode})",
374
+ xaxis_title="Neuron Index (normalized 0–1)",
375
+ yaxis_title="Mean Activation",
376
+ template="plotly_white",
377
+ )
378
+ _log_plotly_to_wandb(trainer, f"Energy Distribution/{dataset_name}/energy_distribution_combined", fig)
379
+
380
+ def _log_energy_distribution(
381
+ self,
382
+ trainer: Trainer,
383
+ activations: torch.Tensor,
384
+ name: str,
385
+ mode: str,
386
+ dataset_name: str,
387
+ ):
388
+ """Plot average activation per neuron (bar height = mean, error bars = std from raw data)."""
389
+ try:
390
+ import plotly.graph_objects as go
391
+ except ImportError:
392
+ log.debug("plotly not available, skipping energy distribution plot")
393
+ return
394
+
395
+ acts = activations.detach().cpu()
396
+ mean_per_neuron = acts.mean(dim=0)
397
+ std_per_neuron = acts.std(dim=0)
398
+ n_concepts = mean_per_neuron.shape[0]
399
+
400
+ logging.info(f"dataset_name: {dataset_name}, modality: {name}, mean_per_neuron: {mean_per_neuron.shape}, std_per_neuron: {std_per_neuron.shape}, n_concepts: {n_concepts}")
401
+
402
+ # check if nans in activations
403
+
404
+ if torch.isnan(mean_per_neuron).any() or torch.isnan(std_per_neuron).any():
405
+ log.warning(f"NaNs found in activations for dataset_name: {dataset_name}, modality: {name}")
406
+
407
+ logging.info(f"neurons with nans: {torch.isnan(mean_per_neuron).nonzero()}, {torch.isnan(std_per_neuron).nonzero()}")
408
+ logging.info(f'mean_per_neuron: {mean_per_neuron}, std_per_neuron: {std_per_neuron}')
409
+
410
+
411
+ # Modality color: blue for audio, red for text
412
+ color = "blue" if name == "audio" else "red"
413
+ x_norm = _normalized_neuron_x(n_concepts)
414
+ fig = go.Figure()
415
+ fig.add_trace(go.Bar(
416
+ x=x_norm,
417
+ y=mean_per_neuron.numpy().tolist(),
418
+ name=name,
419
+ width=BAR_WIDTH,
420
+ marker=dict(color=color, line=BAR_MARKER_LINE),
421
+ hovertemplate="Neuron: %{x:.4f}<br>Mean: %{y:.4f}<extra></extra>",
422
+ ))
423
+ fig.update_layout(
424
+ title=f"Energy Distribution — {name} ({mode})",
425
+ xaxis_title="Neuron Index (normalized 0–1)",
426
+ yaxis_title="Mean Activation",
427
+ template="plotly_white",
428
+ )
429
+ _log_plotly_to_wandb(trainer, f"Energy Distribution/{dataset_name}/energy_distribution_{name}", fig)
430
+
431
+ def _log_cumulative_energy_plot(
432
+ self,
433
+ trainer: Trainer,
434
+ pl_module: LightningModule,
435
+ E: torch.Tensor,
436
+ mode: str,
437
+ dataset_name: str,
438
+ log_scale: bool = True,
439
+ ):
440
+ """
441
+ Log cumulative energy plot: concepts sorted by energy (descending),
442
+ x-axis = concept rank, y-axis = cumulative normalized energy.
443
+ If log_scale is True (default), x-axis is log-scaled.
444
+ """
445
+ try:
446
+ import plotly.graph_objects as go
447
+ except ImportError:
448
+ log.debug("plotly not available, skipping cumulative energy plot")
449
+ return
450
+
451
+ # Sort energies descending
452
+ E_sorted, _ = torch.sort(E, descending=True)
453
+ E_sorted = E_sorted.detach().cpu()
454
+ total_energy = E_sorted.sum().clamp_min(1e-12)
455
+
456
+ # Compute cumulative normalized energy
457
+ cumulative = torch.cumsum(E_sorted, dim=0) / total_energy
458
+ cumulative = cumulative.numpy()
459
+
460
+ n_concepts = len(cumulative)
461
+ x = list(range(1, n_concepts + 1))
462
+
463
+ fig = go.Figure()
464
+ fig.add_trace(go.Scatter(
465
+ x=x,
466
+ y=cumulative.tolist(),
467
+ mode="lines",
468
+ name="Cumulative Energy",
469
+ line=dict(color="blue", width=2),
470
+ ))
471
+ fig.update_layout(
472
+ title=f"Cumulative Normalized Energy ({mode})",
473
+ xaxis_title="Number of Concepts (ranked by energy)",
474
+ yaxis_title="Cumulative Energy Fraction",
475
+ yaxis_range=[0, 1.05],
476
+ template="plotly_white",
477
+ )
478
+
479
+ if log_scale:
480
+ fig.update_xaxes(type="log")
481
+
482
+ plot_key = f"Energy Distribution/{dataset_name}/cumulative_plot"
483
+ _log_plotly_to_wandb(trainer, plot_key, fig)
484
+
485
+
486
+ # -----------------------------------------------------------------------------
487
+ # ModalityScoreCallback
488
+ # -----------------------------------------------------------------------------
489
+ class ModalityScoreCallback(BaseCallback):
490
+ """
491
+ ModalityScore_i = E_audio[z_i] / (E_audio[z_i] + E_text[z_i])
492
+
493
+ Logs summary stats:
494
+ - mean modality score
495
+ - fraction mostly-audio (>0.9)
496
+ - fraction mostly-text (<0.1)
497
+
498
+ Stores:
499
+ pl_module._last_modality_score = score
500
+ """
501
+
502
+ def __init__(
503
+ self,
504
+ enable_on_validation: bool = True,
505
+ enable_on_test: bool = True,
506
+ every_n_steps: int = None,
507
+ every_n_epochs: int = 1,
508
+ eps: float = 1e-5,
509
+ prefix: str = "modality",
510
+ ):
511
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
512
+ self.enable_on_validation = enable_on_validation
513
+ self.enable_on_test = enable_on_test
514
+ self.eps = eps
515
+ self.prefix = prefix
516
+
517
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
518
+ if not self.enable_on_validation:
519
+ return
520
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
521
+ return
522
+ self._compute_and_log(trainer, pl_module, mode="val")
523
+
524
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
525
+ if not self.enable_on_test:
526
+ return
527
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
528
+ return
529
+ self._compute_and_log(trainer, pl_module, mode="test")
530
+
531
+ @staticmethod
532
+ @torch.no_grad()
533
+ def _compute(
534
+ Za: torch.Tensor,
535
+ Zt: torch.Tensor,
536
+ eps: float = 1e-5,
537
+ ) -> Dict:
538
+ """
539
+ Pure computation — no trainer / pl_module / logging.
540
+
541
+ Args:
542
+ Za: audio activations [N_a, C]
543
+ Zt: text activations [N_t, C]
544
+ eps: epsilon for numerical stability
545
+
546
+ Returns:
547
+ dict with keys: score, E, Ea, Et, mean, frac_audio, frac_text
548
+ """
549
+ Ea = Za.mean(dim=0)
550
+ Et = Zt.mean(dim=0)
551
+ score = Ea / (Ea + Et + eps)
552
+ E = 0.5 * (Ea + Et)
553
+ return dict(
554
+ score=score,
555
+ E=E,
556
+ Ea=Ea,
557
+ Et=Et,
558
+ mean=score.mean().item(),
559
+ frac_audio=((score > 0.9).float().mean().item()),
560
+ frac_text=((score < 0.1).float().mean().item()),
561
+ )
562
+
563
+ @torch.no_grad()
564
+ def _compute_and_log(self, trainer: Trainer, pl_module: LightningModule, mode: str):
565
+ """Gather data from module, call _compute, then log."""
566
+ all_acts = pl_module.val_activations if mode == "val" else pl_module.test_activations
567
+ device = pl_module.device
568
+
569
+ for dataloader_idx, acts in all_acts.items():
570
+ Za = _resolve_modality_tensors(acts, "audio", device, trainer)
571
+ Zt = _resolve_modality_tensors(acts, "text", device, trainer)
572
+
573
+ if Za is None or Zt is None:
574
+ log.warning(f"[ModalityScoreCallback] Missing modality activations for mode={mode}, dataloader_idx={dataloader_idx}.")
575
+ continue
576
+
577
+ result = self._compute(Za, Zt, eps=self.eps)
578
+ score, E = result["score"], result["E"]
579
+
580
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, mode)
581
+ log_prefix = f"Modality Score/{dataset_name}"
582
+ pl_module.log(f"{log_prefix}/mean", result["mean"], prog_bar=True, sync_dist=True)
583
+ pl_module.log(f"{log_prefix}/frac_audio_gt0.9", result["frac_audio"], sync_dist=True)
584
+ pl_module.log(f"{log_prefix}/frac_text_lt0.1", result["frac_text"], sync_dist=True)
585
+
586
+ if dataloader_idx == 0 or len(all_acts) == 1:
587
+ pl_module._last_modality_score = score.detach()
588
+
589
+ self._log_modality_histograms(trainer, score, E, mode, dataset_name)
590
+ self._log_modality_score_distribution(trainer, score, mode, dataset_name)
591
+
592
+ @torch.no_grad()
593
+ def _log_modality_score_distribution(
594
+ self,
595
+ trainer: Trainer,
596
+ score: torch.Tensor,
597
+ mode: str,
598
+ dataset_name: str,
599
+ ):
600
+ """Plot modality score per neuron index using plotly."""
601
+ try:
602
+ import plotly.graph_objects as go
603
+ except ImportError:
604
+ log.debug("plotly not available, skipping modality score distribution plot")
605
+ return
606
+
607
+ score = score.detach().cpu()
608
+ n = score.shape[0]
609
+ x_norm = _normalized_neuron_x(n)
610
+ fig = go.Figure()
611
+ fig.add_trace(go.Bar(
612
+ x=x_norm,
613
+ y=score.numpy().tolist(),
614
+ name="Modality Score",
615
+ width=BAR_WIDTH,
616
+ marker=dict(color="steelblue", line=BAR_MARKER_LINE),
617
+ ))
618
+ fig.update_layout(
619
+ title=f"Modality Score Distribution ({mode})",
620
+ xaxis_title="Neuron Index (normalized 0–1)",
621
+ yaxis_title="Modality Score",
622
+ template="plotly_white",
623
+ )
624
+ _log_plotly_to_wandb(trainer, f"Modality Score/{dataset_name}/modality_score_distribution", fig)
625
+
626
+ def _log_modality_histograms(
627
+ self,
628
+ trainer: Trainer,
629
+ score: torch.Tensor,
630
+ E: torch.Tensor,
631
+ mode: str,
632
+ dataset_name: str,
633
+ ):
634
+ """
635
+ Log two histograms of modality scores per concept:
636
+ - Unweighted: count of concepts per bin (normalized to proportion).
637
+ - Energy-weighted: energy in each bin (normalized by total energy).
638
+ """
639
+ try:
640
+ import numpy as np
641
+ import plotly.graph_objects as go
642
+ except ImportError:
643
+ log.debug("numpy/plotly not available, skipping modality histograms")
644
+ return
645
+
646
+ score_np = score.detach().cpu().float().numpy()
647
+ E_np = E.detach().cpu().float().numpy()
648
+ bins = np.linspace(0.0, 1.0, 51)
649
+ bin_centers = 0.5 * (bins[:-1] + bins[1:])
650
+
651
+ # Unweighted: count per bin, normalize to proportion
652
+ counts, _ = np.histogram(score_np, bins=bins)
653
+ total = max(counts.sum(), 1e-12)
654
+ density_unweighted = (counts / total).tolist()
655
+
656
+ # Energy-weighted: sum of energy per bin, normalize by total energy
657
+ counts_w, _ = np.histogram(score_np, bins=bins, weights=E_np)
658
+ total_E = max(E_np.sum(), 1e-12)
659
+ density_weighted = (counts_w / total_E).tolist()
660
+
661
+ layout_base = dict(
662
+ xaxis_title="Modality Score (0=text, 1=audio)",
663
+ yaxis_title="Proportion",
664
+ template="plotly_white",
665
+ bargap=0.1,
666
+ )
667
+
668
+ fig_unweighted = go.Figure()
669
+ fig_unweighted.add_trace(go.Bar(
670
+ x=bin_centers.tolist(),
671
+ y=density_unweighted,
672
+ marker=dict(color="steelblue", line=BAR_MARKER_LINE),
673
+ width=0.005,
674
+ ))
675
+ fig_unweighted.update_layout(
676
+ title=f"Modality Score Distribution — Unweighted ({mode})",
677
+ **layout_base,
678
+ )
679
+ fig_unweighted.update_yaxes(rangemode="tozero")
680
+ _log_plotly_to_wandb(
681
+ trainer,
682
+ f"Modality Score/{dataset_name}/distribution_unweighted",
683
+ fig_unweighted,
684
+ )
685
+
686
+ fig_weighted = go.Figure()
687
+ fig_weighted.add_trace(go.Bar(
688
+ x=bin_centers.tolist(),
689
+ y=density_weighted,
690
+ marker=dict(color="coral", line=BAR_MARKER_LINE),
691
+ width=0.005,
692
+ ))
693
+ fig_weighted.update_layout(
694
+ title=f"Modality Score Distribution — Energy-Weighted ({mode})",
695
+ **layout_base,
696
+ )
697
+ fig_weighted.update_yaxes(rangemode="tozero")
698
+ _log_plotly_to_wandb(
699
+ trainer,
700
+ f"Modality Score/{dataset_name}/distribution_energy_weighted",
701
+ fig_weighted,
702
+ )
703
+
704
+
705
+ # -----------------------------------------------------------------------------
706
+ # BridgeScoreCallback
707
+ # -----------------------------------------------------------------------------
708
+ class BridgeScoreCallback(BaseCallback):
709
+ """
710
+ Bridge matrix:
711
+ B = E[z_a^T z_t] ⊙ (D D^T)
712
+ where:
713
+ - E[z_a^T z_t] is estimated as (Za^T Zt)/N for paired samples
714
+ - D D^T uses cosine similarity between dictionary atoms (rows of D)
715
+
716
+ Logs only matrix summaries (avoid logging full B):
717
+ - abs mean
718
+ - abs max
719
+ - abs topK mean
720
+
721
+ Stores:
722
+ pl_module._last_bridge = B
723
+ """
724
+
725
+ def __init__(
726
+ self,
727
+ enable_on_validation: bool = True,
728
+ enable_on_test: bool = True,
729
+ every_n_steps: int = None,
730
+ every_n_epochs: int = 1,
731
+ prefix: str = "bridge",
732
+ topk_edges: int = 200,
733
+ ):
734
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
735
+ self.enable_on_validation = enable_on_validation
736
+ self.enable_on_test = enable_on_test
737
+ self.prefix = prefix
738
+ self.topk_edges = topk_edges
739
+
740
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
741
+ if not self.enable_on_validation:
742
+ return
743
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
744
+ return
745
+ self._compute_and_log(trainer, pl_module, mode="val")
746
+
747
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
748
+ if not self.enable_on_test:
749
+ return
750
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
751
+ return
752
+ self._compute_and_log(trainer, pl_module, mode="test")
753
+
754
+ @staticmethod
755
+ @torch.no_grad()
756
+ def _compute(
757
+ Za: torch.Tensor,
758
+ Zt: torch.Tensor,
759
+ W_dec: torch.Tensor,
760
+ topk_edges: int = 200,
761
+ ) -> Dict:
762
+ """
763
+ Pure computation — no trainer / pl_module / logging.
764
+
765
+ Args:
766
+ Za: audio activations [N, C]
767
+ Zt: text activations [N, C] (must be same N)
768
+ W_dec: decoder dictionary [C, d]
769
+ topk_edges: number of top bridge edges
770
+
771
+ Returns:
772
+ dict with keys: B, abs_mean, abs_max, abs_topk_mean, k
773
+ """
774
+ if Za.shape[0] != Zt.shape[0]:
775
+ raise ValueError(f"BridgeScore requires paired N. Got Za={Za.shape}, Zt={Zt.shape}")
776
+
777
+ N = Za.shape[0]
778
+ coact = (Za.t() @ Zt) / max(N, 1)
779
+
780
+ Dn = F.normalize(W_dec, dim=-1)
781
+ align = Dn @ Dn.t()
782
+ B = coact * align
783
+
784
+ absB = B.abs()
785
+ k = min(topk_edges, absB.numel())
786
+ top_vals = torch.topk(absB.cpu().flatten(), k=k).values
787
+
788
+ return dict(
789
+ align=align,
790
+ coact=coact,
791
+ B=B,
792
+ abs_mean=absB.mean().item(),
793
+ abs_max=absB.max().item(),
794
+ abs_topk_mean=top_vals.mean().item(),
795
+ k=k,
796
+ )
797
+
798
+ @torch.no_grad()
799
+ def _compute_and_log(self, trainer: Trainer, pl_module: LightningModule, mode: str):
800
+ """Gather data from module, call _compute, then log."""
801
+ all_acts = pl_module.val_activations if mode == "val" else pl_module.test_activations
802
+ device = pl_module.device
803
+
804
+ bridges = {}
805
+
806
+ for dataloader_idx, acts in all_acts.items():
807
+ Za = _resolve_modality_tensors(acts, "audio", device, trainer)
808
+ Zt = _resolve_modality_tensors(acts, "text", device, trainer)
809
+
810
+ if Za is None or Zt is None:
811
+ log.warning(f"[BridgeScoreCallback] Missing modality activations for mode={mode}, dataloader_idx={dataloader_idx}.")
812
+ continue
813
+
814
+ W_dec = get_dictionary_from_lightningsae(pl_module).to(device)
815
+ result = self._compute(Za, Zt, W_dec, topk_edges=self.topk_edges)
816
+
817
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, mode)
818
+ bridges[dataset_name] = {
819
+ "align": result["align"],
820
+ "coact": result["coact"],
821
+ "B": result["B"],
822
+ }
823
+ log_prefix = f"Bridge/{dataset_name}"
824
+ pl_module.log(f"{log_prefix}/abs_mean", result["abs_mean"], prog_bar=True, sync_dist=True)
825
+ pl_module.log(f"{log_prefix}/abs_max", result["abs_max"], sync_dist=True)
826
+ pl_module.log(f"{log_prefix}/abs_top{result['k']}_mean", result["abs_topk_mean"], sync_dist=True)
827
+
828
+ if dataloader_idx == 0 or len(all_acts) == 1:
829
+ pl_module._last_bridge = result["B"].detach()
830
+
831
+ # Preferred structure: per-dataset bridge tensors.
832
+ # Example: pl_module.bridges[dataset_name]["align" | "coact" | "B"]
833
+ pl_module.bridges = bridges
834
+
835
+
836
+ # -----------------------------------------------------------------------------
837
+ # ModalityClassifierCallback
838
+ # -----------------------------------------------------------------------------
839
+ class ModalityClassifierCallback(BaseCallback):
840
+ """
841
+ Per-concept modality classification accuracy.
842
+
843
+ For each concept, trains a logistic regression classifier to distinguish
844
+ audio vs text embeddings based on that concept's activation value.
845
+
846
+ Logs:
847
+ - Mean accuracy across all concepts
848
+ - Histogram of per-concept accuracies (unweighted)
849
+ - Histogram of per-concept accuracies weighted by energy
850
+
851
+ This helps identify which concepts are most discriminative for modality.
852
+ """
853
+
854
+ def __init__(
855
+ self,
856
+ enable_on_validation: bool = True,
857
+ enable_on_test: bool = True,
858
+ every_n_steps: int = None,
859
+ every_n_epochs: int = 1,
860
+ prefix: str = "modality_classifier",
861
+ max_samples: int = 10000,
862
+ test_fraction: float = 0.2,
863
+ ):
864
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
865
+ self.enable_on_validation = enable_on_validation
866
+ self.enable_on_test = enable_on_test
867
+ self.prefix = prefix
868
+ self.max_samples = max_samples
869
+ self.test_fraction = test_fraction
870
+
871
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
872
+ if not self.enable_on_validation:
873
+ return
874
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
875
+ return
876
+ self._compute_and_log(trainer, pl_module, mode="val")
877
+
878
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
879
+ if not self.enable_on_test:
880
+ return
881
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
882
+ return
883
+ self._compute_and_log(trainer, pl_module, mode="test")
884
+
885
+ @staticmethod
886
+ @torch.no_grad()
887
+ def _compute(
888
+ Za: torch.Tensor,
889
+ Zt: torch.Tensor,
890
+ max_samples: int = 10000,
891
+ test_fraction: float = 0.2,
892
+ ) -> Optional[Dict]:
893
+ """
894
+ Pure computation — no trainer / pl_module / logging.
895
+
896
+ Args:
897
+ Za: audio activations [N_a, C]
898
+ Zt: text activations [N_t, C]
899
+ max_samples: subsample limit per modality
900
+ test_fraction: held-out fraction for classifier
901
+
902
+ Returns:
903
+ dict with keys: accuracies (np.ndarray [C]), E_np (np.ndarray [C]),
904
+ mean_acc, energy_weighted_mean_acc
905
+ """
906
+ try:
907
+ import numpy as np
908
+ from sklearn.linear_model import LogisticRegression
909
+ from sklearn.model_selection import train_test_split
910
+ except ImportError:
911
+ log.warning("[ModalityClassifierCallback] sklearn not available, skipping.")
912
+ return None
913
+
914
+ if Za.shape[0] > max_samples:
915
+ Za = Za[torch.randperm(Za.shape[0])[:max_samples]]
916
+ if Zt.shape[0] > max_samples:
917
+ Zt = Zt[torch.randperm(Zt.shape[0])[:max_samples]]
918
+
919
+ Za_np = Za.cpu().float().numpy()
920
+ Zt_np = Zt.cpu().float().numpy()
921
+ n_concepts = Za_np.shape[1]
922
+
923
+ Ea = Za.mean(dim=0)
924
+ Et = Zt.mean(dim=0)
925
+ E = 0.5 * (Ea + Et)
926
+ E_np = E.cpu().float().numpy()
927
+
928
+ y_audio = np.ones(Za_np.shape[0], dtype=np.int32)
929
+ y_text = np.zeros(Zt_np.shape[0], dtype=np.int32)
930
+
931
+ accuracies = []
932
+ for c in range(n_concepts):
933
+ X_c = np.concatenate([Za_np[:, c : c + 1], Zt_np[:, c : c + 1]], axis=0)
934
+ y_c = np.concatenate([y_audio, y_text], axis=0)
935
+ if X_c.std() < 1e-9:
936
+ accuracies.append(0.5)
937
+ continue
938
+ try:
939
+ X_train, X_test, y_train, y_test = train_test_split(
940
+ X_c, y_c, test_size=test_fraction, stratify=y_c, random_state=42
941
+ )
942
+ clf = LogisticRegression(max_iter=200, solver="lbfgs")
943
+ clf.fit(X_train, y_train)
944
+ accuracies.append(clf.score(X_test, y_test))
945
+ except Exception:
946
+ accuracies.append(0.5)
947
+
948
+ accuracies = np.array(accuracies)
949
+ mean_acc = float(accuracies.mean())
950
+ ew_mean_acc = float((accuracies * E_np).sum() / max(E_np.sum(), 1e-12))
951
+
952
+ return dict(
953
+ accuracies=accuracies,
954
+ E_np=E_np,
955
+ mean_acc=mean_acc,
956
+ energy_weighted_mean_acc=ew_mean_acc,
957
+ )
958
+
959
+ @torch.no_grad()
960
+ def _compute_and_log(self, trainer: Trainer, pl_module: LightningModule, mode: str):
961
+ """Gather data from module, call _compute, then log."""
962
+ all_acts = pl_module.val_activations if mode == "val" else pl_module.test_activations
963
+ device = pl_module.device
964
+
965
+ for dataloader_idx, acts in all_acts.items():
966
+ Za = _resolve_modality_tensors(acts, "audio", device, trainer)
967
+ Zt = _resolve_modality_tensors(acts, "text", device, trainer)
968
+
969
+ if Za is None or Zt is None:
970
+ log.warning(f"[ModalityClassifierCallback] Missing modality activations for mode={mode}, dataloader_idx={dataloader_idx}.")
971
+ continue
972
+
973
+ result = self._compute(Za, Zt, max_samples=self.max_samples, test_fraction=self.test_fraction)
974
+ if result is None:
975
+ continue
976
+
977
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, mode)
978
+ log_prefix = f"Modality Classifier/{dataset_name}"
979
+ pl_module.log(f"{log_prefix}/mean_acc", result["mean_acc"], prog_bar=True, sync_dist=True)
980
+ pl_module.log(f"{log_prefix}/energy_weighted_mean_acc", result["energy_weighted_mean_acc"], sync_dist=True)
981
+
982
+ self._log_accuracy_histograms(trainer, result["accuracies"], result["E_np"], mode, dataset_name)
983
+ self._log_per_neuron_accuracy_barplot(trainer, result["accuracies"], result["E_np"], mode, dataset_name)
984
+
985
+ def _log_accuracy_histograms(
986
+ self,
987
+ trainer: Trainer,
988
+ accuracies,
989
+ E,
990
+ mode: str,
991
+ dataset_name: str,
992
+ ):
993
+ """
994
+ Log two histograms of per-concept classification accuracies:
995
+ - Unweighted: proportion of concepts in each accuracy bin.
996
+ - Energy-weighted: proportion of energy in each accuracy bin.
997
+ """
998
+ try:
999
+ import numpy as np
1000
+ import plotly.graph_objects as go
1001
+ except ImportError:
1002
+ log.debug("numpy/plotly not available, skipping accuracy histograms")
1003
+ return
1004
+
1005
+ bins = np.linspace(0.0, 1.0, 51)
1006
+ bin_centers = 0.5 * (bins[:-1] + bins[1:])
1007
+
1008
+ # Unweighted
1009
+ counts, _ = np.histogram(accuracies, bins=bins)
1010
+ total = max(counts.sum(), 1e-12)
1011
+ density_unweighted = (counts / total).tolist()
1012
+
1013
+ # Energy-weighted
1014
+ counts_w, _ = np.histogram(accuracies, bins=bins, weights=E)
1015
+ total_E = max(E.sum(), 1e-12)
1016
+ density_weighted = (counts_w / total_E).tolist()
1017
+
1018
+ layout_base = dict(
1019
+ xaxis_title="Per-Concept Classification Accuracy",
1020
+ yaxis_title="Proportion",
1021
+ template="plotly_white",
1022
+ bargap=0.1,
1023
+ )
1024
+
1025
+ fig_unweighted = go.Figure()
1026
+ fig_unweighted.add_trace(
1027
+ go.Bar(
1028
+ x=bin_centers.tolist(),
1029
+ y=density_unweighted,
1030
+ marker=dict(color="teal", line=BAR_MARKER_LINE),
1031
+ width=BAR_WIDTH,
1032
+ )
1033
+ )
1034
+ fig_unweighted.update_layout(
1035
+ title=f"Per-Concept Modality Classification Accuracy — Unweighted ({mode})",
1036
+ **layout_base,
1037
+ )
1038
+ fig_unweighted.update_yaxes(rangemode="tozero")
1039
+ _log_plotly_to_wandb(
1040
+ trainer,
1041
+ f"Modality Classifier/{dataset_name}/acc_histogram_unweighted",
1042
+ fig_unweighted,
1043
+ )
1044
+
1045
+ fig_weighted = go.Figure()
1046
+ fig_weighted.add_trace(
1047
+ go.Bar(
1048
+ x=bin_centers.tolist(),
1049
+ y=density_weighted,
1050
+ marker=dict(color="orange", line=BAR_MARKER_LINE),
1051
+ width=BAR_WIDTH,
1052
+ )
1053
+ )
1054
+ fig_weighted.update_layout(
1055
+ title=f"Per-Concept Modality Classification Accuracy — Energy-Weighted ({mode})",
1056
+ **layout_base,
1057
+ )
1058
+ fig_weighted.update_yaxes(rangemode="tozero")
1059
+ _log_plotly_to_wandb(
1060
+ trainer,
1061
+ f"Modality Classifier/{dataset_name}/acc_histogram_energy_weighted",
1062
+ fig_weighted,
1063
+ )
1064
+
1065
+ def _log_per_neuron_accuracy_barplot(
1066
+ self,
1067
+ trainer: Trainer,
1068
+ accuracies,
1069
+ E,
1070
+ mode: str,
1071
+ dataset_name: str,
1072
+ ):
1073
+ """
1074
+ Log a barplot showing accuracy for each neuron/concept.
1075
+ X-axis: neuron index, Y-axis: classification accuracy.
1076
+ """
1077
+ try:
1078
+ import numpy as np
1079
+ import plotly.graph_objects as go
1080
+ except ImportError:
1081
+ log.debug("numpy/plotly not available, skipping per-neuron accuracy barplot")
1082
+ return
1083
+
1084
+ n_concepts = len(accuracies)
1085
+ x_norm = _normalized_neuron_x(n_concepts)
1086
+
1087
+ # Sort by accuracy (descending) for better visualization
1088
+ sorted_idx = np.argsort(accuracies)[::-1]
1089
+ sorted_accuracies = accuracies[sorted_idx]
1090
+ sorted_energies = E[sorted_idx]
1091
+
1092
+ # Color by energy (normalized)
1093
+ E_norm = sorted_energies / max(sorted_energies.max(), 1e-12)
1094
+
1095
+ fig = go.Figure()
1096
+ fig.add_trace(
1097
+ go.Bar(
1098
+ x=x_norm,
1099
+ y=sorted_accuracies.tolist(),
1100
+ width=0.001,
1101
+ marker=dict(
1102
+ color=E_norm.tolist(),
1103
+ colorscale="Viridis",
1104
+ colorbar=dict(title="Normalized<br>Energy"),
1105
+ line=BAR_MARKER_LINE,
1106
+ ),
1107
+ hovertemplate="Neuron: %{x:.4f}<br>Accuracy: %{y:.3f}<extra></extra>",
1108
+ )
1109
+ )
1110
+ fig.update_layout(
1111
+ title=f"Per-Neuron Classification Accuracy — sorted by accuracy ({mode})",
1112
+ xaxis_title="Neuron Index (normalized 0–1, sorted by accuracy)",
1113
+ yaxis_title="Classification Accuracy",
1114
+ template="plotly_white",
1115
+ yaxis=dict(range=[0.5, 1]),
1116
+ height=500,
1117
+ )
1118
+ _log_plotly_to_wandb(
1119
+ trainer,
1120
+ f"Modality Classifier/{dataset_name}/per_neuron_accuracy",
1121
+ fig,
1122
+ )
1123
+
1124
+
1125
+ # -----------------------------------------------------------------------------
1126
+ # CrossRunStabilityCallback (TEST ONLY)
1127
+ # -----------------------------------------------------------------------------
1128
+ class CrossRunStabilityCallback(BaseCallback):
1129
+ """
1130
+ TEST ONLY.
1131
+
1132
+ Stability between current dictionary D0 and checkpoint dictionary Dk using Hungarian matching
1133
+ over cosine similarities (max average matched similarity).
1134
+
1135
+ Optionally also computes stability restricted to top-K concepts by test energy.
1136
+
1137
+ Args:
1138
+ checkpoint_paths: list of s3://... or local paths
1139
+ topk_by_energy: if not None, compute additional stability on top-K energetic concepts
1140
+ n_curve_points: number of points to sample for stability vs K plots
1141
+ """
1142
+
1143
+ def __init__(
1144
+ self,
1145
+ checkpoint_paths: List[str],
1146
+ every_n_steps: int = None,
1147
+ every_n_epochs: int = 1,
1148
+ topk_by_energy: Optional[int] = 512,
1149
+ prefix: str = "stability",
1150
+ n_curve_points: int = 50,
1151
+ ):
1152
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
1153
+ self.checkpoint_paths = checkpoint_paths
1154
+ self.topk_by_energy = topk_by_energy
1155
+ self.prefix = prefix
1156
+ self.n_curve_points = n_curve_points
1157
+
1158
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
1159
+ return # explicitly disabled
1160
+
1161
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
1162
+ self._compute(trainer, pl_module)
1163
+
1164
+ @torch.no_grad()
1165
+ def _compute(self, trainer: Trainer, pl_module: LightningModule):
1166
+ if not self.checkpoint_paths:
1167
+ log.warning("[CrossRunStabilityCallback] No checkpoint paths provided.")
1168
+ return
1169
+
1170
+ try:
1171
+ from scipy.optimize import linear_sum_assignment
1172
+ except Exception as e:
1173
+ raise ImportError(
1174
+ "CrossRunStabilityCallback requires scipy (scipy.optimize.linear_sum_assignment). "
1175
+ f"Install scipy or replace Hungarian implementation. Original error: {e}"
1176
+ )
1177
+
1178
+ device = pl_module.device
1179
+
1180
+ # Current dictionary
1181
+ D0 = get_dictionary_from_lightningsae(pl_module).to(device)
1182
+ D0 = F.normalize(D0, dim=-1) # [C, d]
1183
+ n_concepts = D0.shape[0]
1184
+
1185
+ # Get energy for ranking concepts
1186
+ E = None
1187
+ if hasattr(pl_module, "_last_energy") and pl_module._last_energy.get("combined") is not None:
1188
+ E = pl_module._last_energy["combined"].to(device)
1189
+
1190
+ # Otherwise compute from test activations
1191
+ if E is None:
1192
+ all_test_acts = getattr(pl_module, "test_activations", None)
1193
+ if all_test_acts:
1194
+ for dataloader_idx, acts in all_test_acts.items():
1195
+ if acts.get("audio") and acts.get("text"):
1196
+ if isinstance(acts["audio"], list):
1197
+ Za = torch.cat([a.to(device) for a in acts["audio"]], dim=0)
1198
+ else:
1199
+ Za = acts["audio"].to(device)
1200
+
1201
+ if isinstance(acts["text"], list):
1202
+ Zt = torch.cat([a.to(device) for a in acts["text"]], dim=0)
1203
+ else:
1204
+ Zt = acts["text"].to(device)
1205
+
1206
+ Za = gather_tensor_if_distributed(Za, trainer)
1207
+ Zt = gather_tensor_if_distributed(Zt, trainer)
1208
+ E = 0.5 * (Za.mean(0) + Zt.mean(0))
1209
+ break
1210
+
1211
+ # Get sorted indices by energy (descending)
1212
+ if E is not None:
1213
+ sorted_indices = torch.argsort(E, descending=True)
1214
+ E_sorted = E[sorted_indices]
1215
+ total_energy = E_sorted.sum().clamp_min(1e-12)
1216
+ cumulative_energy = torch.cumsum(E_sorted, dim=0) / total_energy
1217
+ else:
1218
+ sorted_indices = torch.arange(n_concepts, device=device)
1219
+ cumulative_energy = None
1220
+
1221
+ # Define K values to sample for the stability curves
1222
+ k_values = self._get_k_values(n_concepts)
1223
+
1224
+ def stability_hungarian(A: torch.Tensor, B: torch.Tensor) -> float:
1225
+ sim = (A @ B.t()).detach().cpu().numpy()
1226
+ cost = -sim
1227
+ r, c = linear_sum_assignment(cost)
1228
+ return float(sim[r, c].mean())
1229
+
1230
+ stabs_all = []
1231
+ stabs_top = []
1232
+ # For curve plots: stability_per_k[checkpoint_idx] = list of stabilities for each k
1233
+ stability_per_k_all = []
1234
+
1235
+ for p in self.checkpoint_paths:
1236
+ m = copy.deepcopy(pl_module).to("cpu")
1237
+ sd = load_state_dict_any(p, map_location="cpu")
1238
+ m.load_state_dict(sd, strict=False)
1239
+
1240
+ Dk = get_dictionary_from_lightningsae(m).to(device)
1241
+ Dk = F.normalize(Dk, dim=-1)
1242
+
1243
+ # Full stability
1244
+ stab = stability_hungarian(D0, Dk)
1245
+ stabs_all.append(stab)
1246
+ pl_module.log(f"test/{self.prefix}_vs_{_safe_name(p)}", stab, sync_dist=True)
1247
+
1248
+ # Top-K stability (single value)
1249
+ if self.topk_by_energy is not None and E is not None:
1250
+ k = min(self.topk_by_energy, n_concepts)
1251
+ top_idx = sorted_indices[:k]
1252
+ stab_top = stability_hungarian(D0[top_idx], Dk[top_idx])
1253
+ stabs_top.append(stab_top)
1254
+ pl_module.log(
1255
+ f"test/{self.prefix}_top{k}_vs_{_safe_name(p)}",
1256
+ stab_top,
1257
+ sync_dist=True,
1258
+ )
1259
+
1260
+ # Compute stability for each K value (for curve plots)
1261
+ stabs_for_k = []
1262
+ for k in k_values:
1263
+ top_idx_k = sorted_indices[:k]
1264
+ stab_k = stability_hungarian(D0[top_idx_k], Dk[top_idx_k])
1265
+ stabs_for_k.append(stab_k)
1266
+ stability_per_k_all.append(stabs_for_k)
1267
+
1268
+ # Log mean stability
1269
+ pl_module.log(
1270
+ f"test/{self.prefix}_mean",
1271
+ float(sum(stabs_all) / max(len(stabs_all), 1)),
1272
+ prog_bar=True,
1273
+ sync_dist=True,
1274
+ )
1275
+ if stabs_top:
1276
+ k = min(self.topk_by_energy, n_concepts)
1277
+ pl_module.log(
1278
+ f"test/{self.prefix}_top{k}_mean",
1279
+ float(sum(stabs_top) / max(len(stabs_top), 1)),
1280
+ prog_bar=True,
1281
+ sync_dist=True,
1282
+ )
1283
+
1284
+ # Compute mean stability across checkpoints for each K
1285
+ if stability_per_k_all:
1286
+ import numpy as np
1287
+ stab_arr = np.array(stability_per_k_all) # [n_checkpoints, n_k_values]
1288
+ mean_stab_per_k = stab_arr.mean(axis=0).tolist()
1289
+
1290
+ # Plot 1: Stability vs Number of Concepts
1291
+ self._log_stability_vs_num_concepts_plot(
1292
+ trainer, k_values, mean_stab_per_k
1293
+ )
1294
+
1295
+ # Plot 2: Stability vs Cumulative Energy (energy-weighted)
1296
+ if cumulative_energy is not None:
1297
+ cum_energy_at_k = [cumulative_energy[k - 1].item() for k in k_values]
1298
+ self._log_stability_vs_cumulative_energy_plot(
1299
+ trainer, cum_energy_at_k, mean_stab_per_k
1300
+ )
1301
+
1302
+ def _get_k_values(self, n_concepts: int) -> List[int]:
1303
+ """Generate K values to sample for stability curves (log-spaced for better resolution at low K)."""
1304
+ import numpy as np
1305
+ # Use log spacing with more points at lower K values
1306
+ n_points = min(self.n_curve_points, n_concepts)
1307
+ k_vals = np.unique(np.geomspace(1, n_concepts, n_points).astype(int))
1308
+ # Ensure we have at least 1 and n_concepts
1309
+ k_vals = sorted(set([1] + k_vals.tolist() + [n_concepts]))
1310
+ return k_vals
1311
+
1312
+ def _log_stability_vs_num_concepts_plot(
1313
+ self,
1314
+ trainer: Trainer,
1315
+ k_values: List[int],
1316
+ mean_stabilities: List[float],
1317
+ ):
1318
+ """Plot stability as a function of number of concepts (ranked by energy)."""
1319
+ try:
1320
+ import plotly.graph_objects as go
1321
+ except ImportError:
1322
+ log.debug("plotly not available, skipping stability vs num concepts plot")
1323
+ return
1324
+
1325
+ fig = go.Figure()
1326
+ fig.add_trace(go.Scatter(
1327
+ x=k_values,
1328
+ y=mean_stabilities,
1329
+ mode="lines+markers",
1330
+ name="Mean Stability",
1331
+ line=dict(color="green", width=2),
1332
+ marker=dict(size=4),
1333
+ ))
1334
+ fig.update_layout(
1335
+ title="Stability vs Number of Concepts (ranked by energy)",
1336
+ xaxis_title="Number of Top Concepts (by energy)",
1337
+ yaxis_title="Stability (Hungarian matching)",
1338
+ yaxis_range=[0, 1.05],
1339
+ xaxis_type="log",
1340
+ template="plotly_white",
1341
+ )
1342
+
1343
+ plot_key = f"test/{self.prefix}_vs_num_concepts_plot"
1344
+ _log_plotly_to_wandb(trainer, plot_key, fig)
1345
+
1346
+ def _log_stability_vs_cumulative_energy_plot(
1347
+ self,
1348
+ trainer: Trainer,
1349
+ cumulative_energy: List[float],
1350
+ mean_stabilities: List[float],
1351
+ ):
1352
+ """Plot stability as a function of cumulative energy fraction (energy-weighted view)."""
1353
+ try:
1354
+ import plotly.graph_objects as go
1355
+ except ImportError:
1356
+ log.debug("plotly not available, skipping stability vs cumulative energy plot")
1357
+ return
1358
+
1359
+ fig = go.Figure()
1360
+ fig.add_trace(go.Scatter(
1361
+ x=cumulative_energy,
1362
+ y=mean_stabilities,
1363
+ mode="lines+markers",
1364
+ name="Mean Stability",
1365
+ line=dict(color="purple", width=2),
1366
+ marker=dict(size=4),
1367
+ ))
1368
+ fig.update_layout(
1369
+ title="Stability vs Cumulative Energy Fraction",
1370
+ xaxis_title="Cumulative Energy Fraction",
1371
+ yaxis_title="Stability (Hungarian matching)",
1372
+ xaxis_range=[0, 1.05],
1373
+ yaxis_range=[0, 1.05],
1374
+ template="plotly_white",
1375
+ )
1376
+
1377
+ plot_key = f"test/{self.prefix}_vs_cumulative_energy_plot"
1378
+ _log_plotly_to_wandb(trainer, plot_key, fig)
1379
+
1380
+
1381
+ # -----------------------------------------------------------------------------
1382
+ # SimLoggerCallback
1383
+ # -----------------------------------------------------------------------------
1384
+ class SimLoggerCallback(BaseCallback):
1385
+ """
1386
+ Logs distributions of pairwise cosine similarities between embeddings:
1387
+ - audio-audio (blue)
1388
+ - text-text (red)
1389
+ - text-audio off-diagonal (purple)
1390
+ - text-audio diagonal / matched pairs (dark purple)
1391
+
1392
+ Reads embeddings from pl_module.val_embeddings / pl_module.test_embeddings
1393
+ (populated by LightningSAE._eval_step).
1394
+ """
1395
+
1396
+ def __init__(
1397
+ self,
1398
+ enable_on_validation: bool = True,
1399
+ enable_on_test: bool = True,
1400
+ every_n_steps: int = None,
1401
+ every_n_epochs: int = 1,
1402
+ max_samples: int = 2048,
1403
+ n_bins: int = 50,
1404
+ prefix: str = "similarity",
1405
+ ):
1406
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
1407
+ self.enable_on_validation = enable_on_validation
1408
+ self.enable_on_test = enable_on_test
1409
+ self.max_samples = max_samples
1410
+ self.n_bins = n_bins
1411
+ self.prefix = prefix
1412
+
1413
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
1414
+ if not self.enable_on_validation:
1415
+ return
1416
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
1417
+ return
1418
+ self._compute_and_log(trainer, pl_module, mode="val")
1419
+
1420
+ def on_test_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
1421
+ if not self.enable_on_test:
1422
+ return
1423
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
1424
+ return
1425
+ self._compute_and_log(trainer, pl_module, mode="test")
1426
+
1427
+ @staticmethod
1428
+ @torch.no_grad()
1429
+ def _compute(
1430
+ audio_emb: Optional[torch.Tensor],
1431
+ text_emb: Optional[torch.Tensor],
1432
+ ) -> Dict[str, torch.Tensor]:
1433
+ """
1434
+ Pure computation — no trainer / pl_module / logging.
1435
+
1436
+ Args:
1437
+ audio_emb: [N_a, D] or None
1438
+ text_emb: [N_t, D] or None
1439
+
1440
+ Returns:
1441
+ dict mapping label -> 1D tensor of cosine similarities.
1442
+ Labels: 'audio-audio', 'text-text', 'text-audio (matched)',
1443
+ 'text-audio (off-diag)'.
1444
+ """
1445
+ def cosine_sim_matrix(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
1446
+ return F.normalize(a, dim=-1) @ F.normalize(b, dim=-1).T
1447
+
1448
+ sims = {}
1449
+
1450
+ if audio_emb is not None and audio_emb.size(0) > 1:
1451
+ sim_aa = cosine_sim_matrix(audio_emb, audio_emb)
1452
+ triu_idx = torch.triu_indices(sim_aa.size(0), sim_aa.size(1), offset=1)
1453
+ sims["audio-audio"] = sim_aa[triu_idx[0], triu_idx[1]]
1454
+
1455
+ if text_emb is not None and text_emb.size(0) > 1:
1456
+ sim_tt = cosine_sim_matrix(text_emb, text_emb)
1457
+ triu_idx = torch.triu_indices(sim_tt.size(0), sim_tt.size(1), offset=1)
1458
+ sims["text-text"] = sim_tt[triu_idx[0], triu_idx[1]]
1459
+
1460
+ if audio_emb is not None and text_emb is not None:
1461
+ sim_ta = cosine_sim_matrix(text_emb, audio_emb)
1462
+ n_diag = min(sim_ta.shape[0], sim_ta.shape[1])
1463
+ sims["text-audio (matched)"] = torch.diag(sim_ta[:n_diag, :n_diag])
1464
+ mask = torch.ones_like(sim_ta, dtype=torch.bool)
1465
+ for i in range(n_diag):
1466
+ mask[i, i] = False
1467
+ sims["text-audio (off-diag)"] = sim_ta[mask]
1468
+
1469
+ return sims
1470
+
1471
+ @torch.no_grad()
1472
+ def _compute_and_log(self, trainer: Trainer, pl_module: LightningModule, mode: str):
1473
+ """Gather data from module, call _compute, then log."""
1474
+ all_embs = getattr(pl_module, f"{mode}_embeddings", {})
1475
+ device = pl_module.device
1476
+
1477
+ for dataloader_idx, embs in all_embs.items():
1478
+ audio_emb = _resolve_modality_tensors(embs, "audio", device, trainer)
1479
+ text_emb = _resolve_modality_tensors(embs, "text", device, trainer)
1480
+
1481
+ if audio_emb is None and text_emb is None:
1482
+ log.warning(f"[SimLoggerCallback] No embeddings for mode={mode}, dataloader_idx={dataloader_idx}.")
1483
+ continue
1484
+
1485
+ # Move to CPU and subsample
1486
+ if audio_emb is not None:
1487
+ audio_emb = audio_emb.cpu()
1488
+ if audio_emb.size(0) > self.max_samples:
1489
+ audio_emb = audio_emb[torch.randperm(audio_emb.size(0))[: self.max_samples]]
1490
+ if text_emb is not None:
1491
+ text_emb = text_emb.cpu()
1492
+ if text_emb.size(0) > self.max_samples:
1493
+ text_emb = text_emb[torch.randperm(text_emb.size(0))[: self.max_samples]]
1494
+
1495
+ sims = self._compute(audio_emb, text_emb)
1496
+ if not sims:
1497
+ continue
1498
+
1499
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, mode)
1500
+ self._log_similarity_figure(trainer, sims, mode, dataset_name)
1501
+
1502
+ def _log_similarity_figure(
1503
+ self,
1504
+ trainer: Trainer,
1505
+ sims: Dict[str, torch.Tensor],
1506
+ mode: str,
1507
+ dataset_name: str,
1508
+ ):
1509
+ """Build and log plotly figure from pre-computed similarity tensors."""
1510
+ try:
1511
+ import plotly.graph_objects as go
1512
+ except ImportError:
1513
+ log.debug("plotly not available, skipping similarity distribution plot")
1514
+ return
1515
+
1516
+ colors = {
1517
+ "audio-audio": "rgba(0, 0, 255, 0.6)",
1518
+ "text-text": "rgba(255, 0, 0, 0.6)",
1519
+ "text-audio (off-diag)": "rgba(128, 0, 128, 0.5)",
1520
+ "text-audio (matched)": "rgba(75, 0, 130, 0.8)",
1521
+ }
1522
+
1523
+ fig = go.Figure()
1524
+ for label, vals in sims.items():
1525
+ fig.add_trace(go.Histogram(
1526
+ x=vals.cpu().numpy().tolist(),
1527
+ name=label,
1528
+ opacity=0.7,
1529
+ marker_color=colors.get(label, "gray"),
1530
+ nbinsx=self.n_bins,
1531
+ histnorm="probability density",
1532
+ ))
1533
+
1534
+ fig.update_layout(
1535
+ title=f"Embedding Cosine Similarity Distributions ({mode})",
1536
+ xaxis_title="Cosine Similarity",
1537
+ yaxis_title="Density",
1538
+ barmode="overlay",
1539
+ template="plotly_white",
1540
+ legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
1541
+ )
1542
+
1543
+ _log_plotly_to_wandb(trainer, f"Similarity/{dataset_name}/distributions", fig)
steerable_retrieval/callbacks/save.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SaveActivationsCallback — saves validation embeddings, preactivations,
3
+ activations, and reconstructions to per-tensor .pt files at the end of each
4
+ validation epoch.
5
+
6
+ Reads embeddings and activations stored on the LightningModule by _eval_step,
7
+ then re-runs the SAE forward pass to capture preactivations and reconstructions
8
+ (which are not stored on the module to save memory during normal training).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from typing import Dict, Optional
16
+
17
+ import torch
18
+ from lightning.pytorch import Trainer
19
+ from lightning.pytorch.core import LightningModule
20
+
21
+ from steerable_retrieval.callbacks.utils import BaseCallback
22
+ from steerable_retrieval.callbacks.energy import _get_dataset_name, gather_tensor_if_distributed, _cat_or_none
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ class SaveActivationsCallback(BaseCallback):
28
+ """
29
+ At the end of every validation epoch, saves separate .pt files containing
30
+ embeddings, preactivations, activations, and reconstructions for every
31
+ validation dataloader / modality.
32
+
33
+ File structure::
34
+
35
+ <save_dir>/activations/<dataset_name>/<modality>/<tensor_name>/tensors.pt
36
+
37
+ Example::
38
+
39
+ activations/dataset_name/audio/embeddings/tensors.pt
40
+ activations/dataset_name/audio/preactivations/tensors.pt
41
+ activations/dataset_name/audio/activations/tensors.pt
42
+ activations/dataset_name/audio/reconstructions/tensors.pt
43
+ """
44
+
45
+ ROOT_DIRNAME = "activations"
46
+
47
+ def __init__(
48
+ self,
49
+ save_dir: str,
50
+ every_n_steps: int = None,
51
+ every_n_epochs: int = 1,
52
+ ):
53
+ super().__init__(every_n_steps=every_n_steps, every_n_epochs=every_n_epochs)
54
+ self.save_dir = save_dir
55
+
56
+ def on_validation_epoch_end(self, trainer: Trainer, pl_module: LightningModule):
57
+ if not (self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)):
58
+ return
59
+ self._save(trainer, pl_module)
60
+
61
+ @torch.no_grad()
62
+ def _save(self, trainer: Trainer, pl_module: LightningModule):
63
+ """Build tensors from module-stored embeddings/activations and save to disk."""
64
+ device = pl_module.device
65
+ all_acts = getattr(pl_module, "val_activations", {})
66
+ all_embs = getattr(pl_module, "val_embeddings", {})
67
+
68
+ for dataloader_idx in all_acts:
69
+ dataset_name = _get_dataset_name(trainer, dataloader_idx, "val")
70
+
71
+ acts_dl = all_acts.get(dataloader_idx, {})
72
+ embs_dl = all_embs.get(dataloader_idx, {})
73
+
74
+ for modality in ("audio", "text"):
75
+ act_chunks = acts_dl.get(modality, [])
76
+ emb_chunks = embs_dl.get(modality, [])
77
+ if not act_chunks:
78
+ continue
79
+
80
+ # Concatenate and gather
81
+ activations = _cat_or_none(act_chunks, device=device)
82
+ embeddings = _cat_or_none(emb_chunks, device=device) if emb_chunks else None
83
+
84
+ activations = gather_tensor_if_distributed(activations, trainer)
85
+ if embeddings is not None:
86
+ embeddings = gather_tensor_if_distributed(embeddings, trainer)
87
+
88
+ # Re-run SAE forward on embeddings to get preactivations & reconstructions
89
+ preactivations = None
90
+ reconstructions = None
91
+ if embeddings is not None:
92
+ # Process in chunks to avoid OOM
93
+ chunk_size = 512
94
+ pre_chunks, rec_chunks = [], []
95
+ for i in range(0, embeddings.size(0), chunk_size):
96
+ emb_chunk = embeddings[i : i + chunk_size].to(device)
97
+ _, z, xhat, pre = pl_module(emb_chunk)
98
+ pre_chunks.append(pre.cpu() if pre is not None else torch.zeros_like(z).cpu())
99
+ rec_chunks.append(xhat.cpu())
100
+ preactivations = torch.cat(pre_chunks, dim=0)
101
+ reconstructions = torch.cat(rec_chunks, dim=0)
102
+
103
+ tensors = {
104
+ "embeddings": embeddings.cpu() if embeddings is not None else None,
105
+ "preactivations": preactivations,
106
+ "activations": activations.cpu(),
107
+ "reconstructions": reconstructions,
108
+ }
109
+ if trainer.is_global_zero:
110
+ self._save_tensors(dataset_name=dataset_name, modality=modality, tensors=tensors)
111
+
112
+ # Only rank-0 writes to disk
113
+ if trainer.is_global_zero:
114
+ log.info(f"[SaveActivationsCallback] Saved validation activations to {self.save_dir}")
115
+
116
+ def _save_tensors(
117
+ self,
118
+ dataset_name: str,
119
+ modality: str,
120
+ tensors: Dict[str, Optional[torch.Tensor]],
121
+ ) -> None:
122
+ base_dir = os.path.join(self.save_dir, self.ROOT_DIRNAME, dataset_name, modality)
123
+ for tensor_name, tensor_value in tensors.items():
124
+ if tensor_value is None:
125
+ continue
126
+ tensor_dir = os.path.join(base_dir, tensor_name)
127
+ os.makedirs(tensor_dir, exist_ok=True)
128
+ tensor_path = os.path.join(tensor_dir, "tensors.pt")
129
+ torch.save(tensor_value, tensor_path)
steerable_retrieval/callbacks/utils.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from lightning.pytorch.callbacks import Callback
2
+ from lightning.pytorch import Trainer
3
+ from lightning.pytorch.core import LightningModule
4
+
5
+ import torch
6
+
7
+
8
+ class BaseCallback(Callback):
9
+
10
+ def __init__(self, every_n_steps = 1, every_n_epochs = 1, **kwargs):
11
+ super().__init__(**kwargs)
12
+ self.every_n_steps = every_n_steps
13
+ self.every_n_epochs = every_n_epochs
14
+
15
+ def _check_step(self, trainer: Trainer, pl_module: LightningModule) -> bool:
16
+ if self.every_n_steps is not None:
17
+ return trainer.global_step % self.every_n_steps == 0
18
+ else:
19
+ return False
20
+
21
+ def _check_epoch(self, trainer: Trainer, pl_module: LightningModule) -> bool:
22
+ if self.every_n_epochs is not None:
23
+ return trainer.current_epoch % self.every_n_epochs == 0
24
+ else:
25
+ return False
26
+
27
+ def _should_run(self, trainer: Trainer, pl_module: LightningModule) -> bool:
28
+ return self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)
29
+
30
+ def _should_run_on_validation(self, trainer: Trainer, pl_module: LightningModule) -> bool:
31
+ return self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)
32
+
33
+ def _should_run_on_test(self, trainer: Trainer, pl_module: LightningModule) -> bool:
34
+ return self._check_step(trainer, pl_module) or self._check_epoch(trainer, pl_module)
35
+
36
+
steerable_retrieval/dataloading/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
steerable_retrieval/dataloading/dataloaders.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import random
4
+ from hashlib import sha256
5
+
6
+ import pandas as pd
7
+ from lightning import LightningDataModule
8
+ from sklearn.model_selection import train_test_split
9
+ from torch.utils.data import DataLoader
10
+
11
+ from .datasets import TextAudioDataset
12
+ from steerable_retrieval.utils.instantiators import instantiate
13
+
14
+
15
+ def instantiate_datasets(datasets_cfg):
16
+ datasets = []
17
+ dataset_names = []
18
+ for dataset_name in datasets_cfg:
19
+ dataset = instantiate(datasets_cfg[dataset_name])
20
+ datasets.append(dataset)
21
+ dataset_names.append(dataset_name)
22
+ return datasets, dataset_names
23
+
24
+
25
+ def get_song_describer_annotations(data_path = None, csv_path = None, val_split = 0.1):
26
+
27
+ df = pd.read_csv(csv_path)
28
+
29
+
30
+ df = df[['path','caption','is_valid_subset','caption_id']].rename(columns = {'path':'file_path'})
31
+ df['file_path'] = os.path.join(data_path) + '/' + df['file_path']
32
+ #replace .mp3 with .2min.mp3
33
+ df['file_path'] = df['file_path'].apply(lambda x: x.replace('.mp3','.2min.mp3'))
34
+
35
+ records = df.to_dict(orient = 'records')
36
+
37
+ for record in records:
38
+ record['caption'] = {sha256(record['caption'].encode('utf-8')).hexdigest(): record['caption']}
39
+ if val_split == 0.0:
40
+ print('No validation split')
41
+ for record in records:
42
+ record['split'] = 'train'
43
+ return records
44
+
45
+ train_indices, val_indices = train_test_split(range(len(records)), test_size = val_split, random_state = 42)
46
+
47
+ for idx in train_indices:
48
+ records[idx]['split'] = 'train'
49
+
50
+ for idx in val_indices:
51
+ records[idx]['split'] = 'val'
52
+
53
+ return records
54
+
55
+
56
+
57
+ def get_musiccaps_annotations(data_path = None, csv_path = None, val_split = 0.1, test_split = 0.1):
58
+
59
+ df = pd.read_csv(csv_path)
60
+ df['file_path'] = data_path + '/' + df['ytid'] + '.wav'
61
+
62
+ records = df.to_dict(orient = 'records')
63
+
64
+ for record in records:
65
+ record['caption'] = {sha256(record['caption'].encode('utf-8')).hexdigest(): record['caption']}
66
+
67
+ if val_split == 0.0:
68
+ print('No validation split')
69
+ for record in records:
70
+ record['split'] = 'train'
71
+ return records
72
+
73
+ # split into train, val, test
74
+ train_indices, test_indices = train_test_split(range(len(records)), test_size = test_split + val_split, random_state = 42)
75
+ val_indices, test_indices = train_test_split(test_indices, test_size = test_split/(test_split + val_split), random_state = 42)
76
+
77
+
78
+
79
+ for idx in train_indices:
80
+ records[idx]['split'] = 'train'
81
+
82
+ for idx in val_indices:
83
+ records[idx]['split'] = 'val'
84
+
85
+ for idx in test_indices:
86
+ records[idx]['split'] = 'test'
87
+
88
+ return records
89
+
90
+ def get_musiccaps_truncated_annotations(data_path = None, csv_path = None, val_split = 0.1, test_split = 0.1):
91
+ df = pd.read_csv(csv_path)
92
+ df['file_path'] = data_path + '/' + df['ytid'] + '.wav'
93
+
94
+ import random
95
+
96
+ records = df.to_dict(orient = 'records')
97
+
98
+ print('Truncating captions')
99
+
100
+ for record in records:
101
+ # select a random sentence from the caption
102
+ sentences = record['caption'].split('.')
103
+ random_sentence = random.choice(sentences)
104
+ record['caption'] = {sha256(random_sentence.encode('utf-8')).hexdigest(): random_sentence}
105
+
106
+ if val_split == 0.0:
107
+ print('No validation split')
108
+ for record in records:
109
+ record['split'] = 'train'
110
+ return records
111
+
112
+ # split into train, val, test
113
+ train_indices, test_indices = train_test_split(range(len(records)), test_size = test_split + val_split, random_state = 42)
114
+ val_indices, test_indices = train_test_split(test_indices, test_size = test_split/(test_split + val_split), random_state = 42)
115
+
116
+ for idx in train_indices:
117
+ records[idx]['split'] = 'train'
118
+
119
+ for idx in val_indices:
120
+ records[idx]['split'] = 'val'
121
+
122
+ for idx in test_indices:
123
+ records[idx]['split'] = 'test'
124
+
125
+ return records
126
+
127
+
128
+ def get_maxcaps_annotations(data_path = None, csv_path = None):
129
+ """Read JSONL file line-by-line to avoid memory issues with large files."""
130
+
131
+ df = pd.read_csv(csv_path)
132
+ df['file_path'] = data_path + '/' + df['file_path']
133
+ records = df.to_dict(orient = 'records')
134
+ for record in records:
135
+ record['caption'] = {sha256(record['caption'].encode('utf-8')).hexdigest(): record['caption']}
136
+ return records
137
+
138
+ def get_yt8m_annotations(data_path = None, csv_path = None):
139
+ df = pd.read_csv(csv_path)
140
+ df['file_path'] = data_path + '/' + df['file_path']
141
+ records = df.to_dict(orient = 'records')
142
+ for record in records:
143
+ record['caption'] = {sha256(record['caption'].encode('utf-8')).hexdigest(): record['caption']}
144
+ return records
145
+
146
+ def get_music4all_annotations(manifest_csv=None, audio_status_ok=True):
147
+ """Annotations for music4all from the MuQ-MuLan embedding manifest.
148
+
149
+ ``file_path`` is set directly to the pre-extracted ``.npy`` audio-embedding path so
150
+ the preextracted-feature loader reads it as-is. Splits (train/val/test) and captions
151
+ are taken from the manifest. Only rows with a successfully embedded audio are kept.
152
+ """
153
+ df = pd.read_csv(manifest_csv)
154
+ if audio_status_ok and 'audio_embedding_status' in df.columns:
155
+ df = df[df['audio_embedding_status'] == 'ok']
156
+ df = df[df['audio_embedding_path'].notna()]
157
+
158
+ records = []
159
+ for row in df.itertuples(index=False):
160
+ caption = str(getattr(row, 'caption', '') or '')
161
+ split = getattr(row, 'split', 'train')
162
+ records.append({
163
+ 'file_path': row.audio_embedding_path,
164
+ 'caption': {sha256(caption.encode('utf-8')).hexdigest(): caption},
165
+ 'split': split if split in ('train', 'val', 'test') else 'train',
166
+ 'track_id': getattr(row, 'track_id', None),
167
+ })
168
+ logging.info(f"Loaded {len(records)} music4all annotations from {manifest_csv}")
169
+ return records
170
+
171
+
172
+ def get_folder_annotations(data_path = None):
173
+
174
+ # recursively get all files in the data_path directory that are audio files, and their paths
175
+ audio_files = []
176
+
177
+ for root, dirs, files in os.walk(data_path):
178
+ audio_files += [os.path.join(root, file) for file in files if file.endswith('.wav') or file.endswith('.mp3')]
179
+
180
+ records = [{'file_path': file, 'caption': '', 'split': 'train'} for file in audio_files]
181
+
182
+ logging.info(f"Found {len(records)} audio files in {data_path}")
183
+
184
+
185
+ return records
186
+
187
+
188
+
189
+ class TextAudioDataModule(LightningDataModule):
190
+
191
+ def __init__(self,
192
+ datasets,
193
+ return_audio = True,
194
+ return_text = True,
195
+ concept = None,
196
+ target_n_samples = 96000,
197
+ target_sr = 48000,
198
+ batch_size = 32, num_workers = 0, preextracted_features = False, truncate_preextracted = 50, root_dir = None, new_dir = None,
199
+ **kwargs):
200
+
201
+
202
+ super().__init__()
203
+ self.annotations = []
204
+ dataset_names, datasets = list(datasets.keys()), list(datasets.values())
205
+
206
+
207
+
208
+
209
+ self.datasets = datasets
210
+ self.dataset_names = dataset_names
211
+ for dataset in self.datasets:
212
+ dataset.split = dataset.split
213
+
214
+ self.return_audio = return_audio
215
+ self.return_text = return_text
216
+ self.concept = concept
217
+ self.target_n_samples = target_n_samples
218
+ self.target_sr = target_sr
219
+ self.batch_size = batch_size
220
+ self.num_workers = num_workers
221
+ self.preextracted_features = preextracted_features
222
+ self.truncate_preextracted = truncate_preextracted
223
+
224
+
225
+ self.truncate_preextracted = [self.truncate_preextracted for _ in range(len(self.datasets))]
226
+
227
+ self.root_dirs = [dataset.root_dir for dataset in self.datasets]
228
+ self.new_dirs = [dataset.new_dir for dataset in self.datasets]
229
+
230
+ self.train_annotations = []
231
+ self.val_annotations = []
232
+ self.test_annotations = []
233
+ self.val_dataset_names = []
234
+ self.test_dataset_names = []
235
+ self.val_dataset_indices = [] # Track original dataset indices for val
236
+ self.test_dataset_indices = [] # Track original dataset indices for test
237
+
238
+
239
+ for i, dataset_ in enumerate(self.datasets):
240
+ self.train_annotations.extend([annot for annot in dataset_.annotations if annot['split'] == 'train'])
241
+ val_annots = [annot for annot in dataset_.annotations if annot['split'] == 'val']
242
+ test_annots = [annot for annot in dataset_.annotations if annot['split'] == 'test']
243
+ if val_annots:
244
+ self.val_annotations.append(val_annots)
245
+ self.val_dataset_names.append(self.dataset_names[i])
246
+ self.val_dataset_indices.append(i)
247
+ if test_annots:
248
+ self.test_annotations.append(test_annots)
249
+ self.test_dataset_names.append(self.dataset_names[i])
250
+ self.test_dataset_indices.append(i)
251
+
252
+
253
+ # filter for empty validation and test annotations (already filtered above, but keeping for safety)
254
+ self.val_annotations = [annot for annot in self.val_annotations if annot]
255
+ self.test_annotations = [annot for annot in self.test_annotations if annot]
256
+
257
+ # Create dataloader_names mapping: maps dataloader_idx -> dataset_name (for val and test)
258
+ # This will be used by callbacks to properly name metrics
259
+ self.dataloader_names = {}
260
+ for idx, name in enumerate(self.val_dataset_names):
261
+ self.dataloader_names[idx] = name
262
+ self.test_dataloader_names = {}
263
+ for idx, name in enumerate(self.test_dataset_names):
264
+ self.test_dataloader_names[idx] = name
265
+
266
+ print(f"Number of training samples: {len(self.train_annotations)}")
267
+ print(f"Number of validation samples: {sum([len(annot) for annot in self.val_annotations])} over {len(self.val_annotations)} datasets, {[len(annot) for annot in self.val_annotations]}")
268
+ print(f"Number of test samples: {sum([len(annot) for annot in self.test_annotations])} over {len(self.test_annotations)} datasets, {[len(annot) for annot in self.test_annotations]}")
269
+ print(f"Datasets: {self.dataset_names}")
270
+
271
+ @property
272
+ def names(self):
273
+ """Mapping mode -> (dataloader_idx -> dataset name). Used by callbacks for log keys."""
274
+ return {
275
+ "val": getattr(self, "dataloader_names", None) or {},
276
+ "test": getattr(self, "test_dataloader_names", None) or {},
277
+ }
278
+
279
+ def setup(self, stage: str) -> None:
280
+
281
+ if stage != 'eval':
282
+ self.train_dataset = TextAudioDataset(annotations=self.train_annotations, target_n_samples=self.target_n_samples, target_sr=self.target_sr, return_audio=self.return_audio, return_text=self.return_text, concept=self.concept, preextracted_features=self.preextracted_features, truncate_preextracted=self.truncate_preextracted[0], root_dir=self.root_dirs[0], new_dir=self.new_dirs[0])
283
+
284
+ self.val_datasets = [TextAudioDataset(annotations=self.val_annotations[i], target_n_samples=self.target_n_samples, target_sr=self.target_sr, return_audio=self.return_audio, return_text=self.return_text, concept=self.concept, preextracted_features=self.preextracted_features, truncate_preextracted=self.truncate_preextracted[self.val_dataset_indices[i]], root_dir=self.root_dirs[self.val_dataset_indices[i]], new_dir=self.new_dirs[self.val_dataset_indices[i]]) for i in range(len(self.val_annotations))]
285
+ self.test_datasets = [TextAudioDataset(annotations=self.test_annotations[i], target_n_samples=self.target_n_samples, target_sr=self.target_sr, return_audio=self.return_audio, return_text=self.return_text, concept=self.concept, preextracted_features=self.preextracted_features, truncate_preextracted=self.truncate_preextracted[self.test_dataset_indices[i]], root_dir=self.root_dirs[self.test_dataset_indices[i]], new_dir=self.new_dirs[self.test_dataset_indices[i]]) for i in range(len(self.test_annotations))]
286
+
287
+
288
+ def train_dataloader(self):
289
+ return DataLoader(
290
+ self.train_dataset,
291
+ batch_size=self.batch_size,
292
+ num_workers=self.num_workers,
293
+ shuffle=True,
294
+ drop_last=True # Critical for distributed training to avoid hangs
295
+ )
296
+
297
+ def val_dataloader(self):
298
+ return [
299
+ DataLoader(
300
+ val_dataset,
301
+ batch_size=self.batch_size,
302
+ num_workers=self.num_workers,
303
+ shuffle=False,
304
+ drop_last=False # Don't drop for validation
305
+ )
306
+ for val_dataset in self.val_datasets
307
+ ]
308
+
309
+ def test_dataloader(self):
310
+ return [
311
+ DataLoader(
312
+ test_dataset,
313
+ batch_size=self.batch_size,
314
+ num_workers=self.num_workers,
315
+ shuffle=False,
316
+ drop_last=False # Don't drop for test
317
+ )
318
+ for test_dataset in self.test_datasets
319
+ ]
steerable_retrieval/dataloading/datasets.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch.utils.data import Dataset
2
+ from .loading_utils import *
3
+ import torch
4
+ import os
5
+ import random
6
+ from tqdm import tqdm
7
+ import pandas as pd
8
+ from hydra.utils import instantiate
9
+
10
+ import logging
11
+
12
+ class BasicProcessor:
13
+ def __init__(self, probability = 1.0, split = None):
14
+ self.probability = probability
15
+ self.split = split
16
+
17
+ def process(self, annot):
18
+ """
19
+ Process the annotation if the probability is less than the probability and the split is in the split list
20
+ """
21
+ if random.random() < self.probability:
22
+ if self.split is None:
23
+ return self.process(annot)
24
+ if self.split is not None and annot['split'] in self.split:
25
+ return self.process(annot)
26
+ else:
27
+ return annot
28
+ else:
29
+ return annot
30
+
31
+ def __call__(self, annot):
32
+ return self.process(annot)
33
+
34
+ class RandomNSentencesProcessor(BasicProcessor):
35
+
36
+ def process(self, annot):
37
+
38
+ prompt = annot['prompt']
39
+ sentences = prompt.split('.')
40
+ n_sentences = len(sentences)
41
+ keep_n_sentences = random.randint(1, n_sentences)
42
+ random_sentences = random.sample(random_sentences, keep_n_sentences)
43
+ prompt = '. '.join(random_sentences)
44
+ return prompt
45
+
46
+ class ShuffleSentencesProcessor(BasicProcessor):
47
+ def __init__(self, **kwargs):
48
+ super().__init__(**kwargs)
49
+
50
+ def process(self, annot):
51
+ prompt = annot['prompt']
52
+ sentences = prompt.split('.')
53
+ random.shuffle(sentences)
54
+ prompt = '. '.join(sentences)
55
+ return prompt
56
+
57
+ class ShuffleTagsProcessor(BasicProcessor):
58
+ def __init__(self, replace_caption_p = 0.5, **kwargs):
59
+ super().__init__(**kwargs)
60
+ self.replace_caption_p = replace_caption_p
61
+
62
+ def process(self, annot):
63
+ ## if tags is in the annotation, it will be a comma-separated string
64
+ ## drop anywhere from all but 1 to 1 tag, and shuffle
65
+ if 'tags' in annot:
66
+ tags = annot['tags'].split(',')
67
+ else:
68
+ return annot
69
+ len_tags = len(tags)
70
+ drop_tags = random.randint(1, len_tags-1)
71
+ tags_to_drop = random.sample(tags, drop_tags)
72
+ tags = [tag for tag in tags if tag not in tags_to_drop]
73
+ random.shuffle(tags)
74
+ annot['tags'] = '. '.join(tags)
75
+
76
+ # if replace caption, replace else add it to the caption
77
+ if random.random() < self.replace_caption_p:
78
+ annot['prompt'] = annot['tags']
79
+ else:
80
+ annot['prompt'] = annot['prompt'] + '.' + annot['tags']
81
+ return annot
82
+
83
+ class TextAudioDataset(Dataset):
84
+ def __init__(self,
85
+ annotations = None,
86
+ get_annotations_function = None,
87
+ task_kwargs = None,
88
+ target_n_samples = 96000,
89
+ target_sr = 48000,
90
+ return_audio = True,
91
+ return_text = True,
92
+ concept = None,
93
+ return_full_audio = False,
94
+ preextracted_features = False,
95
+ truncate_preextracted = 50,
96
+ split = None,
97
+ filter_split = None,
98
+ root_dir = None,
99
+ new_dir = None,
100
+ limit_n = None,
101
+ processors = [],
102
+ **kwargs
103
+ ):
104
+
105
+
106
+ # Get annotations either directly or from function
107
+ if annotations is not None:
108
+ self.annotations = annotations
109
+ elif get_annotations_function is not None:
110
+ # Support both string and callable
111
+ if isinstance(get_annotations_function, str):
112
+ task_kwargs = task_kwargs or {}
113
+ import importlib
114
+
115
+ # parse the fully qualified function name
116
+ module_name, func_name = get_annotations_function.rsplit('.', 1)
117
+ module = importlib.import_module(module_name)
118
+ get_annotations_func = getattr(module, func_name)
119
+ self.annotations = get_annotations_func(**task_kwargs)
120
+ else:
121
+ task_kwargs = task_kwargs or {}
122
+ self.annotations = get_annotations_function(**task_kwargs)
123
+ else:
124
+ raise ValueError("Must provide either annotations or get_annotations_function")
125
+
126
+
127
+
128
+
129
+ self.target_n_samples = target_n_samples
130
+ self.target_sr = target_sr
131
+ self.return_audio = return_audio
132
+ self.return_text = return_text
133
+ self.concept = concept
134
+ self.return_full_audio = return_full_audio
135
+ self.preextracted_features = preextracted_features
136
+ self.truncate_preextracted = truncate_preextracted
137
+ self.split = split
138
+ self.root_dir = root_dir
139
+ self.new_dir = new_dir
140
+ self.limit_n = limit_n
141
+ # Update split if needed
142
+ if split is not None and split != 'keep':
143
+ for annot in self.annotations:
144
+ annot['split'] = split
145
+ elif split == 'keep':
146
+ # Keep original splits from annotations, or set to 'train' if not present
147
+ for annot in self.annotations:
148
+ if 'split' not in annot or annot['split'] not in ['train', 'val', 'test']:
149
+ annot['split'] = 'train'
150
+ elif split is None and len(self.annotations) > 0 and 'split' not in self.annotations[0].keys():
151
+ for annot in self.annotations:
152
+ annot['split'] = 'train'
153
+
154
+ if filter_split is not None:
155
+ self.annotations = [annot for annot in self.annotations if annot['split'] in filter_split]
156
+
157
+ annot_df = pd.DataFrame(self.annotations)
158
+
159
+ try:
160
+ annot_df['file_index'] = pd.factorize(annot_df['file_path'])[0]
161
+ except Exception as e:
162
+ print(e)
163
+
164
+ annot_df['file_path'] = annot_df['file_path'].apply(lambda x: x.replace(root_dir, new_dir) if root_dir is not None and new_dir is not None else x)
165
+
166
+ self.annotations = annot_df.to_dict('records')
167
+
168
+ # Filter out annotations whose feature/audio file is missing (avoids IndexError when retrying in __getitem__)
169
+ if self.return_audio and self.preextracted_features:
170
+ n_before = len(self.annotations)
171
+ self.annotations = [
172
+ a for a in self.annotations
173
+ if os.path.exists(a['file_path'].replace('.mp3', '.npy').replace('.wav', '.npy'))
174
+ ]
175
+ if len(self.annotations) < n_before:
176
+ logging.warning(
177
+ f"Filtered out {n_before - len(self.annotations)} annotations with missing feature files. "
178
+ f"Dataset has {len(self.annotations)} samples."
179
+ )
180
+ elif self.return_audio and not self.preextracted_features:
181
+ n_before = len(self.annotations)
182
+ def _audio_exists(a):
183
+ p = a['file_path']
184
+ return os.path.exists(p.replace('.npy', '.mp3')) or os.path.exists(p.replace('.npy', '.wav'))
185
+ self.annotations = [a for a in self.annotations if _audio_exists(a)]
186
+ if len(self.annotations) < n_before:
187
+ logging.warning(
188
+ f"Filtered out {n_before - len(self.annotations)} annotations with missing audio files. "
189
+ f"Dataset has {len(self.annotations)} samples."
190
+ )
191
+
192
+ if self.limit_n is not None and self.limit_n < len(self.annotations):
193
+ self.annotations = self.annotations[:self.limit_n]
194
+ print(f"Limiting dataset to {self.limit_n} samples")
195
+ else:
196
+ print(f"Dataset has {len(self.annotations)} samples")
197
+
198
+
199
+ assert return_audio or return_text, "At least one of return_audio or return_text must be True (duh)"
200
+
201
+ self.processors = [instantiate(processor) for processor in processors]
202
+
203
+
204
+
205
+
206
+ def purge(self):
207
+ if self.return_audio and not self.preextracted_features:
208
+ raise NotImplementedError("Purging your audio dataset is probably a bad idea")
209
+ else:
210
+ file_paths = [annot['file_path'] for annot in self.annotations]
211
+ for file_path in file_paths:
212
+ os.remove(file_path)
213
+ print(f"Removed {len(file_paths)} files")
214
+
215
+ def __len__(self):
216
+ return len(self.annotations)
217
+
218
+ def __getitem__(self, idx, return_full_audio = False, hop = None, verbose = False):
219
+
220
+
221
+ return_full_audio = self.return_full_audio if return_full_audio is None else return_full_audio
222
+
223
+ annot = self.annotations[idx]
224
+
225
+
226
+ if self.return_audio:
227
+ if not self.preextracted_features:
228
+ # file_path = annot['file_path'].replace('.npy','.mp3').replace('.wav','.mp3')
229
+ # check if the mp3 file exists
230
+ if os.path.exists(annot['file_path'].replace('.npy','.mp3')):
231
+ file_path = annot['file_path'].replace('.npy','.mp3')
232
+ elif os.path.exists(annot['file_path'].replace('.npy','.wav')):
233
+ file_path = annot['file_path'].replace('.npy','.wav')
234
+ else:
235
+ return self.__getitem__(idx+1)
236
+
237
+ annot['file_path'] = file_path
238
+ try:
239
+ audio = load_full_and_split(
240
+ annot['file_path'],
241
+ self.target_sr,
242
+ self.target_n_samples,
243
+ hop=hop,
244
+ verbose=verbose
245
+ ) if return_full_audio else load_audio_chunk(
246
+ annot['file_path'],
247
+ target_sr=self.target_sr,
248
+ target_n_samples=self.target_n_samples,
249
+ verbose=verbose
250
+ )
251
+ audio = audio.mean(1)
252
+ except Exception as e:
253
+ return self.__getitem__(idx+1)
254
+ else:
255
+ file_path = annot['file_path'].replace('.mp3','.npy').replace('.wav','.npy')
256
+ try:
257
+
258
+ audio = np.load(file_path,mmap_mode='r')
259
+ # Preextracted features may be stored per-clip as a single vector [D]
260
+ # or as multiple frames [T, D]; sample a random frame when framewise.
261
+ if audio.ndim > 1:
262
+ rand_index = random.randint(0, audio.shape[0]-1)
263
+ audio = audio[rand_index]
264
+ audio = torch.tensor(np.asarray(audio))
265
+ except Exception as e:
266
+ return self.__getitem__(idx+1)
267
+
268
+
269
+
270
+ if self.return_text:
271
+ possible_captions = annot['caption']
272
+ # ramdomly choose a caption hash
273
+
274
+ random_hash = random.choice(list(possible_captions.keys()))
275
+
276
+ caption = possible_captions[random_hash]
277
+
278
+ return_dict = {}
279
+
280
+ if self.return_audio:
281
+ # Clone so storage is resizable; avoids DataLoader collate error with mmap/numpy-derived tensors
282
+ return_dict['audio'] = audio.clone() if isinstance(audio, torch.Tensor) else audio
283
+ return_dict['file_path'] = annot['file_path']
284
+
285
+ if self.return_text:
286
+ return_dict['prompt'] = caption
287
+
288
+ return_dict['file_idx'] = annot['file_index']
289
+
290
+ for processor in self.processors:
291
+ return_dict = processor(return_dict)
292
+
293
+ return return_dict
294
+
295
+
296
+ def extract_features(self, model, extract_method = 'extract_features', extract_kwargs = {}, out_key = 'embedding',hop = None, return_full_audio = True, verbose = False):
297
+
298
+ device = next(model.parameters()).device
299
+ print(f"Extracting features with {extract_method} method on {device} device") if verbose else None
300
+
301
+
302
+ for param in model.parameters():
303
+ param.requires_grad = False
304
+ try:
305
+ model.eval()
306
+ except:
307
+ pass
308
+
309
+ for i in range(len(self)):
310
+ try:
311
+ item = self.__getitem__(i, return_full_audio = return_full_audio, hop = hop, verbose = verbose)
312
+ file_path = self.annotations[i]['file_path'].replace('.mp3','.npy').replace('.wav','.npy')
313
+
314
+ audio = item['audio'].squeeze(1).to(device)
315
+
316
+
317
+
318
+ if audio.shape[0] > 200 :
319
+ chunks = torch.split(audio, 200, dim=0)
320
+ chunks = list(chunks)
321
+ audio_features = []
322
+ for chunk in chunks:
323
+ feat = getattr(model, extract_method)(chunk, **extract_kwargs)
324
+ if out_key is not None:
325
+ feat = feat[out_key]
326
+ audio_features.append(feat)
327
+ audio_features = torch.cat(audio_features, dim=0)
328
+ else:
329
+ audio_features = getattr(model, extract_method)(audio.to(device), **extract_kwargs)
330
+ if out_key is not None:
331
+ audio_features = audio_features[out_key]
332
+
333
+
334
+ print(f"Extracted features for {file_path}, shape: {audio_features.shape}") if verbose else None
335
+
336
+ yield audio_features, file_path
337
+ except Exception as e:
338
+ print(f"Error extracting features for {file_path}: {e}") if verbose else None
339
+ continue
340
+
341
+ def extract_and_save_features(self, model, save_dir = None, extract_method = 'extract_features', extract_kwargs = {}, out_key = 'embedding', hop = None, return_full_audio = True, limit_n = None, save = False, verbose = True, root_path = None, done_ids = None):
342
+
343
+
344
+ print(self.__len__())
345
+
346
+ audio_features_all = []
347
+ counter = 0
348
+ skipped_count = 0
349
+
350
+ save_dir = '' if save_dir is None else save_dir
351
+ done_ids = done_ids or set()
352
+
353
+ if 's3://' in save_dir:
354
+ import boto3
355
+ import io
356
+ client = boto3.client('s3')
357
+ else:
358
+ client = None
359
+ import io
360
+
361
+ # filter self.annotations to only include files that are not in done_ids
362
+ new_annotations = []
363
+ for annot in self.annotations:
364
+ fp = annot['file_path']
365
+ fp = fp.replace(root_path+'/','')
366
+ # remove extension
367
+ fp = fp.replace('.mp3','').replace('.wav','').replace('.npy','')
368
+
369
+ if fp not in done_ids:
370
+ new_annotations.append(annot)
371
+
372
+ self.annotations = new_annotations
373
+
374
+ for audio_features, file_path in (pbar:= tqdm(self.extract_features(model, extract_method = extract_method, extract_kwargs = extract_kwargs, out_key = out_key, hop = hop, return_full_audio = return_full_audio, verbose = verbose))):
375
+
376
+ # print(file_path, root_path, save_dir)
377
+
378
+ if root_path is not None:
379
+ file_path = file_path.replace(root_path+'/','')
380
+
381
+ save_path = os.path.join(save_dir, file_path)
382
+
383
+ if save and audio_features is not None:
384
+
385
+
386
+ #remove the root path from the file path
387
+
388
+
389
+ if 's3://' in save_dir:
390
+ bucket, key = save_dir.replace("s3://", "").split("/", 1)
391
+ key = f"{key}/{file_path}"
392
+
393
+ # local_path = os.path.join(local_temp_dir, file_path)
394
+ # os.makedirs(os.path.dirname(local_path), exist_ok=True)
395
+ # np.save(local_path, audio_features.detach().cpu().numpy())
396
+
397
+ pbar.set_description(f"Uploading features to s3://{bucket}/{key}") if verbose else None
398
+ try:
399
+ # client.upload_file(save_path, bucket, key)
400
+
401
+ buffer = io.BytesIO()
402
+ np.save(buffer, audio_features.detach().cpu().numpy())
403
+ buffer.seek(0)
404
+ client.put_object(Bucket=bucket, Key=key, Body=buffer)
405
+ except Exception as e:
406
+ print(f"Error uploading to s3: {e}") if verbose else None
407
+
408
+ # os.remove(local_path)
409
+ else:
410
+ pbar.set_description(f"Saving features in {save_path}, shape: {audio_features.shape}")
411
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
412
+ if os.path.exists(save_path):
413
+ os.remove(save_path)
414
+ np.save(save_path, audio_features.detach().cpu().numpy())
415
+
416
+ if not save and audio_features is not None:
417
+ pbar.set_description(f"{file_path}, shape: {audio_features.shape}")
418
+ pass
419
+
420
+ audio_features_all.append(audio_features.detach().cpu()) if audio_features is not None else None
421
+
422
+ counter += 1
423
+ if limit_n and counter >= limit_n:
424
+ break
425
+
426
+ if skipped_count > 0:
427
+ print(f"Skipped {skipped_count} already processed items") if verbose else None
428
+
429
+ try:
430
+ print(f"Returning {len(audio_features_all)} features") if verbose else None
431
+ all_= torch.stack(audio_features_all)
432
+ print(f"Stacked features, shape: {all_.shape}") if verbose else None
433
+ return all_
434
+
435
+
436
+ except:
437
+ return None
steerable_retrieval/dataloading/loading_utils.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torchaudio
2
+ import soundfile as sf
3
+ import numpy as np
4
+ import torch
5
+ import logging
6
+
7
+
8
+ def _safe_sr_frames(path: str):
9
+ try:
10
+ info = sf.info(path)
11
+ return info.samplerate, info.frames
12
+ except sf.LibsndfileError:
13
+ # soundfile can't read some formats (e.g. mp3); fall back to torchaudio,
14
+ # which uses the same ffmpeg/soundfile backends without needing torchcodec.
15
+ info = torchaudio.info(path)
16
+ return info.sample_rate, info.num_frames
17
+
18
+
19
+ def load_audio_chunk(path, target_n_samples, target_sr, start=None, verbose=False):
20
+ """
21
+ Load a chunk of audio from a file using torchaudio.
22
+
23
+ Args:
24
+ path: Path to audio file
25
+ target_n_samples: Number of samples to load (at target_sr)
26
+ target_sr: Target sample rate
27
+ start: Starting frame (at original sr). If None, random start.
28
+ verbose: Print debug info
29
+
30
+ Returns:
31
+ audio: Tensor of shape [n_samples, n_channels]
32
+ """
33
+ # Get audio metadata using soundfile
34
+ sr, frames = _safe_sr_frames(path)
35
+
36
+ print(f"length of audio in seconds: {frames/sr}") if verbose else None
37
+ print(f"Original sample rate: {sr}") if verbose else None
38
+
39
+ # Adjust for MP3 padding
40
+ if path.split(".")[-1].lower() == "mp3":
41
+ frames = frames - 8192
42
+
43
+ # Calculate how many frames to load at original sr
44
+ new_target_n_samples = int(target_n_samples * sr / target_sr)
45
+
46
+ print(f"New target n samples: {new_target_n_samples}") if verbose else None
47
+
48
+ # Random start if not specified
49
+ if start is None:
50
+ max_start = max(1, frames - new_target_n_samples)
51
+ start = np.random.randint(0, max_start)
52
+
53
+ # Load audio chunk with torchaudio
54
+ # torchaudio.load returns (waveform, sample_rate) where waveform is [channels, samples]
55
+ audio, loaded_sr = torchaudio.load(
56
+ path,
57
+ frame_offset=start,
58
+ num_frames=new_target_n_samples,
59
+ normalize=True,
60
+ )
61
+
62
+ # Resample if needed
63
+ if loaded_sr != target_sr:
64
+ audio = torchaudio.functional.resample(audio, loaded_sr, target_sr)
65
+ print(f"Resampled to {target_sr}, shape of audio: {audio.shape}") if verbose else None
66
+
67
+ # pad if needed
68
+ if audio.shape[1] < target_n_samples:
69
+ audio = torch.nn.functional.pad(audio, (0, target_n_samples - audio.shape[1]))
70
+
71
+ # Convert from [channels, samples] to [samples, channels]
72
+ audio = audio.T
73
+
74
+ return audio
75
+
76
+
77
+ def load_full_audio(path, target_sr, verbose=False):
78
+ """
79
+ Load full audio file using torchaudio.
80
+
81
+ Args:
82
+ path: Path to audio file
83
+ target_sr: Target sample rate
84
+ verbose: Print debug info
85
+
86
+ Returns:
87
+ audio: Tensor of shape [n_channels, n_samples]
88
+ """
89
+ # Load with torchaudio - returns [channels, samples]
90
+ audio, sr = torchaudio.load(path, normalize=True)
91
+
92
+ # If stereo, average to mono
93
+ if audio.shape[0] == 2:
94
+ audio = audio.mean(dim=0, keepdim=True)
95
+
96
+
97
+ # Resample if needed
98
+ if sr != target_sr:
99
+ audio = torchaudio.functional.resample(audio, sr, target_sr)
100
+
101
+
102
+ return audio
103
+
104
+
105
+ def load_full_and_split(path, target_sr, target_n_samples, hop=None, verbose=False):
106
+ """
107
+ Load full audio and split into overlapping chunks.
108
+
109
+ Args:
110
+ path: Path to audio file
111
+ target_sr: Target sample rate
112
+ target_n_samples: Samples per chunk
113
+ hop: Hop size between chunks (default: target_n_samples)
114
+ verbose: Print debug info
115
+
116
+ Returns:
117
+ audio: Tensor of shape [n_chunks, 1, target_n_samples]
118
+ """
119
+ hop = target_n_samples if hop is None else hop
120
+ audio = load_full_audio(path, target_sr, verbose=verbose)
121
+ audio = audio.squeeze()
122
+
123
+
124
+ # If audio is shorter than target, repeat
125
+ if audio.shape[0] < target_n_samples:
126
+ n_repeats = int(np.ceil(target_n_samples / audio.shape[0]))
127
+ audio = audio.repeat(n_repeats)
128
+
129
+ audio = audio.unfold(0, int(target_n_samples), int(hop)).unsqueeze(1)
130
+
131
+ return audio
steerable_retrieval/experiments/README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Offline Processing Experiments
2
+
3
+ This directory contains offline experiment jobs that run outside training callbacks.
4
+
5
+ ## Stability (offline)
6
+
7
+ Execution script:
8
+ - `python steerable_retrieval/experiments/run_stability.py model_source.config_source=<WANDB_RUN_ID_OR_YAML_PATH> model_source.checkpoint_source=<CKPT> stability.checkpoint_paths=[<CKPT1>,<CKPT2>]`
9
+
10
+ Launch as SageMaker processing job:
11
+ - `python launch_stability.py model_source.config_source=<WANDB_RUN_ID_OR_YAML_PATH> model_source.checkpoint_source=<CKPT> stability.checkpoint_paths=[<CKPT1>,<CKPT2>]`
12
+
13
+ ## Concept Isolation (offline)
14
+
15
+ Execution script:
16
+ - `python steerable_retrieval/experiments/run_concept_isolation.py model_source.config_source=<WANDB_RUN_ID_OR_YAML_PATH> model_source.checkpoint_source=<CKPT> concepts_experiment=extract concepts.vocab_path=<VOCAB_TXT>`
17
+
18
+ Score edit (add):
19
+ - `python steerable_retrieval/experiments/run_concept_isolation.py model_source.config_source=<WANDB_RUN_ID_OR_YAML_PATH> model_source.checkpoint_source=<CKPT> concepts_experiment=score_edit_add concepts.vocab_path=<VOCAB_TXT>`
20
+
21
+ Score edit (suppress):
22
+ - `python steerable_retrieval/experiments/run_concept_isolation.py model_source.config_source=<WANDB_RUN_ID_OR_YAML_PATH> model_source.checkpoint_source=<CKPT> concepts_experiment=score_edit_suppress concepts.vocab_path=<VOCAB_TXT>`
23
+
24
+ Launch as SageMaker processing job:
25
+ - `python launch_concept_isolation.py model_source.config_source=<WANDB_RUN_ID_OR_YAML_PATH> model_source.checkpoint_source=<CKPT> concepts_experiment=extract concepts.vocab_path=<VOCAB_TXT>`
26
+
27
+ ## Notes
28
+
29
+ - Both jobs save run config and outputs under `output_dir` (default `${paths.output_dir}`).
30
+ - Stability logging to W&B is controlled by `wandb.*` keys in `configs/experiment/stability.yaml`.
31
+ - Concept isolation is now a dispatcher with `concepts_experiment=extract|score_edit_add|score_edit_suppress`.
32
+ - `model_source.config_source` can be a W&B run id, a local `.yaml` file path, or an `s3://...yaml` path.
33
+ - If `config_source` contains `.yaml`, the model config is read from that YAML; otherwise it is fetched from W&B.
34
+ - `model_source.checkpoint_source` is the checkpoint path loaded directly (local path or S3 path).
35
+
steerable_retrieval/experiments/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Offline experiment entrypoints and utilities."""
2
+
steerable_retrieval/experiments/common.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+ from typing import Any, Callable, Dict, List, Optional
9
+
10
+ import hydra
11
+ import torch
12
+ from omegaconf import DictConfig, OmegaConf
13
+
14
+ from steerable_retrieval.callbacks.energy import load_state_dict_any
15
+ from steerable_retrieval.utils.pylogger import RankedLogger
16
+
17
+ log = RankedLogger(__name__, rank_zero_only=True)
18
+
19
+
20
+ def _silence_huggingface_loading() -> None:
21
+ """Reduce HuggingFace/Transformers load-time noise (progress bars + logs)."""
22
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
23
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
24
+ logging.getLogger("transformers").setLevel(logging.ERROR)
25
+ logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
26
+ try:
27
+ from transformers.utils import logging as hf_logging
28
+
29
+ hf_logging.set_verbosity_error()
30
+ except Exception:
31
+ pass
32
+
33
+
34
+ def ensure_dir(path: str) -> str:
35
+ os.makedirs(path, exist_ok=True)
36
+ return path
37
+
38
+
39
+ def save_config(cfg: DictConfig, output_dir: str, filename: str = "config.yaml") -> str:
40
+ ensure_dir(output_dir)
41
+ out_path = os.path.join(output_dir, filename)
42
+ OmegaConf.save(cfg, out_path, resolve=True)
43
+ return out_path
44
+
45
+
46
+ def resolve_checkpoint_path(model_source_cfg: DictConfig) -> str:
47
+ ckpt = model_source_cfg.get("checkpoint_source")
48
+ if ckpt is None:
49
+ # Backward compatibility with older experiment configs.
50
+ ckpt = model_source_cfg.get("checkpoint_path")
51
+ if ckpt:
52
+ return str(ckpt)
53
+
54
+ run_id = model_source_cfg.get("run_id")
55
+ if run_id is None:
56
+ run_id = model_source_cfg.get("config_source")
57
+ if run_id is None:
58
+ run_id = model_source_cfg.get("wandb_run_id")
59
+ if not run_id:
60
+ raise ValueError(
61
+ "Missing model_source.run_id (or compatible fallback). "
62
+ "Set model_source.checkpoint_source explicitly or provide a run id for auto checkpoint resolution."
63
+ )
64
+ run_id = str(run_id)
65
+ if ".yaml" in run_id:
66
+ raise ValueError(
67
+ "Auto checkpoint resolution expects model_source.config_source to be a run id, "
68
+ "not a YAML path. Set model_source.checkpoint_source explicitly."
69
+ )
70
+
71
+ s3_prefix = str(model_source_cfg.get("checkpoint_s3_prefix", "s3://your-bucket/checkpoints")).rstrip("/")
72
+ parsed = urlparse(s3_prefix)
73
+ if parsed.scheme != "s3" or not parsed.netloc:
74
+ raise ValueError(
75
+ f"Invalid model_source.checkpoint_s3_prefix '{s3_prefix}'. Expected an s3:// URI."
76
+ )
77
+ bucket = parsed.netloc
78
+ prefix = parsed.path.lstrip("/")
79
+ key_prefix = f"{prefix}/{run_id}/checkpoints/".lstrip("/")
80
+
81
+ try:
82
+ import boto3
83
+ except Exception as exc:
84
+ raise RuntimeError("boto3 is required to auto-resolve checkpoint path from S3.") from exc
85
+
86
+ client = boto3.client("s3")
87
+ paginator = client.get_paginator("list_objects_v2")
88
+ candidates = []
89
+ for page in paginator.paginate(Bucket=bucket, Prefix=key_prefix):
90
+ for obj in page.get("Contents", []):
91
+ key = str(obj.get("Key", ""))
92
+ filename = key.rsplit("/", 1)[-1]
93
+ if "epoch" in filename and (key.endswith(".pt") or key.endswith(".ckpt")):
94
+ candidates.append(obj)
95
+
96
+ if not candidates:
97
+ raise FileNotFoundError(
98
+ "No checkpoint file found under "
99
+ f"s3://{bucket}/{key_prefix} matching '*epoch*.pt' or '*epoch*.ckpt'."
100
+ )
101
+
102
+ latest = max(candidates, key=lambda x: x.get("LastModified"))
103
+ latest_key = str(latest["Key"])
104
+ resolved_ckpt = f"s3://{bucket}/{latest_key}"
105
+ log.info(f"Auto-resolved checkpoint from run id '{run_id}': {resolved_ckpt}")
106
+ return resolved_ckpt
107
+
108
+
109
+ def resolve_run_id(run_id: str, entity: Optional[str], project: Optional[str]) -> str:
110
+ if not entity or not project:
111
+ raise ValueError(
112
+ "wandb.entity and wandb.project are required to resolve run id via "
113
+ "config.sagemaker_job_name."
114
+ )
115
+ try:
116
+ import wandb
117
+ except Exception as exc:
118
+ raise RuntimeError("wandb is required to resolve model_source.config_source.") from exc
119
+
120
+ api = wandb.Api()
121
+ runs = api.runs(
122
+ path=f"{entity}/{project}",
123
+ filters={"config.sagemaker_job_name": run_id},
124
+ per_page=1,
125
+ )
126
+ runs = list(runs)
127
+ if not runs:
128
+ raise ValueError(
129
+ f"No W&B run found in {entity}/{project} with config.sagemaker_job_name='{run_id}'."
130
+ )
131
+ resolved = str(runs[0].id)
132
+ log.info(
133
+ f"Resolved model_source.config_source '{run_id}' to W&B run id '{resolved}' "
134
+ f"via config.sagemaker_job_name."
135
+ )
136
+ return resolved
137
+
138
+
139
+ def _fetch_model_cfg_from_wandb(run_id: str, entity: Optional[str], project: Optional[str]) -> DictConfig:
140
+ resolved_run_id = resolve_run_id(run_id=run_id, entity=entity, project=project)
141
+ run_path = f"{entity}/{project}/{resolved_run_id}"
142
+ import wandb
143
+ api = wandb.Api()
144
+ run = api.run(run_path)
145
+ run_cfg = OmegaConf.create(run.config)
146
+ model_cfg = run_cfg.get("model")
147
+ if model_cfg is None:
148
+ raise KeyError(f"No 'model' key found in wandb run config for {run_path}.")
149
+ model_cfg = OmegaConf.create(model_cfg)
150
+ try:
151
+ OmegaConf.resolve(model_cfg)
152
+ except Exception:
153
+ log.warning(
154
+ "Could not fully resolve interpolations inside wandb run model config. "
155
+ "Proceeding with unresolved values."
156
+ )
157
+ return OmegaConf.create(OmegaConf.to_container(model_cfg, resolve=False))
158
+
159
+
160
+ def _fetch_yaml_cfg_from_s3(s3_uri: str) -> DictConfig:
161
+ try:
162
+ import boto3
163
+ except Exception as exc:
164
+ raise RuntimeError("boto3 is required to read YAML model config from S3.") from exc
165
+
166
+ parsed = urlparse(s3_uri)
167
+ bucket = parsed.netloc
168
+ key = parsed.path.lstrip("/")
169
+ if not bucket or not key:
170
+ raise ValueError(f"Invalid S3 URI for model_source.config_source: {s3_uri}")
171
+
172
+ client = boto3.client("s3")
173
+ body = client.get_object(Bucket=bucket, Key=key)["Body"].read().decode("utf-8")
174
+ cfg = OmegaConf.create(body)
175
+ OmegaConf.resolve(cfg)
176
+ return cfg
177
+
178
+
179
+ def _fetch_model_cfg_from_yaml(config_source: str) -> DictConfig:
180
+ if config_source.startswith("s3://"):
181
+ cfg = _fetch_yaml_cfg_from_s3(config_source)
182
+ else:
183
+ cfg = OmegaConf.load(config_source)
184
+ OmegaConf.resolve(cfg)
185
+
186
+ model_cfg = cfg.get("model")
187
+ # Accept either a full experiment config (with "model") or a model-only config.
188
+ if model_cfg is None:
189
+ model_cfg = cfg
190
+ return OmegaConf.create(OmegaConf.to_container(model_cfg, resolve=True))
191
+
192
+
193
+ def resolve_model_from_source(cfg: DictConfig) -> str:
194
+ model_source_cfg = cfg.get("model_source")
195
+ if model_source_cfg is None:
196
+ raise ValueError("Missing cfg.model_source.")
197
+
198
+ run_id = model_source_cfg.get("run_id")
199
+ config_source = model_source_cfg.get("config_source")
200
+ if config_source is None:
201
+ # Backward compatibility with older experiment configs.
202
+ config_source = model_source_cfg.get("wandb_run_id")
203
+ if not run_id and not config_source:
204
+ raise ValueError(
205
+ "model_source.run_id must be set (preferred), or set model_source.config_source "
206
+ "to a W&B id or a .yaml path (local/S3)."
207
+ )
208
+
209
+ if run_id:
210
+ run_id = str(run_id)
211
+ model_cfg = _fetch_model_cfg_from_wandb(
212
+ run_id=run_id,
213
+ entity=cfg.get("wandb", {}).get("entity"),
214
+ project=cfg.get("wandb", {}).get("project"),
215
+ )
216
+ resolved_source = run_id
217
+ else:
218
+ config_source = str(config_source)
219
+ if ".yaml" in config_source:
220
+ model_cfg = _fetch_model_cfg_from_yaml(config_source)
221
+ else:
222
+ model_cfg = _fetch_model_cfg_from_wandb(
223
+ run_id=config_source,
224
+ entity=cfg.get("wandb", {}).get("entity"),
225
+ project=cfg.get("wandb", {}).get("project"),
226
+ )
227
+ resolved_source = config_source
228
+
229
+ # Apply optional local/runtime overrides after fetching model config from source.
230
+ # This is useful for local CPU smoke tests when source configs are CUDA-oriented.
231
+ post_overrides = cfg.get("model_post_overrides", {})
232
+ if not torch.cuda.is_available():
233
+ model_cfg.sae_encoder.device = 'cpu'
234
+ model_cfg.sae_decoder.device = 'cpu'
235
+ model_cfg.audio_encoder.device = 'cpu'
236
+ model_cfg.text_encoder.device = 'cpu'
237
+ if post_overrides:
238
+ model_cfg = OmegaConf.merge(model_cfg, post_overrides)
239
+
240
+ cfg.model = model_cfg
241
+ return resolved_source
242
+
243
+
244
+ def instantiate_model_and_load(cfg: DictConfig, device: torch.device):
245
+ _silence_huggingface_loading()
246
+ model = hydra.utils.instantiate(cfg.model)
247
+ ckpt_path = resolve_checkpoint_path(cfg.model_source)
248
+ state_dict = load_state_dict_any(ckpt_path, map_location="cpu")
249
+ model_state_keys = set(model.state_dict().keys())
250
+ checkpoint_keys = set(state_dict.keys()) if isinstance(state_dict, dict) else set()
251
+ matched_keys = model_state_keys.intersection(checkpoint_keys)
252
+ if not matched_keys:
253
+ raise RuntimeError(
254
+ "Checkpoint appears incompatible: zero matching keys between model and checkpoint state_dict."
255
+ )
256
+ matched_key_names = sorted(matched_keys)
257
+ load_result = model.load_state_dict(state_dict, strict=False)
258
+ load_report = {
259
+ "checkpoint_keys": len(checkpoint_keys),
260
+ "model_keys": len(model_state_keys),
261
+ "matched_keys": len(matched_keys),
262
+ "matched_key_names": matched_key_names,
263
+ "missing_keys": len(getattr(load_result, "missing_keys", [])),
264
+ "unexpected_keys": len(getattr(load_result, "unexpected_keys", [])),
265
+ }
266
+ setattr(model, "_checkpoint_load_report", load_report)
267
+ model = model.to(device)
268
+ model.eval()
269
+ preview_limit = 30
270
+ matched_preview = matched_key_names[:preview_limit]
271
+ remaining = len(matched_key_names) - len(matched_preview)
272
+ preview_suffix = f" ... (+{remaining} more)" if remaining > 0 else ""
273
+ log.info(
274
+ f"Loaded model from checkpoint: {ckpt_path} | "
275
+ f"matched={load_report['matched_keys']}/{load_report['model_keys']} "
276
+ f"missing={load_report['missing_keys']} unexpected={load_report['unexpected_keys']} "
277
+ f"| matched_keys={matched_preview}{preview_suffix}"
278
+ )
279
+ return model, ckpt_path
280
+
281
+
282
+ def read_vocab_lines(vocab_path: str) -> List[str]:
283
+ with open(vocab_path, "r", encoding="utf-8") as f:
284
+ concepts = [line.strip() for line in f if line.strip()]
285
+ if not concepts:
286
+ raise ValueError(f"No concepts found in vocabulary file: {vocab_path}")
287
+ return concepts
288
+
289
+
290
+ def resolve_callable(path_str: str) -> Callable[..., Any]:
291
+ if ":" in path_str:
292
+ module_name, fn_name = path_str.split(":", 1)
293
+ else:
294
+ module_name, fn_name = path_str.rsplit(".", 1)
295
+ module = importlib.import_module(module_name)
296
+ fn = getattr(module, fn_name)
297
+ if not callable(fn):
298
+ raise TypeError(f"Resolved object is not callable: {path_str}")
299
+ return fn
300
+
301
+
302
+ def maybe_start_wandb(cfg: DictConfig, job_type: str, name: str, output_dir: str):
303
+ wb_cfg = cfg.get("wandb", {})
304
+ if not wb_cfg.get("enabled", True):
305
+ return None
306
+ try:
307
+ import wandb
308
+ except Exception:
309
+ log.warning("wandb not available, skipping remote logging.")
310
+ return None
311
+
312
+ run = wandb.init(
313
+ project=wb_cfg.get("project", "SpaMR"),
314
+ entity=wb_cfg.get("entity"),
315
+ name=name,
316
+ group=wb_cfg.get("group"),
317
+ job_type=job_type,
318
+ tags=list(wb_cfg.get("tags", [])),
319
+ dir=output_dir,
320
+ config=OmegaConf.to_container(cfg, resolve=True),
321
+ )
322
+ return run
323
+
324
+
325
+ def finish_wandb(run) -> None:
326
+ if run is None:
327
+ return
328
+ try:
329
+ import wandb
330
+
331
+ wandb.finish()
332
+ except Exception:
333
+ return
334
+
335
+
336
+ def save_json(path: str, payload: Dict[str, Any]) -> None:
337
+ import json
338
+
339
+ ensure_dir(str(Path(path).parent))
340
+ with open(path, "w", encoding="utf-8") as f:
341
+ json.dump(payload, f, indent=2, sort_keys=True)
342
+
343
+
steerable_retrieval/experiments/concepts/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Concept isolation experiment implementations."""
2
+
steerable_retrieval/experiments/concepts/extract.py ADDED
@@ -0,0 +1,899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import math
5
+ import os
6
+ import re
7
+ from functools import partial
8
+ from typing import Any, Dict
9
+
10
+ import torch
11
+ from omegaconf import DictConfig, OmegaConf
12
+ import torch.nn.functional as F
13
+
14
+ from steerable_retrieval.callbacks.energy import BridgeScoreCallback, get_dictionary_from_lightningsae, load_state_dict_any
15
+ from steerable_retrieval.experiments.common import ensure_dir, read_vocab_lines
16
+ from steerable_retrieval.utils import RankedLogger
17
+
18
+ log = RankedLogger(__name__, rank_zero_only=True)
19
+
20
+
21
+ def _slugify(value: str) -> str:
22
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-")
23
+ return slug or "na"
24
+
25
+
26
+ def _flatten_params(prefix: str, value: Any, items: list[tuple[str, str]]) -> None:
27
+ if isinstance(value, dict):
28
+ for key in sorted(value.keys()):
29
+ child_prefix = f"{prefix}.{key}" if prefix else str(key)
30
+ _flatten_params(child_prefix, value[key], items)
31
+ return
32
+ items.append((prefix or "param", str(value)))
33
+
34
+
35
+ def _auto_experiment_id(cfg: DictConfig) -> str:
36
+ extractor_cfg = cfg.concepts_experiment.get("extractor")
37
+ params_cfg = extractor_cfg.get("params", {}) if extractor_cfg is not None else {}
38
+ params = OmegaConf.to_container(params_cfg, resolve=True) if params_cfg is not None else {}
39
+ if not isinstance(params, dict):
40
+ params = {"value": params}
41
+
42
+ method = params.get("selection_method")
43
+ if method is None:
44
+ target = str(extractor_cfg.get("target", "extract")) if extractor_cfg is not None else "extract"
45
+ method = target.rsplit(".", 1)[-1]
46
+ method_slug = _slugify(method)
47
+
48
+ flat_items: list[tuple[str, str]] = []
49
+ _flatten_params("", params, flat_items)
50
+ parts = [f"{_slugify(k)}-{_slugify(v)}" for k, v in flat_items if str(v) != ""]
51
+ suffix = "__".join(parts)
52
+ out = f"{method_slug}__{suffix}" if suffix else method_slug
53
+ return out[:180]
54
+
55
+
56
+ def _resolve_output_dir(cfg: DictConfig, output_dir: str, source_run_id: str) -> tuple[str, str, str]:
57
+ run_id = cfg.get("model_source", {}).get("run_id") or source_run_id or "unknown_run"
58
+ run_slug = _slugify(run_id)
59
+ experiment_id_cfg = cfg.get("experiment_id")
60
+ experiment_id = _slugify(experiment_id_cfg) if experiment_id_cfg else _auto_experiment_id(cfg)
61
+ full_output_dir = ensure_dir(os.path.join(output_dir, run_slug, experiment_id))
62
+ return full_output_dir, run_slug, experiment_id
63
+
64
+
65
+ def _sort_unique_by_abs_desc(indices: torch.Tensor, values: torch.Tensor) -> torch.Tensor:
66
+ if indices.numel() == 0:
67
+ return indices.to(dtype=torch.long)
68
+ uniq = torch.unique(indices.to(dtype=torch.long))
69
+ abs_vals = values[uniq].abs()
70
+ order = torch.argsort(abs_vals, descending=True)
71
+ return uniq[order]
72
+
73
+
74
+ def _cap_indices(indices: torch.Tensor, max_selected: int | None) -> torch.Tensor:
75
+ if max_selected is None:
76
+ return indices
77
+ if max_selected <= 0:
78
+ return indices[:0]
79
+ return indices[: int(max_selected)]
80
+
81
+
82
+ def get_selected_indices_abs_mass(
83
+ cosine_similarities: torch.Tensor,
84
+ *,
85
+ eta: float = 0.95,
86
+ max_selected: int | None = None,
87
+ ):
88
+ abs_cosine = cosine_similarities.abs()
89
+ selected_indices = torch.empty(0, dtype=torch.long, device=cosine_similarities.device)
90
+ k95 = 0
91
+ if abs_cosine.numel() > 0:
92
+ sorted_abs_values, sorted_abs_indices = torch.sort(abs_cosine, descending=True)
93
+ total_abs_mass = sorted_abs_values.sum()
94
+ if float(total_abs_mass.item()) > 0.0:
95
+ cumsum_abs = torch.cumsum(sorted_abs_values, dim=0)
96
+ threshold = eta * total_abs_mass
97
+ cutoff_candidates = torch.nonzero(cumsum_abs >= threshold, as_tuple=False)
98
+ cutoff = int(cutoff_candidates[0].item()) + 1 if cutoff_candidates.numel() > 0 else sorted_abs_values.numel()
99
+ else:
100
+ cutoff = 0
101
+ selected_indices = sorted_abs_indices[:cutoff]
102
+ k95 = cutoff
103
+ selected_indices = _cap_indices(selected_indices, max_selected=max_selected)
104
+ return selected_indices, {
105
+ "method": "abs_mass",
106
+ "k95": int(k95),
107
+ "eta": float(eta),
108
+ "num_selected": int(selected_indices.numel()),
109
+ }
110
+
111
+
112
+ def get_selected_indices_zscore(
113
+ cosine_similarities: torch.Tensor,
114
+ *,
115
+ tau: float = 2.5,
116
+ max_selected: int | None = None,
117
+ ):
118
+ x = cosine_similarities
119
+ if x.numel() == 0:
120
+ return x.new_empty(0, dtype=torch.long), {"method": "zscore", "tau": float(tau), "num_selected": 0}
121
+ mu = x.mean()
122
+ sigma = x.std(unbiased=False)
123
+ sigma_val = float(sigma.item())
124
+ if sigma_val <= 0.0:
125
+ return x.new_empty(0, dtype=torch.long), {
126
+ "method": "zscore",
127
+ "tau": float(tau),
128
+ "mu": float(mu.item()),
129
+ "sigma": sigma_val,
130
+ "num_selected": 0,
131
+ "num_pos": 0,
132
+ }
133
+
134
+ upper = mu + tau * sigma
135
+ lower = mu - tau * sigma
136
+ pos_idx = torch.nonzero(x >= upper, as_tuple=False).flatten()
137
+ # if zero, select the max positive value
138
+ if pos_idx.numel() == 0:
139
+ pos_idx = torch.argmax(x)
140
+ selected = _sort_unique_by_abs_desc(pos_idx, x)
141
+ selected = _cap_indices(selected, max_selected=max_selected)
142
+ return selected, {
143
+ "method": "zscore",
144
+ "tau": float(tau),
145
+ "mu": float(mu.item()),
146
+ "sigma": sigma_val,
147
+ "threshold_upper": float(upper.item()),
148
+ "threshold_lower": float(lower.item()),
149
+ "num_pos": int(pos_idx.numel()),
150
+ "num_selected": int(selected.numel()),
151
+ }
152
+
153
+
154
+ def get_selected_indices_fdr(
155
+ cosine_similarities: torch.Tensor,
156
+ *,
157
+ q0: float = 0.05,
158
+ max_selected: int | None = None,
159
+ ):
160
+ x = cosine_similarities
161
+ if x.numel() == 0:
162
+ return x.new_empty(0, dtype=torch.long), {"method": "fdr", "q0": float(q0), "num_selected": 0}
163
+
164
+ mu = x.mean()
165
+ sigma = x.std(unbiased=False).clamp_min(1e-12)
166
+ z = (x - mu) / sigma
167
+ # Two-sided p-values under normal null.
168
+ pvals = torch.erfc(z.abs() / math.sqrt(2.0)).clamp_min(1e-12).clamp_max(1.0)
169
+ m = int(pvals.numel())
170
+ sorted_pvals, sorted_idx = torch.sort(pvals, descending=False)
171
+ ranks = torch.arange(1, m + 1, device=x.device, dtype=x.dtype)
172
+ thresholds = (q0 * ranks) / float(m)
173
+ passed = sorted_pvals <= thresholds
174
+ if not bool(passed.any()):
175
+ selected = x.new_empty(0, dtype=torch.long)
176
+ p_cut = None
177
+ else:
178
+ k = int(torch.nonzero(passed, as_tuple=False)[-1].item()) + 1
179
+ p_cut = float(sorted_pvals[k - 1].item())
180
+ selected = sorted_idx[:k]
181
+
182
+ selected = _sort_unique_by_abs_desc(selected, x)
183
+ selected = _cap_indices(selected, max_selected=max_selected)
184
+ return selected, {
185
+ "method": "fdr",
186
+ "q0": float(q0),
187
+ "mu": float(mu.item()),
188
+ "sigma": float(sigma.item()),
189
+ "p_cut": p_cut,
190
+ "num_selected": int(selected.numel()),
191
+ "num_tests": m,
192
+ }
193
+
194
+
195
+ def get_selected_indices_gmm(
196
+ cosine_similarities: torch.Tensor,
197
+ *,
198
+ posterior_threshold: float = 0.8,
199
+ max_selected: int | None = None,
200
+ max_iter: int = 50,
201
+ ):
202
+ x = cosine_similarities
203
+ if x.numel() == 0:
204
+ return x.new_empty(0, dtype=torch.long), {"method": "gmm", "num_selected": 0}
205
+ x1 = x.reshape(-1, 1)
206
+ n = x1.shape[0]
207
+ mu = torch.quantile(x, torch.tensor([0.25, 0.75], device=x.device, dtype=x.dtype)).reshape(2, 1)
208
+ var0 = x.var(unbiased=False).clamp_min(1e-6)
209
+ var = torch.full((2, 1), var0, device=x.device, dtype=x.dtype)
210
+ pi = torch.full((2, 1), 0.5, device=x.device, dtype=x.dtype)
211
+ two_pi = torch.tensor(2.0 * math.pi, device=x.device, dtype=x.dtype)
212
+
213
+ for _ in range(max_iter):
214
+ # E-step
215
+ norm = (1.0 / torch.sqrt(two_pi * var)) * torch.exp(-0.5 * ((x1 - mu) ** 2) / var)
216
+ weighted = pi * norm
217
+ denom = weighted.sum(dim=0, keepdim=True).clamp_min(1e-12)
218
+ resp = weighted / denom
219
+ # M-step
220
+ Nk = resp.sum(dim=1, keepdim=True).clamp_min(1e-12)
221
+ pi = Nk / float(n)
222
+ mu = (resp @ x1) / Nk
223
+ centered = x1.unsqueeze(0) - mu.unsqueeze(1)
224
+ var = ((resp.unsqueeze(2) * (centered**2)).sum(dim=1) / Nk).clamp_min(1e-6)
225
+
226
+ # "Relevant" component: larger absolute mean.
227
+ relevant_idx = int(torch.argmax(mu.abs()).item())
228
+ posterior_relevant = resp[relevant_idx]
229
+ selected = torch.nonzero(posterior_relevant > posterior_threshold, as_tuple=False).flatten()
230
+ selected = _sort_unique_by_abs_desc(selected, x)
231
+ selected = _cap_indices(selected, max_selected=max_selected)
232
+ return selected, {
233
+ "method": "gmm",
234
+ "posterior_threshold": float(posterior_threshold),
235
+ "means": [float(mu[0].item()), float(mu[1].item())],
236
+ "stds": [float(torch.sqrt(var[0]).item()), float(torch.sqrt(var[1]).item())],
237
+ "weights": [float(pi[0].item()), float(pi[1].item())],
238
+ "relevant_component": relevant_idx,
239
+ "num_selected": int(selected.numel()),
240
+ }
241
+
242
+
243
+ def get_selected_indices_sparse_pursuit(
244
+ cosine_similarities: torch.Tensor,
245
+ *,
246
+ target_k: int | None = None,
247
+ l1_lambda: float = 0.0,
248
+ max_selected: int | None = None,
249
+ ):
250
+ x = cosine_similarities
251
+ if x.numel() == 0:
252
+ return x.new_empty(0, dtype=torch.long), {"method": "sparse_pursuit", "num_selected": 0}
253
+
254
+ if target_k is not None and target_k > 0:
255
+ k = min(int(target_k), int(x.numel()))
256
+ selected = torch.topk(x.abs(), k=k).indices
257
+ mode = "topk_abs"
258
+ threshold = float(torch.topk(x.abs(), k=k).values[-1].item()) if k > 0 else 0.0
259
+ else:
260
+ lam = float(l1_lambda)
261
+ selected = torch.nonzero(x.abs() >= lam, as_tuple=False).flatten()
262
+ mode = "l1_threshold"
263
+ threshold = lam
264
+
265
+ selected = _sort_unique_by_abs_desc(selected, x)
266
+ selected = _cap_indices(selected, max_selected=max_selected)
267
+ return selected, {
268
+ "method": "sparse_pursuit",
269
+ "mode": mode,
270
+ "threshold": threshold,
271
+ "target_k": None if target_k is None else int(target_k),
272
+ "l1_lambda": float(l1_lambda),
273
+ "num_selected": int(selected.numel()),
274
+ }
275
+
276
+
277
+ def get_selected_indices_quantile(
278
+ cosine_similarities: torch.Tensor,
279
+ *,
280
+ q: float = 0.05,
281
+ positive_only: bool = True,
282
+ max_selected: int | None = None,
283
+ ):
284
+ x = cosine_similarities
285
+ if x.numel() == 0:
286
+ return x.new_empty(0, dtype=torch.long), {"method": "quantile", "q": float(q), "num_selected": 0}
287
+
288
+ q = float(q)
289
+ if not (0.0 < q < 1.0):
290
+ raise ValueError(f"quantile selection expects q in (0, 1), got {q}")
291
+
292
+ if positive_only:
293
+ pool = x[x > 0]
294
+ if pool.numel() == 0:
295
+ return x.new_empty(0, dtype=torch.long), {
296
+ "method": "quantile",
297
+ "q": q,
298
+ "positive_only": True,
299
+ "threshold": None,
300
+ "num_selected": 0,
301
+ }
302
+ threshold = torch.quantile(pool, 1.0 - q)
303
+ selected = torch.nonzero(x >= threshold, as_tuple=False).flatten()
304
+ selected = selected[x[selected] > 0]
305
+ else:
306
+ threshold = torch.quantile(x.abs(), 1.0 - q)
307
+ selected = torch.nonzero(x.abs() >= threshold, as_tuple=False).flatten()
308
+
309
+ selected = _sort_unique_by_abs_desc(selected, x)
310
+ selected = _cap_indices(selected, max_selected=max_selected)
311
+ return selected, {
312
+ "method": "quantile",
313
+ "q": q,
314
+ "positive_only": bool(positive_only),
315
+ "threshold": float(threshold.item()) if threshold is not None else None,
316
+ "num_selected": int(selected.numel()),
317
+ }
318
+
319
+
320
+ def get_selected_indices(
321
+ cosine_similarities: torch.Tensor,
322
+ *,
323
+ method: str = "abs_mass",
324
+ **kwargs,
325
+ ):
326
+ method_key = str(method).lower()
327
+ if method_key in {"abs_mass", "cosine_mass", "k95"}:
328
+ return get_selected_indices_abs_mass(cosine_similarities, **kwargs)
329
+ if method_key in {"zscore", "z_score"}:
330
+ return get_selected_indices_zscore(cosine_similarities, **kwargs)
331
+ if method_key in {"fdr", "bh"}:
332
+ return get_selected_indices_fdr(cosine_similarities, **kwargs)
333
+ if method_key in {"gmm", "mixture"}:
334
+ return get_selected_indices_gmm(cosine_similarities, **kwargs)
335
+ if method_key in {"sparse_pursuit", "lasso", "topk"}:
336
+ return get_selected_indices_sparse_pursuit(cosine_similarities, **kwargs)
337
+ if method_key in {"quantile", "q"}:
338
+ return get_selected_indices_quantile(cosine_similarities, **kwargs)
339
+ raise ValueError(f"Unknown selection method '{method}'.")
340
+
341
+
342
+ def attention(q, k, v):
343
+ return torch.nn.functional.softmax(q @ k.T / math.sqrt(q.shape[-1]), dim=-1) @ v
344
+
345
+
346
+ def _attention_over_neurons(
347
+ query: torch.Tensor,
348
+ keys: torch.Tensor,
349
+ tau: float = 0.07,
350
+ normalize: bool = True,
351
+ ) -> torch.Tensor:
352
+ """
353
+ Attention over neuron prototypes.
354
+
355
+ Args
356
+ ----
357
+ query: [1, d] or [d]
358
+ keys: [K, d]
359
+ tau: temperature (smaller => peakier)
360
+ normalize: if True, use cosine logits via L2-normalization
361
+
362
+ Returns
363
+ -------
364
+ alpha: [K] attention weights over neurons
365
+ """
366
+ if query.dim() == 1:
367
+ query = query.unsqueeze(0) # [1, d]
368
+ assert query.dim() == 2 and keys.dim() == 2, (query.shape, keys.shape)
369
+ assert query.shape[-1] == keys.shape[-1], (query.shape, keys.shape)
370
+
371
+ if normalize:
372
+ q = F.normalize(query, dim=-1) # [1, d]
373
+ k = F.normalize(keys, dim=-1) # [K, d]
374
+ logits = (q @ k.t()).squeeze(0) # [K], cosine logits
375
+ else:
376
+ # scaled dot-product in raw space
377
+ logits = (query @ keys.t()).squeeze(0) / math.sqrt(query.shape[-1]) # [K]
378
+
379
+ alpha = F.softmax(logits / tau, dim=-1) # [K]
380
+ return alpha
381
+
382
+
383
+ def _raw_cosine_for_concept(
384
+ *,
385
+ model,
386
+ concept: str,
387
+ device: torch.device,
388
+ text_encoder=None,
389
+ basis_chunk_size: int = 256,
390
+ attention: bool = False,
391
+ attention_tau: float = 0.07,
392
+ attention_normalize: bool = True,
393
+ attention_score_mode: str = "alpha",
394
+ ):
395
+ """Compute raw cosine-like scores for one concept with optional attention reweighting."""
396
+ encoder = text_encoder if text_encoder is not None else getattr(model, "text_encoder", None)
397
+ if encoder is None:
398
+ raise ValueError("A text encoder must be provided when model.text_encoder is not available.")
399
+
400
+ text_embedding = encoder([concept]) # [1, d]
401
+ sae_text_out = model.sae_encoder(text_embedding)
402
+ if isinstance(sae_text_out, (tuple, list)):
403
+ sae_text_activations = sae_text_out[1] if len(sae_text_out) > 1 else sae_text_out[0]
404
+ else:
405
+ sae_text_activations = sae_text_out
406
+
407
+ dict_size = int(model.sae_decoder.W_dec.shape[0])
408
+ if basis_chunk_size <= 0:
409
+ basis_chunk_size = dict_size
410
+
411
+ basis = []
412
+ text_vec = text_embedding
413
+ for start in range(0, dict_size, basis_chunk_size):
414
+ end = min(start + basis_chunk_size, dict_size)
415
+ chunk = end - start
416
+ eye_chunk = torch.zeros((chunk, dict_size), device=device, dtype=text_vec.dtype)
417
+ eye_chunk[torch.arange(chunk, device=device), torch.arange(start, end, device=device)] = 1
418
+ basis_chunk = model.sae_decoder(eye_chunk)
419
+ basis.append(basis_chunk)
420
+ basis = torch.cat(basis, dim=0) # [K, d]
421
+
422
+ if attention:
423
+ alpha = _attention_over_neurons(
424
+ query=text_vec, keys=basis, tau=attention_tau, normalize=attention_normalize
425
+ )
426
+ cosine_similarities = F.cosine_similarity(text_vec.expand_as(basis), basis, dim=-1)
427
+ if attention_score_mode == "alpha":
428
+ cosine_similarities = alpha
429
+ elif attention_score_mode == "alpha_cos":
430
+ cosine_similarities = alpha * cosine_similarities
431
+ elif attention_score_mode == "cos":
432
+ pass
433
+ else:
434
+ raise ValueError(
435
+ f"Unknown attention_score_mode={attention_score_mode}. "
436
+ "Use one of {'alpha','alpha_cos','cos'}."
437
+ )
438
+ else:
439
+ cosine_similarities = F.cosine_similarity(text_vec.expand_as(basis), basis, dim=-1)
440
+
441
+ return cosine_similarities, sae_text_activations
442
+
443
+
444
+ def _cosine_probe_vector_and_stats(
445
+ *,
446
+ model,
447
+ concept: str,
448
+ concept_index: int,
449
+ device: torch.device,
450
+ selection_method: str = "abs_mass",
451
+ selection_kwargs: Dict[str, Any] | None = None,
452
+ basis_chunk_size: int = 256,
453
+ attention: bool = False,
454
+ attention_tau: float = 0.07,
455
+ attention_normalize: bool = True,
456
+ attention_score_mode: str = "alpha", # {"alpha", "alpha_cos", "cos"}
457
+ ):
458
+ del concept_index # Kept for extractor signature consistency.
459
+ cosine_similarities, sae_text_activations = _raw_cosine_for_concept(
460
+ model=model,
461
+ concept=concept,
462
+ device=device,
463
+ basis_chunk_size=basis_chunk_size,
464
+ attention=attention,
465
+ attention_tau=attention_tau,
466
+ attention_normalize=attention_normalize,
467
+ attention_score_mode=attention_score_mode,
468
+ )
469
+
470
+ # --- Selection (unchanged) ---
471
+ selection_kwargs = dict(selection_kwargs or {})
472
+
473
+ selected_indices, selection_stats = get_selected_indices(
474
+ cosine_similarities,
475
+ method=selection_method,
476
+ **selection_kwargs,
477
+ )
478
+
479
+ mask = torch.zeros_like(cosine_similarities)
480
+ if selected_indices.numel() > 0:
481
+ mask[selected_indices] = 1
482
+ masked_cosine = cosine_similarities * mask
483
+
484
+ probs = torch.softmax(cosine_similarities.abs(), dim=0)
485
+ entropy = float((-(probs * torch.log(probs.clamp_min(1e-12))).sum()).item())
486
+ stats = dict(selection_stats)
487
+ stats["entropy"] = entropy
488
+
489
+ return {
490
+ "raw_cosine_similarities": cosine_similarities,
491
+ "masked_cosine": masked_cosine,
492
+ "sae_text_activations": sae_text_activations,
493
+ "stats": stats,
494
+ }
495
+
496
+
497
+ @torch.no_grad()
498
+ def cosine_with_tfidf(
499
+ *,
500
+ model,
501
+ concepts: list[str],
502
+ device: torch.device,
503
+ tau: float = 4.0,
504
+ basis_chunk_size: int = 256,
505
+ attention: bool = False,
506
+ attention_tau: float = 0.07,
507
+ attention_normalize: bool = True,
508
+ attention_score_mode: str = "alpha",
509
+ ):
510
+ """
511
+ Two-pass cosine extraction with global TF-IDF neuron reweighting.
512
+
513
+ Steps:
514
+ 1) raw cosine per concept
515
+ 2) z-score mask on raw cosine
516
+ 3) compute global neuron TF-IDF weights from first-pass masks
517
+ 4) apply weights to raw cosine
518
+ 5) z-score mask again on weighted cosine
519
+ """
520
+ if not concepts:
521
+ return {}
522
+
523
+ raw_by_concept: Dict[str, torch.Tensor] = {}
524
+ sae_by_concept: Dict[str, torch.Tensor] = {}
525
+ first_mask_by_concept: Dict[str, torch.Tensor] = {}
526
+
527
+ # Pass 1: raw cosine + first z-score mask
528
+ for concept in concepts:
529
+ raw_cos, sae_text_acts = _raw_cosine_for_concept(
530
+ model=model,
531
+ concept=concept,
532
+ device=device,
533
+ basis_chunk_size=basis_chunk_size,
534
+ attention=attention,
535
+ attention_tau=attention_tau,
536
+ attention_normalize=attention_normalize,
537
+ attention_score_mode=attention_score_mode,
538
+ )
539
+ idx1, stats1 = get_selected_indices_zscore(raw_cos, tau=tau)
540
+ mask1 = torch.zeros_like(raw_cos)
541
+ if idx1.numel() > 0:
542
+ mask1[idx1] = 1
543
+ raw_by_concept[concept] = raw_cos
544
+ sae_by_concept[concept] = sae_text_acts
545
+ first_mask_by_concept[concept] = mask1
546
+
547
+ # Global TF-IDF weights over neurons from first-pass masks.
548
+ first_mask_matrix = torch.stack([first_mask_by_concept[c] for c in concepts], dim=0) # [C, K]
549
+ raw_matrix = torch.stack([raw_by_concept[c] for c in concepts], dim=0) # [C, K]
550
+ n_concepts = first_mask_matrix.shape[0]
551
+ doc_freq = first_mask_matrix.sum(dim=0) # [K]
552
+ tf = (raw_matrix.abs() * first_mask_matrix).sum(dim=0) / max(n_concepts, 1) # [K]
553
+ idf = torch.log((n_concepts + 1.0) / (doc_freq + 1.0)) + 1.0 # [K]
554
+ neuron_weights = tf * idf
555
+ # Normalize to keep scales comparable across runs.
556
+ neuron_weights = neuron_weights / neuron_weights.mean().clamp_min(1e-12)
557
+
558
+ out = {}
559
+ # Pass 2: reweight raw cosine + second z-score mask
560
+ for concept in concepts:
561
+ raw_cos = raw_by_concept[concept]
562
+ weighted_cos = raw_cos * neuron_weights
563
+ idx2, stats2 = get_selected_indices_zscore(weighted_cos, tau=tau)
564
+ mask2 = torch.zeros_like(weighted_cos)
565
+ if idx2.numel() > 0:
566
+ mask2[idx2] = 1
567
+ masked_weighted = weighted_cos * mask2
568
+
569
+ probs = torch.softmax(weighted_cos.abs(), dim=0)
570
+ entropy = float((-(probs * torch.log(probs.clamp_min(1e-12))).sum()).item())
571
+ stats = dict(stats2)
572
+ stats["method"] = "cosine_with_tfidf"
573
+ stats["entropy"] = entropy
574
+ stats["tau"] = float(tau)
575
+ stats["tfidf_weight_mean"] = float(neuron_weights.mean().item())
576
+ stats["tfidf_weight_max"] = float(neuron_weights.max().item())
577
+ stats["initial_num_selected"] = int(first_mask_by_concept[concept].sum().item())
578
+
579
+ out[concept] = {
580
+ # Save weighted cosine as the primary "raw_cosine_similarities" output
581
+ # for downstream compatibility with existing plotting scripts.
582
+ "raw_cosine_similarities": weighted_cos,
583
+ # Keep original pre-TF-IDF cosine values for debugging/ablation.
584
+ "original_raw_cosine_similarities": raw_cos,
585
+ "weighted_cosine_similarities": weighted_cos,
586
+ "masked_cosine": masked_weighted,
587
+ "sae_text_activations": sae_by_concept[concept],
588
+ "stats": stats,
589
+ }
590
+
591
+ return out
592
+
593
+ def build_concept_extractor(extractor_cfg: DictConfig):
594
+ """Resolve extractor callable from config and apply partial kwargs."""
595
+ if extractor_cfg is None:
596
+ raise ValueError("Missing concepts_experiment extractor config.")
597
+
598
+ target = extractor_cfg.get("target")
599
+ if target is None:
600
+ # Backward compatibility with older config format.
601
+ target = extractor_cfg.get("_extract_concept_distribution")
602
+ if not target:
603
+ raise ValueError(
604
+ "Extractor target missing. Set concepts_experiment.extractor.target "
605
+ "(or legacy methods._extract_concept_distribution)."
606
+ )
607
+
608
+ module_name, fn_name = str(target).rsplit(".", 1)
609
+ fn = getattr(importlib.import_module(module_name), fn_name)
610
+ if not callable(fn):
611
+ raise TypeError(f"Resolved extractor is not callable: {target}")
612
+
613
+ params_cfg = extractor_cfg.get("params", {})
614
+ params = dict(params_cfg) if params_cfg is not None else {}
615
+ return partial(fn, **params)
616
+
617
+
618
+
619
+ def _assert_checkpoint_weights_loaded(model) -> None:
620
+ report = getattr(model, "_checkpoint_load_report", None)
621
+ if report is None:
622
+ raise RuntimeError(
623
+ "Checkpoint load report missing on model. "
624
+ "Ensure model is created via instantiate_model_and_load()."
625
+ )
626
+ if int(report.get("matched_keys", 0)) <= 0:
627
+ raise RuntimeError("Checkpoint weights do not match model parameters (0 matched keys).")
628
+ log.info(
629
+ "Checkpoint load verification passed: "
630
+ f"matched={report['matched_keys']}/{report['model_keys']}, "
631
+ f"missing={report['missing_keys']}, unexpected={report['unexpected_keys']}."
632
+ )
633
+
634
+
635
+ def concept_from_bridge_score(
636
+ *,
637
+ model,
638
+ concept: str,
639
+ concept_index: int,
640
+ device: torch.device,
641
+ bridge_checkpoint_path: str = None,
642
+ bridge_checkpoint_key: str = "bridges",
643
+ dataloader=None,
644
+ ) -> torch.Tensor:
645
+ del concept_index # kept for a stable extractor callable signature
646
+
647
+ def _extract_bridge_from_checkpoint(path: str, key: str):
648
+ checkpoint = load_state_dict_any(path, map_location="cpu")
649
+ state_dict = checkpoint.get("state_dict", checkpoint) if isinstance(checkpoint, dict) else checkpoint
650
+ if not isinstance(state_dict, dict):
651
+ return None
652
+
653
+ bridge_obj = state_dict.get(key)
654
+ if bridge_obj is None:
655
+ return None
656
+
657
+ if isinstance(bridge_obj, torch.Tensor):
658
+ return bridge_obj
659
+
660
+ if isinstance(bridge_obj, dict):
661
+ bridge_tensors = []
662
+ for _, val in sorted(bridge_obj.items(), key=lambda kv: str(kv[0])):
663
+ # New structure: bridges[dataset_name] = {"align": ..., "coact": ..., "B": ...}
664
+ if isinstance(val, dict):
665
+ b_val = val.get("B")
666
+ if isinstance(b_val, torch.Tensor):
667
+ bridge_tensors.append(b_val)
668
+ # Fallback: dict values are directly bridge tensors.
669
+ elif isinstance(val, torch.Tensor):
670
+ bridge_tensors.append(val)
671
+ if bridge_tensors:
672
+ if len(bridge_tensors) == 1:
673
+ return bridge_tensors[0]
674
+ return torch.stack(bridge_tensors, dim=0).mean(dim=0)
675
+ return None
676
+
677
+ @torch.no_grad()
678
+ def _compute_bridge_from_dataloader(dataloader_obj) -> torch.Tensor:
679
+ za_chunks = []
680
+ zt_chunks = []
681
+ for batch in dataloader_obj:
682
+ audio = batch.get("audio") if isinstance(batch, dict) else None
683
+ text = batch.get("prompt") if isinstance(batch, dict) else None
684
+ if audio is None or text is None:
685
+ continue
686
+
687
+ audio = audio.to(device)
688
+ encoded_audio = (
689
+ model.audio_encoder(audio) if not getattr(model, "preextracted_features", False) else audio
690
+ )
691
+ encoded_text = model.text_encoder(text)
692
+ _, z_audio, _, _ = model(encoded_audio)
693
+ _, z_text, _, _ = model(encoded_text)
694
+ n = min(z_audio.shape[0], z_text.shape[0])
695
+ if n <= 0:
696
+ continue
697
+ za_chunks.append(z_audio[:n].detach())
698
+ zt_chunks.append(z_text[:n].detach())
699
+
700
+ if not za_chunks or not zt_chunks:
701
+ raise ValueError(
702
+ "Could not compute bridge from dataloader: no batches contained both audio and prompt."
703
+ )
704
+
705
+ za = torch.cat(za_chunks, dim=0)
706
+ zt = torch.cat(zt_chunks, dim=0)
707
+ w_dec = get_dictionary_from_lightningsae(model).to(device)
708
+ return BridgeScoreCallback._compute(za, zt, w_dec)["B"]
709
+
710
+ bridge_B = None
711
+ if bridge_checkpoint_path:
712
+ bridge_B = _extract_bridge_from_checkpoint(bridge_checkpoint_path, bridge_checkpoint_key)
713
+
714
+ if bridge_B is None:
715
+ if dataloader is None:
716
+ raise ValueError(
717
+ "bridges not found in checkpoint (or checkpoint not provided). "
718
+ "Provide dataloader to compute bridge scores on the fly."
719
+ )
720
+ bridge_B = _compute_bridge_from_dataloader(dataloader)
721
+
722
+ bridge_B = bridge_B.to(device)
723
+ text_embedding = model.text_encoder([concept])
724
+ activations = model.sae_encoder(text_embedding)
725
+ bridged_activations = bridge_B @ activations
726
+ return bridged_activations.detach().cpu()
727
+
728
+
729
+ def run_extract_experiment(
730
+ *,
731
+ cfg: DictConfig,
732
+ model,
733
+ device: torch.device,
734
+ output_dir: str,
735
+ source_run_id: str,
736
+ ckpt_path: str,
737
+ ) -> Dict[str, Any]:
738
+
739
+
740
+ extractor_cfg = cfg.concepts_experiment.get("extractor")
741
+ if extractor_cfg is None:
742
+ extractor_cfg = cfg.concepts_experiment.get("methods", {})
743
+
744
+
745
+ extractor = build_concept_extractor(extractor_cfg)
746
+ extractor_target = str(extractor_cfg.get("target", ""))
747
+ concepts = read_vocab_lines(str(cfg.concepts.vocab_path))
748
+ _assert_checkpoint_weights_loaded(model)
749
+
750
+ out_ = {}
751
+
752
+ if extractor_target.endswith("cosine_with_tfidf"):
753
+ # Batch method needs all concepts to compute global neuron TF-IDF weights.
754
+ tfidf_results = extractor(
755
+ model=model,
756
+ concepts=concepts,
757
+ device=device,
758
+ )
759
+ for concept in concepts:
760
+ results = tfidf_results[concept]
761
+ masked_cosine = results["masked_cosine"].detach().cpu()
762
+ raw_cosine = results["raw_cosine_similarities"].detach().cpu()
763
+ weighted_cosine = results.get("weighted_cosine_similarities")
764
+ if isinstance(weighted_cosine, torch.Tensor):
765
+ weighted_cosine = weighted_cosine.detach().cpu()
766
+ sae_acts = results["sae_text_activations"]
767
+ if isinstance(sae_acts, torch.Tensor):
768
+ sae_acts = sae_acts.detach().cpu()
769
+ out_[concept] = {
770
+ "masked_cosine": masked_cosine,
771
+ "stats": results["stats"],
772
+ "raw_cosine_similarities": raw_cosine,
773
+ "weighted_cosine_similarities": weighted_cosine,
774
+ "sae_text_activations": sae_acts,
775
+ }
776
+ else:
777
+ for idx, concept in enumerate(concepts):
778
+ results = extractor(
779
+ model=model,
780
+ concept=concept,
781
+ concept_index=idx,
782
+ device=device,
783
+ )
784
+ masked_cosine = results["masked_cosine"].detach().cpu()
785
+ raw_cosine = results["raw_cosine_similarities"].detach().cpu()
786
+ sae_acts = results["sae_text_activations"]
787
+ if isinstance(sae_acts, torch.Tensor):
788
+ sae_acts = sae_acts.detach().cpu()
789
+ out_[concept] = {
790
+ "masked_cosine": masked_cosine,
791
+ "stats": results["stats"],
792
+ "raw_cosine_similarities": raw_cosine,
793
+ "sae_text_activations": sae_acts,
794
+ }
795
+
796
+ # Print a compact activation summary for quick inspection.
797
+ # "Active" means non-zero entries in the extracted activation tensor.
798
+ width = 40
799
+ topk_width = 20
800
+ summary_rows = []
801
+ total_active = 0
802
+ total_values = 0
803
+ global_max = None
804
+ for concept, values in out_.items():
805
+ tensor = values['masked_cosine'].detach().cpu()
806
+ if tensor.numel() == 0:
807
+ active = 0
808
+ size = 0
809
+ frac = 0.0
810
+ max_val = 0.0
811
+ topk_pairs = []
812
+ else:
813
+ active = int((tensor != 0).sum().item())
814
+ size = int(tensor.numel())
815
+ frac = active / max(size, 1)
816
+ max_val = float(tensor.max().item())
817
+ flat = tensor.flatten()
818
+ k = min(5, flat.numel())
819
+ top_vals = torch.topk(flat, k=k).values
820
+ denom = max(abs(max_val), 1e-12)
821
+ topk_pairs = []
822
+ for v in top_vals:
823
+ v_float = float(v.item())
824
+ rel = max(0.0, min(1.0, v_float / denom))
825
+ filled_top = int(round(rel * topk_width))
826
+ top_bar = "#" * filled_top + "-" * (topk_width - filled_top)
827
+ topk_pairs.append((v_float, top_bar))
828
+ if global_max is None:
829
+ global_max = max_val
830
+ else:
831
+ global_max = max(global_max, max_val)
832
+ total_active += active
833
+ total_values += size
834
+ filled = int(round(frac * width))
835
+ bar = "#" * filled + "-" * (width - filled)
836
+ summary_rows.append((concept, active, size, frac, max_val, bar, topk_pairs))
837
+
838
+ log.info("Activation summary per concept (active/total | active_frac | max):")
839
+ for concept, active, size, frac, max_val, bar, topk_pairs in summary_rows:
840
+ log.info(
841
+ f"- {concept:>24s} | {active:>7d}/{size:<7d} | {frac:6.2%} | "
842
+ f"max={max_val:>9.4f} | [{bar}]"
843
+ )
844
+ if topk_pairs:
845
+ topk_str = " | ".join([f"{val:9.4f} [{top_bar}]" for val, top_bar in topk_pairs])
846
+ log.info(f" top5: {topk_str}")
847
+ else:
848
+ log.info(" top5: n/a")
849
+ global_frac = (total_active / total_values) if total_values > 0 else 0.0
850
+ if global_max is None:
851
+ global_max = 0.0
852
+ log.info(
853
+ "Activation summary total: "
854
+ f"{total_active}/{total_values} active ({global_frac:.2%}), global max={global_max:.4f}."
855
+ )
856
+ if out_:
857
+ for concept, values in out_.items():
858
+ stats = values['stats']
859
+ k95 = int(stats.get("k95", 0))
860
+ entropy = float(stats.get("entropy", 0.0))
861
+ log.info(
862
+ f"{concept:24s} | {k95:4d} | {entropy:9.4f}"
863
+ )
864
+
865
+
866
+ # Resolve output directory: <output_dir>/<run_id>/<experiment_id>
867
+ output_dir, run_id_slug, experiment_id = _resolve_output_dir(cfg, output_dir, source_run_id)
868
+
869
+ # Save activations under concepts.pt
870
+ activations_path = os.path.join(output_dir, "concepts.pt")
871
+ torch.save(out_, activations_path)
872
+ log.info(f"Saved concept activation dictionary to {activations_path}")
873
+
874
+ # Save config
875
+ config_path = os.path.join(output_dir, "config.yaml")
876
+ with open(config_path, "w") as f:
877
+ OmegaConf.save(cfg, f)
878
+
879
+
880
+ log.info(f"Saved concept activation dictionary to {activations_path}")
881
+
882
+ out_dict = {
883
+ "experiment": "concept_isolation",
884
+ "concepts_experiment": str(cfg.concepts_experiment.name),
885
+ "config_source": source_run_id,
886
+ "run_id": run_id_slug,
887
+ "experiment_id": experiment_id,
888
+ "checkpoint_path": ckpt_path,
889
+ "num_concepts": len(concepts),
890
+ "concept_activations_path": activations_path,
891
+ }
892
+
893
+ # Save JSON summary output
894
+ json_output_path = os.path.join(output_dir, "output.json")
895
+ import json
896
+ with open(json_output_path, "w") as f:
897
+ json.dump(out_dict, f)
898
+
899
+ return out_dict
steerable_retrieval/experiments/concepts/get_concepts.py ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Build a concept library for MuQ (MuQ-MuLan) from caption n-grams + groundedness on precomputed audio embeddings.
4
+
5
+ Assumptions:
6
+ - You have a local folder of ~5k audio embedding .npy files (each is [D] or [1,D] or [T,D] -> we pool to [D]).
7
+ - You have a CSV on S3 with a column 'caption' containing text captions.
8
+ - You have access to MuQMuLan via `from muq_mulan import MuQMuLan` (adjust import to your project).
9
+
10
+ What it does:
11
+ 1) Load captions from s3://... CSV.
12
+ 2) Extract 1–3-gram candidates (stopword filtered + min docfreq).
13
+ 3) Embed all candidate phrases with MuQ text tower.
14
+ 4) Compute groundedness tail score against your audio embedding set.
15
+ 5) Select a diverse set via greedy MMR (optional).
16
+ 6) Save CSV/JSONL with concepts + stats.
17
+
18
+ Example:
19
+ python build_concept_library.py \
20
+ --audio_dir /path/to/muq_audio_npys \
21
+ --captions_s3 s3://my-bucket/path/captions.csv \
22
+ --out_csv concepts.csv \
23
+ --max_candidates 5000 \
24
+ --final_k 250 \
25
+ --device cuda
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import csv
32
+ import glob
33
+ import json
34
+ import math
35
+ import os
36
+ import re
37
+ import warnings
38
+ from dataclasses import dataclass
39
+ from typing import Dict, Iterable, List, Optional, Tuple
40
+
41
+ import numpy as np
42
+ import torch
43
+
44
+ # -----------------------------
45
+ # Text encoder (given)
46
+ # -----------------------------
47
+ # IMPORTANT: adjust imports to your codebase.
48
+ # from openmuq import MuQMuLan # <-- likely incorrect; replace with your actual import
49
+ from steerable_retrieval.models.encoders.muq import MuQTextEncoder
50
+
51
+ # -----------------------------
52
+ # S3 CSV loader
53
+ # -----------------------------
54
+ def read_captions_from_s3_csv(s3_uri: str, caption_col: str = "caption", max_rows: Optional[int] = None) -> List[str]:
55
+ """
56
+ Read captions from an S3 CSV using boto3 streaming.
57
+ Requires AWS credentials in environment / config.
58
+
59
+ s3_uri: s3://bucket/key.csv
60
+ """
61
+ import boto3
62
+ from botocore.config import Config
63
+
64
+ m = re.match(r"^s3://([^/]+)/(.+)$", s3_uri)
65
+ if not m:
66
+ raise ValueError(f"Invalid s3 uri: {s3_uri}")
67
+ bucket, key = m.group(1), m.group(2)
68
+
69
+ s3 = boto3.client("s3", config=Config(signature_version="s3v4"))
70
+ obj = s3.get_object(Bucket=bucket, Key=key)
71
+ body = obj["Body"]
72
+
73
+ # Stream decode bytes -> text lines
74
+ lines = (line.decode("utf-8", errors="replace") for line in body.iter_lines())
75
+
76
+ reader = csv.DictReader(lines)
77
+ caps: List[str] = []
78
+ for i, row in enumerate(reader):
79
+ if caption_col not in row:
80
+ raise KeyError(f"CSV missing column '{caption_col}'. Columns: {list(row.keys())}")
81
+ cap = (row[caption_col] or "").strip()
82
+ if cap:
83
+ caps.append(cap)
84
+ if max_rows is not None and (i + 1) >= max_rows:
85
+ break
86
+ return caps
87
+
88
+
89
+ # -----------------------------
90
+ # Caption n-gram mining
91
+ # -----------------------------
92
+ _DEFAULT_STOPWORDS = set(
93
+ """
94
+ a an the and or but if while with without within into onto of for in on at by from as
95
+ is are was were be been being have has had do does did can could may might will would
96
+ this that these those it its it's i you we they them our your their
97
+ music song track sound sounds audio instrumental vocals voice listen listening
98
+ featuring feat ft version remix edit mix original
99
+ genre genres follows follow under includes include including characterized
100
+ scenario scenarios scene scenes depicting suitable settings overall
101
+ creates creating evokes around approximately set one main piece
102
+ tempo bpm key signature time
103
+ 0 1 2 3 4 5 6 7 8 9
104
+ """.split()
105
+ )
106
+
107
+ _TOKEN_RE = re.compile(r"[a-z0-9]+(?:'[a-z0-9]+)?", re.IGNORECASE)
108
+
109
+
110
+ def _get_merged_stopwords() -> set:
111
+ """Return built-in stopwords merged with NLTK English stopwords when available."""
112
+ stopwords = set(_DEFAULT_STOPWORDS)
113
+ try:
114
+ from nltk.corpus import stopwords as nltk_stopwords # type: ignore
115
+ try:
116
+ stopwords.update(nltk_stopwords.words("english"))
117
+ except LookupError:
118
+ import nltk # type: ignore
119
+
120
+ nltk.download("stopwords", quiet=True)
121
+ stopwords.update(nltk_stopwords.words("english"))
122
+ except Exception as exc:
123
+ warnings.warn(f"NLTK stopwords unavailable, using built-in stopwords only: {exc}")
124
+ return stopwords
125
+
126
+
127
+ def normalize_text(s: str) -> str:
128
+ s = s.lower()
129
+ s = re.sub(r"\s+", " ", s).strip()
130
+ return s
131
+
132
+
133
+ def tokenize(s: str) -> List[str]:
134
+ return _TOKEN_RE.findall(s.lower())
135
+
136
+
137
+ def extract_ngrams(tokens: List[str], n_min: int = 1, n_max: int = 3) -> List[str]:
138
+ out: List[str] = []
139
+ L = len(tokens)
140
+ for n in range(n_min, n_max + 1):
141
+ for i in range(0, L - n + 1):
142
+ out.append(" ".join(tokens[i : i + n]))
143
+ return out
144
+
145
+
146
+ def mine_ngram_candidates(
147
+ captions: List[str],
148
+ n_min: int = 1,
149
+ n_max: int = 3,
150
+ stopwords: Optional[set] = None,
151
+ min_df: int = 5,
152
+ max_df_frac: float = 0.30,
153
+ max_candidates: int = 5000,
154
+ ) -> List[Tuple[str, int]]:
155
+ """
156
+ Returns list of (ngram, df) sorted by df desc, then length desc.
157
+ Uses document frequency: presence in a caption at least once.
158
+ """
159
+ if stopwords is None:
160
+ stopwords = _get_merged_stopwords()
161
+
162
+ df: Dict[str, int] = {}
163
+ N = len(captions)
164
+
165
+ for cap in captions:
166
+ toks = [t for t in tokenize(cap) if t not in stopwords]
167
+ if not toks:
168
+ continue
169
+ grams = set(extract_ngrams(toks, n_min, n_max))
170
+ for g in grams:
171
+ df[g] = df.get(g, 0) + 1
172
+
173
+ # filter by df
174
+ max_df = int(max_df_frac * N)
175
+ items = [(g, c) for g, c in df.items() if c >= min_df and c <= max_df]
176
+
177
+ # sort by df, then prefer longer phrases
178
+ items.sort(key=lambda x: (x[1], len(x[0].split())), reverse=True)
179
+
180
+ if len(items) > max_candidates:
181
+ items = items[:max_candidates]
182
+ return items
183
+
184
+
185
+ # -----------------------------
186
+ # Audio embedding loading
187
+ # -----------------------------
188
+ def load_audio_embeddings(audio_dir: str, max_files: Optional[int] = None) -> np.ndarray:
189
+ """
190
+ Loads .npy files, returns array [N, D].
191
+ Pools if embedding has shape [T, D] by mean over T.
192
+ """
193
+ paths = sorted(glob.glob(os.path.join(audio_dir, "*.npy")))
194
+ if not paths:
195
+ raise FileNotFoundError(f"No .npy files found in {audio_dir}")
196
+ if max_files is not None:
197
+ paths = paths[:max_files]
198
+
199
+ embs: List[np.ndarray] = []
200
+ D: Optional[int] = None
201
+ for p in paths:
202
+ x = np.load(p)
203
+ x = np.asarray(x)
204
+ if x.ndim == 2:
205
+ # [1, D] or [T, D]
206
+ if x.shape[0] == 1:
207
+ x = x[0]
208
+ else:
209
+ x = x.mean(axis=0)
210
+ elif x.ndim == 1:
211
+ pass
212
+ else:
213
+ raise ValueError(f"Unexpected embedding shape {x.shape} in {p}")
214
+
215
+ if D is None:
216
+ D = int(x.shape[0])
217
+ elif int(x.shape[0]) != D:
218
+ raise ValueError(f"Dimension mismatch: got {x.shape[0]} vs expected {D} in {p}")
219
+
220
+ embs.append(x.astype(np.float32))
221
+
222
+ A = np.stack(embs, axis=0) # [N, D]
223
+ return A
224
+
225
+
226
+ def l2_normalize_np(x: np.ndarray, eps: float = 1e-8) -> np.ndarray:
227
+ n = np.linalg.norm(x, axis=-1, keepdims=True)
228
+ return x / (n + eps)
229
+
230
+
231
+ # -----------------------------
232
+ # Groundedness scoring
233
+ # -----------------------------
234
+ @dataclass
235
+ class GroundScore:
236
+ concept: str
237
+ df: int
238
+ mean: float
239
+ topq_mean: float
240
+ tail_gap: float
241
+ gini: float
242
+ pos_mass: float # fraction of sims > 0
243
+
244
+
245
+ def gini_coefficient(x: np.ndarray) -> float:
246
+ """
247
+ Gini for nonnegative values; for similarities, we shift to nonnegative.
248
+ """
249
+ x = np.asarray(x, dtype=np.float64)
250
+ if x.size == 0:
251
+ return 0.0
252
+ if np.allclose(x, 0):
253
+ return 0.0
254
+ x = np.sort(x)
255
+ n = x.size
256
+ cum = np.cumsum(x)
257
+ # Gini = 1 - 2 * sum_i (cum_i) / (n * cum_n) + 1/n
258
+ return float(1.0 - (2.0 * np.sum(cum) / (n * cum[-1] + 1e-12)) + (1.0 / n))
259
+
260
+
261
+ @torch.no_grad()
262
+ def embed_text_batch(
263
+ enc: MuQTextEncoder, texts: List[str], batch_size: int = 64, device: str = "cuda"
264
+ ) -> np.ndarray:
265
+ feats: List[np.ndarray] = []
266
+ for i in range(0, len(texts), batch_size):
267
+ batch = texts[i : i + batch_size]
268
+ t = enc(batch).to(device)
269
+ t = t.float()
270
+ t = torch.nn.functional.normalize(t, dim=-1)
271
+ feats.append(t.detach().cpu().numpy())
272
+ return np.concatenate(feats, axis=0)
273
+
274
+
275
+ def compute_groundedness(
276
+ text_embs: np.ndarray, # [C, D] L2-normalized
277
+ audio_embs: np.ndarray, # [N, D] L2-normalized
278
+ concepts: List[str],
279
+ dfs: List[int],
280
+ top_q: float = 0.01,
281
+ ) -> List[GroundScore]:
282
+ """
283
+ For each concept embedding, compute similarity distribution vs audio embeddings and derive scores.
284
+ """
285
+ A = audio_embs
286
+ C = text_embs
287
+ assert C.shape[0] == len(concepts) == len(dfs)
288
+
289
+ scores: List[GroundScore] = []
290
+
291
+ # Compute in chunks to avoid huge memory if C is large.
292
+ # sims = A @ C.T -> [N, C]
293
+ # With N=5000, C up to 5000, this is 25M ~ 100MB float32; still OK, but chunk anyway.
294
+ N = A.shape[0]
295
+ qk = max(1, int(math.ceil(top_q * N)))
296
+
297
+ chunk = 512
298
+ for j0 in range(0, C.shape[0], chunk):
299
+ j1 = min(C.shape[0], j0 + chunk)
300
+ Cc = C[j0:j1] # [c, D]
301
+ sims = A @ Cc.T # [N, c]
302
+
303
+ for jj in range(j1 - j0):
304
+ s = sims[:, jj].astype(np.float64)
305
+ mean = float(s.mean())
306
+ # top-q mean
307
+ top_idx = np.argpartition(s, -qk)[-qk:]
308
+ topq_mean = float(s[top_idx].mean())
309
+ tail_gap = topq_mean - mean
310
+
311
+ # groundedness also reflected by concentration: compute gini on shifted sims
312
+ s_shift = s - s.min()
313
+ gini = gini_coefficient(s_shift)
314
+
315
+ pos_mass = float((s > 0).mean())
316
+
317
+ scores.append(
318
+ GroundScore(
319
+ concept=concepts[j0 + jj],
320
+ df=dfs[j0 + jj],
321
+ mean=mean,
322
+ topq_mean=topq_mean,
323
+ tail_gap=tail_gap,
324
+ gini=gini,
325
+ pos_mass=pos_mass,
326
+ )
327
+ )
328
+ return scores
329
+
330
+
331
+ # -----------------------------
332
+ # Diversity selection (MMR)
333
+ # -----------------------------
334
+ def mmr_select(
335
+ concepts: List[str],
336
+ text_embs: np.ndarray, # [C, D] L2-normalized
337
+ base_score: np.ndarray, # [C]
338
+ k: int,
339
+ lambda_mmr: float = 0.7,
340
+ ) -> List[int]:
341
+ """
342
+ Greedy Maximal Marginal Relevance selection.
343
+ """
344
+ C = text_embs
345
+ selected: List[int] = []
346
+ remaining = set(range(len(concepts)))
347
+
348
+ # Precompute similarity matrix chunk-wise if needed; for C <= 5000 OK but keep simple:
349
+ # We'll compute max similarity to selected on the fly.
350
+ while len(selected) < k and remaining:
351
+ best_i = None
352
+ best_val = -1e18
353
+ for i in list(remaining):
354
+ if not selected:
355
+ val = float(base_score[i])
356
+ else:
357
+ # max similarity to selected
358
+ sims = C[selected] @ C[i]
359
+ max_sim = float(np.max(sims))
360
+ val = lambda_mmr * float(base_score[i]) - (1.0 - lambda_mmr) * max_sim
361
+ if val > best_val:
362
+ best_val = val
363
+ best_i = i
364
+ assert best_i is not None
365
+ selected.append(best_i)
366
+ remaining.remove(best_i)
367
+
368
+ return selected
369
+
370
+
371
+ # -----------------------------
372
+ # Main
373
+ # -----------------------------
374
+ def main():
375
+ ap = argparse.ArgumentParser()
376
+ ap.add_argument("--audio_dir", type=str, required=True)
377
+ ap.add_argument("--captions_s3", type=str, required=True)
378
+ ap.add_argument("--caption_col", type=str, default="caption")
379
+ ap.add_argument("--max_captions", type=int, default=10000)
380
+
381
+ ap.add_argument("--n_min", type=int, default=1)
382
+ ap.add_argument("--n_max", type=int, default=3)
383
+ ap.add_argument("--min_df", type=int, default=5)
384
+ ap.add_argument("--max_df_frac", type=float, default=0.30)
385
+ ap.add_argument("--max_candidates", type=int, default=5000)
386
+
387
+ ap.add_argument("--model_name", type=str, default="OpenMuQ/MuQ-MuLan-large")
388
+ ap.add_argument("--device", type=str, default="cpu")
389
+ ap.add_argument("--text_batch_size", type=int, default=16)
390
+
391
+ ap.add_argument("--top_q", type=float, default=0.01)
392
+ ap.add_argument("--final_k", type=int, default=20)
393
+ ap.add_argument("--use_mmr", action="store_true")
394
+ ap.add_argument("--lambda_mmr", type=float, default=0.7)
395
+
396
+ ap.add_argument("--out_csv", type=str, required=True)
397
+ ap.add_argument("--out_jsonl", type=str, default=None)
398
+
399
+ args = ap.parse_args()
400
+
401
+ print(f"[1/6] Loading audio embeddings from {args.audio_dir} ...")
402
+ audio = load_audio_embeddings(args.audio_dir)
403
+ audio = l2_normalize_np(audio)
404
+ print(f"Loaded audio embeddings: {audio.shape}")
405
+
406
+ print(f"[2/6] Reading captions from {args.captions_s3} ...")
407
+ captions = read_captions_from_s3_csv(args.captions_s3, caption_col=args.caption_col, max_rows=args.max_captions)
408
+ print(f"Loaded captions: {len(captions)}")
409
+
410
+ print("[3/6] Mining n-gram candidates ...")
411
+ cand = mine_ngram_candidates(
412
+ captions=captions,
413
+ n_min=args.n_min,
414
+ n_max=args.n_max,
415
+ min_df=args.min_df,
416
+ max_df_frac=args.max_df_frac,
417
+ max_candidates=args.max_candidates,
418
+ )
419
+ concepts = [g for g, _ in cand]
420
+ dfs = [df for _, df in cand]
421
+ print(f"Candidates after filtering: {len(concepts)}")
422
+
423
+ print("[4/6] Embedding candidate concepts with MuQ text encoder ...")
424
+ enc = MuQTextEncoder(model_name=args.model_name, device=args.device, freeze=True)
425
+ text_embs = embed_text_batch(enc, concepts, batch_size=args.text_batch_size, device=args.device)
426
+ text_embs = l2_normalize_np(text_embs)
427
+ print(f"Text embeddings: {text_embs.shape}")
428
+
429
+ print("[5/6] Computing groundedness scores ...")
430
+ scores = compute_groundedness(
431
+ text_embs=text_embs,
432
+ audio_embs=audio,
433
+ concepts=concepts,
434
+ dfs=dfs,
435
+ top_q=args.top_q,
436
+ )
437
+
438
+ # Base score for ranking: tail_gap (primary) * log(df+1) (secondary) + small gini
439
+ base = np.array([s.tail_gap for s in scores], dtype=np.float64)
440
+ dfw = np.log(np.array([s.df for s in scores], dtype=np.float64) + 1.0)
441
+ gini = np.array([s.gini for s in scores], dtype=np.float64)
442
+ base_score = base * dfw + 0.05 * gini
443
+
444
+ # Sort by base_score
445
+ order = np.argsort(-base_score)
446
+ if args.use_mmr:
447
+ # Run MMR on top pool to keep it fast
448
+ pool = order[: min(len(order), max(args.final_k * 10, 500))]
449
+ pool_concepts = [concepts[i] for i in pool]
450
+ pool_embs = text_embs[pool]
451
+ pool_base = base_score[pool]
452
+ sel_pool_idx = mmr_select(pool_concepts, pool_embs, pool_base, k=args.final_k, lambda_mmr=args.lambda_mmr)
453
+ selected = [pool[i] for i in sel_pool_idx]
454
+ else:
455
+ selected = order[: args.final_k].tolist()
456
+
457
+ print(f"[6/6] Writing outputs: top-{len(selected)} concepts -> {args.out_csv}")
458
+ os.makedirs(os.path.dirname(args.out_csv) or ".", exist_ok=True)
459
+ # also write the concepts to a text file
460
+ with open(args.out_csv.replace(".csv", ".txt"), "w", encoding="utf-8") as f:
461
+ for i in selected:
462
+ f.write(scores[i].concept + "\n")
463
+
464
+ with open(args.out_csv, "w", newline="", encoding="utf-8") as f:
465
+ w = csv.writer(f)
466
+ w.writerow(["concept", "df", "mean_sim", "topq_mean_sim", "tail_gap", "gini", "pos_mass", "base_score"])
467
+ for i in selected:
468
+ s = scores[i]
469
+ w.writerow([s.concept, s.df, s.mean, s.topq_mean, s.tail_gap, s.gini, s.pos_mass, float(base_score[i])])
470
+
471
+ if args.out_jsonl:
472
+ os.makedirs(os.path.dirname(args.out_jsonl) or ".", exist_ok=True)
473
+ with open(args.out_jsonl, "w", encoding="utf-8") as f:
474
+ for i in selected:
475
+ s = scores[i]
476
+ f.write(
477
+ json.dumps(
478
+ {
479
+ "concept": s.concept,
480
+ "df": s.df,
481
+ "mean_sim": s.mean,
482
+ "topq_mean_sim": s.topq_mean,
483
+ "tail_gap": s.tail_gap,
484
+ "gini": s.gini,
485
+ "pos_mass": s.pos_mass,
486
+ "base_score": float(base_score[i]),
487
+ },
488
+ ensure_ascii=False,
489
+ )
490
+ + "\n"
491
+ )
492
+
493
+ print("Done.")
494
+
495
+
496
+ if __name__ == "__main__":
497
+ main()
steerable_retrieval/experiments/notebook_cache.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from omegaconf import OmegaConf
11
+
12
+ try:
13
+ import torch
14
+ except Exception: # pragma: no cover
15
+ torch = None
16
+
17
+
18
+ def _normalize_for_hash(value: Any) -> Any:
19
+ if OmegaConf.is_config(value):
20
+ value = OmegaConf.to_container(value, resolve=True)
21
+
22
+ if isinstance(value, dict):
23
+ return {str(key): _normalize_for_hash(val) for key, val in sorted(value.items(), key=lambda item: str(item[0]))}
24
+ if isinstance(value, (list, tuple)):
25
+ return [_normalize_for_hash(item) for item in value]
26
+ if isinstance(value, set):
27
+ return sorted(_normalize_for_hash(item) for item in value)
28
+ if isinstance(value, Path):
29
+ return str(value)
30
+ if isinstance(value, np.ndarray):
31
+ return value.tolist()
32
+ if isinstance(value, np.generic):
33
+ return value.item()
34
+ if torch is not None and isinstance(value, torch.Tensor):
35
+ return value.detach().cpu().tolist()
36
+ if torch is not None and isinstance(value, torch.device):
37
+ return str(value)
38
+ if isinstance(value, (str, int, float, bool)) or value is None:
39
+ return value
40
+ return repr(value)
41
+
42
+
43
+ def stable_hash(value: Any, length: int = 16) -> str:
44
+ payload = json.dumps(
45
+ _normalize_for_hash(value),
46
+ ensure_ascii=True,
47
+ separators=(",", ":"),
48
+ sort_keys=True,
49
+ )
50
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:length]
51
+
52
+
53
+ def build_cache_path(
54
+ cache_root: str | Path,
55
+ eval_config: Any,
56
+ run_id: str,
57
+ model_config: Any,
58
+ suffix: str = "edit.pkl",
59
+ ) -> tuple[str, str, Path]:
60
+ eval_hash = stable_hash(eval_config)
61
+ model_hash = stable_hash({"run_id": run_id, "model_config": model_config})
62
+ cache_path = Path(cache_root) / f"{eval_hash}_{model_hash}_{suffix}"
63
+ return eval_hash, model_hash, cache_path
64
+
65
+
66
+ def load_cached_frame(cache_path: str | Path) -> pd.DataFrame:
67
+ return pd.read_pickle(Path(cache_path))
68
+
69
+
70
+ def save_cached_frame(df: pd.DataFrame, cache_path: str | Path) -> Path:
71
+ cache_path = Path(cache_path)
72
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
73
+ df.to_pickle(cache_path)
74
+ return cache_path
75
+
76
+
77
+ def build_cache_metadata_path(cache_path: str | Path, metadata_filename: str = "hash.json") -> Path:
78
+ cache_path = Path(cache_path)
79
+ cache_stem = cache_path.name
80
+ if cache_path.suffix:
81
+ cache_stem = cache_path.name[: -len(cache_path.suffix)]
82
+ return cache_path.parent / f"{cache_stem}.{metadata_filename}"
83
+
84
+
85
+ def save_cache_metadata(metadata: Any, cache_path: str | Path, metadata_filename: str = "hash.json") -> Path:
86
+ metadata_path = build_cache_metadata_path(cache_path, metadata_filename=metadata_filename)
87
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
88
+ with metadata_path.open("w", encoding="utf-8") as f:
89
+ json.dump(_normalize_for_hash(metadata), f, indent=2, sort_keys=True)
90
+ return metadata_path
steerable_retrieval/experiments/run_concept_isolation.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Any, Dict, Optional
4
+
5
+ import lightning as L
6
+ import rootutils
7
+ import torch
8
+ from dora.hydra import hydra_main
9
+ from omegaconf import DictConfig
10
+
11
+ rootutils.setup_root(__file__, indicator=".project-root", pythonpath=True)
12
+
13
+ from steerable_retrieval.experiments.common import ( # noqa: E402
14
+ ensure_dir,
15
+ instantiate_model_and_load,
16
+ resolve_model_from_source,
17
+ save_config,
18
+ )
19
+ from steerable_retrieval.experiments.concepts.extract import run_extract_experiment
20
+ from steerable_retrieval.utils import RankedLogger, extras, register_resolvers # noqa: E402
21
+
22
+ log = RankedLogger(__name__, rank_zero_only=True)
23
+ register_resolvers()
24
+
25
+
26
+ def run_concept_isolation(cfg: DictConfig) -> Dict[str, Any]:
27
+ if cfg.get("seed") is not None:
28
+ L.seed_everything(int(cfg.seed), workers=True)
29
+
30
+ source_run_id = resolve_model_from_source(cfg)
31
+ output_dir = ensure_dir(str(cfg.output_dir))
32
+ save_config(cfg, output_dir)
33
+
34
+ device = torch.device(cfg.get("device", "cuda:0") if torch.cuda.is_available() else "cpu")
35
+ log.info(f"Using device: {device}")
36
+
37
+ model, ckpt_path = instantiate_model_and_load(cfg, device=device)
38
+ experiment_name = str(cfg.concepts_experiment.get("name", "extract"))
39
+
40
+ if experiment_name == "extract":
41
+ out_dict = run_extract_experiment(
42
+ cfg=cfg,
43
+ model=model,
44
+ device=device,
45
+ output_dir=output_dir,
46
+ source_run_id=source_run_id,
47
+ ckpt_path=ckpt_path,
48
+ )
49
+ else:
50
+ raise ValueError(
51
+ f"Unsupported concepts_experiment.name={experiment_name}. "
52
+ "Expected one of: extract, score_edit_add, score_edit_suppress."
53
+ )
54
+
55
+ # Save metadata for downstream launch tooling.
56
+ with open(os.path.join(output_dir, "output.json"), "w") as f:
57
+ json.dump(out_dict, f)
58
+
59
+ return out_dict
60
+
61
+
62
+ @hydra_main(version_base="1.3", config_path="../../configs", config_name="experiment/concept_isolation.yaml")
63
+ def main(cfg: DictConfig) -> Optional[Dict[str, Any]]:
64
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
65
+ extras(cfg)
66
+ return run_concept_isolation(cfg)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
71
+
steerable_retrieval/experiments/run_stability.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import math
3
+ import os
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ import lightning as L
7
+ import rootutils
8
+ import torch
9
+ import torch.nn.functional as F
10
+ from dora.hydra import hydra_main
11
+ from omegaconf import DictConfig
12
+
13
+ rootutils.setup_root(__file__, indicator=".project-root", pythonpath=True)
14
+
15
+ from steerable_retrieval.callbacks.energy import get_dictionary_from_lightningsae, load_state_dict_any # noqa: E402
16
+ from steerable_retrieval.experiments.common import ( # noqa: E402
17
+ ensure_dir,
18
+ finish_wandb,
19
+ instantiate_model_and_load,
20
+ maybe_start_wandb,
21
+ resolve_model_from_source,
22
+ save_config,
23
+ save_json,
24
+ )
25
+ from steerable_retrieval.utils import RankedLogger, extras, register_resolvers # noqa: E402
26
+
27
+ log = RankedLogger(__name__, rank_zero_only=True)
28
+ register_resolvers()
29
+
30
+
31
+ def _stability_hungarian(a: torch.Tensor, b: torch.Tensor) -> float:
32
+ from scipy.optimize import linear_sum_assignment
33
+
34
+ sim = (a @ b.t()).detach().cpu().numpy()
35
+ row_idx, col_idx = linear_sum_assignment(-sim)
36
+ return float(sim[row_idx, col_idx].mean())
37
+
38
+
39
+ def _k_values(n_concepts: int, n_curve_points: int) -> List[int]:
40
+ if n_concepts <= 1:
41
+ return [1]
42
+ out = [1]
43
+ for i in range(max(2, n_curve_points)):
44
+ frac = i / max(n_curve_points - 1, 1)
45
+ k = int(round(math.exp(math.log(1) * (1 - frac) + math.log(n_concepts) * frac)))
46
+ out.append(max(1, min(n_concepts, k)))
47
+ out.append(n_concepts)
48
+ return sorted(set(out))
49
+
50
+
51
+ def run_stability(cfg: DictConfig) -> Dict[str, Any]:
52
+ if cfg.get("seed") is not None:
53
+ L.seed_everything(int(cfg.seed), workers=True)
54
+
55
+ source_run_id = resolve_model_from_source(cfg)
56
+ output_dir = ensure_dir(str(cfg.output_dir))
57
+ save_config(cfg, output_dir)
58
+
59
+ device = torch.device(cfg.get("device", "cuda:0") if torch.cuda.is_available() else "cpu")
60
+ model, ckpt_path = instantiate_model_and_load(cfg, device=device)
61
+ d0 = F.normalize(get_dictionary_from_lightningsae(model).to(device), dim=-1)
62
+ n_concepts = int(d0.shape[0])
63
+
64
+ checkpoints = list(cfg.stability.checkpoint_paths)
65
+ if not checkpoints:
66
+ raise ValueError("stability.checkpoint_paths cannot be empty.")
67
+
68
+ run = maybe_start_wandb(
69
+ cfg=cfg,
70
+ job_type="stability_processing",
71
+ name=str(cfg.get("run_name", "stability_processing")),
72
+ output_dir=output_dir,
73
+ )
74
+
75
+ sorted_indices = torch.arange(n_concepts, device=device)
76
+ k_values = _k_values(n_concepts=n_concepts, n_curve_points=int(cfg.stability.n_curve_points))
77
+ topk = cfg.stability.get("topk_by_energy")
78
+
79
+ per_checkpoint = []
80
+ all_stabilities = []
81
+ all_topk = []
82
+ curves = []
83
+
84
+ for path in checkpoints:
85
+ comparison = copy.deepcopy(model).to("cpu")
86
+ comparison.load_state_dict(load_state_dict_any(path, map_location="cpu"), strict=False)
87
+ comparison = comparison.to(device).eval()
88
+ dk = F.normalize(get_dictionary_from_lightningsae(comparison).to(device), dim=-1)
89
+
90
+ stab_full = _stability_hungarian(d0, dk)
91
+ all_stabilities.append(stab_full)
92
+
93
+ stab_topk = None
94
+ if topk is not None:
95
+ k = min(int(topk), n_concepts)
96
+ top_idx = sorted_indices[:k]
97
+ stab_topk = _stability_hungarian(d0[top_idx], dk[top_idx])
98
+ all_topk.append(stab_topk)
99
+
100
+ curve_vals = []
101
+ for k in k_values:
102
+ top_idx = sorted_indices[:k]
103
+ curve_vals.append(_stability_hungarian(d0[top_idx], dk[top_idx]))
104
+ curves.append(curve_vals)
105
+
106
+ per_checkpoint.append(
107
+ {
108
+ "checkpoint_path": str(path),
109
+ "stability_full": stab_full,
110
+ "stability_topk": stab_topk,
111
+ }
112
+ )
113
+
114
+ mean_curve = [sum(vals) / len(vals) for vals in zip(*curves)]
115
+ summary = {
116
+ "experiment": "stability_processing",
117
+ "config_source_wandb_run_id": source_run_id,
118
+ "source_checkpoint": ckpt_path,
119
+ "n_concepts": n_concepts,
120
+ "per_checkpoint": per_checkpoint,
121
+ "mean_stability_full": float(sum(all_stabilities) / len(all_stabilities)),
122
+ "mean_stability_topk": float(sum(all_topk) / len(all_topk)) if all_topk else None,
123
+ "k_values": k_values,
124
+ "mean_stability_curve": mean_curve,
125
+ }
126
+ save_json(os.path.join(output_dir, "summary.json"), summary)
127
+
128
+ if run is not None:
129
+ import wandb
130
+
131
+ table = wandb.Table(columns=["checkpoint_path", "stability_full", "stability_topk"])
132
+ for item in per_checkpoint:
133
+ table.add_data(item["checkpoint_path"], item["stability_full"], item["stability_topk"])
134
+ wandb.log({"stability/per_checkpoint": table})
135
+ wandb.log({"stability/mean_full": summary["mean_stability_full"]})
136
+ if summary["mean_stability_topk"] is not None:
137
+ wandb.log({"stability/mean_topk": summary["mean_stability_topk"]})
138
+
139
+ finish_wandb(run)
140
+ return summary
141
+
142
+
143
+ @hydra_main(version_base="1.3", config_path="../../configs/experiment", config_name="stability.yaml")
144
+ def main(cfg: DictConfig) -> Optional[Dict[str, Any]]:
145
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
146
+ extras(cfg)
147
+ return run_stability(cfg)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
152
+
steerable_retrieval/extract_dataset.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any, Dict, Optional, Set
3
+ from datetime import datetime
4
+
5
+ import lightning as L
6
+ import rootutils
7
+ import torch
8
+ import hydra
9
+ from omegaconf import DictConfig, OmegaConf
10
+
11
+
12
+ from dora import get_xp, hydra_main
13
+ import hydra
14
+
15
+ import lightning as L
16
+ import rootutils
17
+ import torch
18
+ from omegaconf import DictConfig
19
+
20
+
21
+ rootutils.setup_root(__file__, indicator=".project-root", pythonpath=True)
22
+ # ------------------------------------------------------------------------------------ #
23
+ # the setup_root above is equivalent to:
24
+ # - adding project root dir to PYTHONPATH
25
+ # (so you don't need to force user to install project as a package)
26
+ # (necessary before importing any local modules e.g. `from gdr import utils`)
27
+ # - setting up PROJECT_ROOT environment variable
28
+ # (which is used as a base for paths in "configs/paths/default.yaml")
29
+ # (this way all filepaths are the same no matter where you run the code)
30
+ # - loading environment variables from ".env" in root dir
31
+ #
32
+ # you can remove it if you:
33
+ # 1. either install project as a package or move entry files to project root dir
34
+ # 2. set `root_dir` to "." in "configs/paths/default.yaml"
35
+ #
36
+ # more info: https://github.com/ashleve/rootutils
37
+ # ------------------------------------------------------------------------------------ #
38
+
39
+ from steerable_retrieval.utils import (
40
+ RankedLogger,
41
+ extras,
42
+ register_resolvers,
43
+ )
44
+
45
+ log = RankedLogger(__name__, rank_zero_only=True)
46
+ register_resolvers()
47
+
48
+
49
+ def list_existing_paths(path: str) -> Set[str]:
50
+ """List existing feature files from either a local directory or S3 path.
51
+
52
+ Args:
53
+ path: Either a local directory path or S3 path (s3://bucket/path) where features are stored
54
+
55
+ Returns:
56
+ Set of relative file paths (done IDs) extracted from existing files
57
+ """
58
+ done_ids = set()
59
+
60
+ if not path:
61
+ log.info("No path provided, skipping path listing")
62
+ return done_ids
63
+
64
+ # Check if it's an S3 path
65
+ if 's3://' in path:
66
+ # S3 path - list from S3
67
+ try:
68
+ import boto3
69
+ client = boto3.client('s3')
70
+
71
+ # Extract bucket and prefix from S3 path
72
+ s3_path = path.replace("s3://", "")
73
+ parts = s3_path.split("/", 1)
74
+ bucket = parts[0]
75
+ prefix = parts[1] if len(parts) > 1 else ""
76
+ if prefix and not prefix.endswith('/'):
77
+ prefix = prefix + '/'
78
+
79
+ log.info(f"Listing existing paths from S3: s3://{bucket}/{prefix}")
80
+
81
+ # Paginate through all objects in the target location
82
+ paginator = client.get_paginator('list_objects_v2')
83
+ page_iterator = paginator.paginate(Bucket=bucket, Prefix=prefix)
84
+
85
+ path_count = 0
86
+ for page in page_iterator:
87
+ if 'Contents' in page:
88
+ for obj in page['Contents']:
89
+ object_key = obj['Key']
90
+ # Extract relative path by removing the prefix
91
+ if object_key.startswith(prefix):
92
+ relative_path = object_key[len(prefix):]
93
+ # Skip empty paths and directories (ending with /)
94
+ if relative_path and not relative_path.endswith('/'):
95
+ # Add the path as-is
96
+ done_ids.add(relative_path.replace('.npy','').replace('.wav','').replace('.mp3',''))
97
+ # remove extension
98
+ path_count += 1
99
+
100
+ log.info(f"Found {path_count} existing paths in S3, {len(done_ids)} unique done IDs")
101
+
102
+ except Exception as e:
103
+ log.warning(f"Failed to list existing paths from S3: {e}")
104
+ log.warning("Continuing without skipping already processed items")
105
+ else:
106
+ # Local directory - list from filesystem
107
+ try:
108
+ if not os.path.exists(path):
109
+ log.info(f"Local path does not exist: {path}, skipping path listing")
110
+ return done_ids
111
+
112
+ log.info(f"Listing existing paths from local directory: {path}")
113
+
114
+ # Walk through the directory and collect all .npy files
115
+ path_count = 0
116
+ for root, dirs, files in os.walk(path):
117
+ for file in files:
118
+ if file.endswith('.npy'):
119
+ # Get relative path from the base path
120
+ full_path = os.path.join(root, file)
121
+ relative_path = os.path.relpath(full_path, path)
122
+ # Normalize path separators (use forward slashes)
123
+ relative_path = relative_path.replace(os.sep, '/')
124
+
125
+ # Add the path as-is
126
+ done_ids.add(relative_path.replace('.npy','').replace('.wav','').replace('.mp3',''))
127
+ path_count += 1
128
+
129
+ log.info(f"Found {path_count} existing paths locally, {len(done_ids)} unique done IDs")
130
+
131
+ except Exception as e:
132
+ log.warning(f"Failed to list existing paths from local directory: {e}")
133
+ log.warning("Continuing without skipping already processed items")
134
+
135
+ return done_ids
136
+
137
+
138
+ def extract_features(cfg: DictConfig) -> Dict[str, Any]:
139
+ """Extracts features from audio datasets using pre-trained encoders.
140
+
141
+ :param cfg: A DictConfig configuration composed by Hydra.
142
+ :return: A dict with extraction metadata.
143
+ """
144
+ # set seed for random number generators in pytorch, numpy and python.random
145
+ if cfg.get("seed"):
146
+ L.seed_everything(cfg.seed, workers=True)
147
+
148
+ log.info(f"Instantiating datamodule <{cfg.data._target_}>")
149
+ datamodule = hydra.utils.instantiate(cfg.data)
150
+
151
+ log.info(f"Instantiating model <{cfg.model._target_}>")
152
+ model = hydra.utils.instantiate(cfg.model)
153
+
154
+ # Setup datamodule
155
+ datamodule.setup(None)
156
+
157
+ # Move encoder_pair to device
158
+ device = cfg.get("device", "cuda:0") if torch.cuda.is_available() else "cpu"
159
+ model.to(device)
160
+ log.info(f"Using device: {device}")
161
+
162
+
163
+ # Get extract parameters from config
164
+ save_dir = cfg.get("save_dir")
165
+ root_path = cfg.get("root_path")
166
+ extract_method = cfg.get("extract_method", "get_audio_embedding_from_data")
167
+ out_key = cfg.get("out_key", None)
168
+ hop = cfg.get("hop", 48000)
169
+ limit_n = cfg.get("limit_n")
170
+ save = cfg.get("save", False)
171
+ sagemaker_dir = cfg.get("sagemaker_dir", None)
172
+
173
+ # Handle save_dir
174
+ if save_dir is None:
175
+ if datamodule.train_dataset is not None and len(datamodule.train_dataset.annotations) > 0:
176
+ save_dir = os.path.dirname(datamodule.train_dataset.annotations[0]['file_path'])
177
+ else:
178
+ raise ValueError("save_dir must be specified in config or available from dataset")
179
+
180
+ # Check for sagemaker config to get S3 output destination
181
+ # In sagemaker, files are saved locally to save_dir, then uploaded to S3 destination
182
+ s3_destination = sagemaker_dir
183
+ log.info(f"S3 destination: {s3_destination}")
184
+ # List existing paths on restart to build done_ids set
185
+ # Check both save_dir and s3_destination (if using SageMaker) to avoid re-extracting
186
+ done_ids = set()
187
+ if save:
188
+ # Check save_dir (local or S3)
189
+ done_ids_save_dir = list_existing_paths(save_dir)
190
+ if done_ids_save_dir:
191
+ log.info(f"Found {len(done_ids_save_dir)} existing files in save_dir: {save_dir}")
192
+ done_ids.update(done_ids_save_dir)
193
+
194
+ # Also check S3 destination if using SageMaker
195
+ if s3_destination:
196
+ done_ids_s3 = list_existing_paths(s3_destination)
197
+ if done_ids_s3:
198
+ log.info(f"Found {len(done_ids_s3)} existing files in S3 destination: {s3_destination}")
199
+ done_ids.update(done_ids_s3)
200
+
201
+ if done_ids:
202
+ log.info(f"Total: Skipping {len(done_ids)} already processed items")
203
+ else:
204
+ log.info("No existing paths found in save_dir or S3 destination")
205
+
206
+
207
+ log.info(f"Extracting features with {extract_method} method")
208
+ log.info(f"Saving to: {save_dir}")
209
+ log.info(f"out_key: {out_key}, hop: {hop}, limit_n: {limit_n}, save: {save}")
210
+
211
+ # Save config to save_dir
212
+ if 's3://' in save_dir:
213
+ # S3 path - upload config
214
+ try:
215
+ import s3fs
216
+ fs = s3fs.S3FileSystem()
217
+ config_path = f"{save_dir}/config.yaml"
218
+
219
+ with fs.open(config_path, "w") as config_file:
220
+ config_file.write(OmegaConf.to_yaml(cfg, resolve=True))
221
+ log.info(f"Uploaded config to {config_path}")
222
+ except Exception as e:
223
+ log.warning(f"Failed to upload config to S3: {e}")
224
+ else:
225
+ # Local path - save config
226
+ os.makedirs(save_dir, exist_ok=True)
227
+ config_path = os.path.join(save_dir, "config.yaml")
228
+ with open(config_path, "w") as config_file:
229
+ config_file.write(OmegaConf.to_yaml(cfg, resolve=True))
230
+ log.info(f"Saved config to {config_path}")
231
+
232
+ # Extract features from all datasets
233
+ datasets = []
234
+ if datamodule.train_dataset is not None:
235
+ datasets.append(datamodule.train_dataset)
236
+ if datamodule.val_datasets:
237
+ datasets.extend([d for d in datamodule.val_datasets if d is not None])
238
+ if datamodule.test_datasets:
239
+ datasets.extend([d for d in datamodule.test_datasets if d is not None])
240
+
241
+ for dataset in datasets:
242
+ if dataset is not None:
243
+ log.info(f"Extracting features from dataset: {type(dataset).__name__}")
244
+ dataset.extract_and_save_features(
245
+ model.audio_encoder,
246
+ save_dir=save_dir,
247
+ extract_method=extract_method,
248
+ out_key=out_key,
249
+ hop=hop,
250
+ limit_n=limit_n,
251
+ save=save,
252
+ verbose=False,
253
+ root_path=root_path,
254
+ done_ids=done_ids
255
+ )
256
+
257
+ log.info("Feature extraction completed!")
258
+
259
+ return {
260
+ "save_dir": save_dir,
261
+ "extract_method": extract_method,
262
+ "out_key": out_key,
263
+ }
264
+
265
+
266
+ @hydra_main(version_base="1.3", config_path="../configs", config_name="extract_feature.yaml")
267
+ def main(cfg: DictConfig) -> Optional[Dict[str, Any]]:
268
+ """Main entry point for feature extraction.
269
+
270
+ :param cfg: DictConfig configuration composed by Hydra.
271
+ :return: Dict with extraction metadata.
272
+ """
273
+ # handle A100 GPUs
274
+ if torch.cuda.is_available() and ("A100" in torch.cuda.get_device_name() or "A5000" in torch.cuda.get_device_name()):
275
+ torch.set_float32_matmul_precision("high")
276
+
277
+ # avoid annoying multiprocessing errors
278
+ torch.multiprocessing.set_sharing_strategy('file_system')
279
+
280
+ # prevent annoying warning
281
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
282
+
283
+ # apply extra utilities
284
+ # (e.g. ask for tags if none are provided in cfg, print cfg tree, etc.)
285
+ extras(cfg)
286
+
287
+ # extract features
288
+ result = extract_features(cfg)
289
+
290
+ return result
291
+
292
+
293
+ if __name__ == "__main__":
294
+ main()
295
+
steerable_retrieval/models/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Model definitions for steerable_retrieval."""
2
+
steerable_retrieval/models/base.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from torch import nn
2
+
3
+ class BaseModule(nn.Module):
4
+ def __init__(self, ckpt_path = None, freeze = False):
5
+ super().__init__()
6
+ self.ckpt_path = ckpt_path
7
+ self.freeze = freeze
8
+
9
+
10
+
11
+ @classmethod
12
+ def from_config(cls, config, device=None):
13
+ config.update(device=device)
14
+ return cls(**config)
15
+
16
+ @classmethod
17
+ def from_yaml(cls, yaml_path, device=None):
18
+
19
+ if 's3://' in yaml_path:
20
+ import s3fs
21
+ fs = s3fs.S3FileSystem()
22
+ with fs.open(yaml_path, "r") as file:
23
+ config = yaml.safe_load(file)
24
+ else:
25
+ with open(yaml_path, "r") as file:
26
+ config = yaml.safe_load(file)
27
+
28
+ config = config.get('model', config)
29
+ config = config.get('init_args', config)
30
+
31
+ return cls.from_config(config, device=device)
32
+
33
+ @classmethod
34
+ def from_pretrained(cls, yaml_or_config, ckpt_path, device=None):
35
+ if isinstance(yaml_or_config, str):
36
+ model = cls.from_yaml(yaml_or_config, device=device)
37
+ else:
38
+ model = cls.from_config(yaml_or_config, device=device)
39
+
40
+ if 's3://' in ckpt_path:
41
+ from s3torchconnector import S3Checkpoint
42
+ checkpoint= S3Checkpoint(region='us-east-1')
43
+ with checkpoint.reader(ckpt_path) as f:
44
+ ckpt = torch.load(f, map_location=device)
45
+ model.load_state_dict(ckpt['state_dict'], strict=True)
46
+ print(f"Model loaded from {ckpt_path}")
47
+ else:
48
+ ckpt = torch.load(ckpt_path, map_location=device)
49
+ model.load_state_dict(ckpt['state_dict'], strict=True)
50
+ print(f"Model loaded from {ckpt_path}")
51
+
52
+ return model
53
+
54
+
55
+ def configure_optimizers(self):
56
+ from steerable_retrieval.utils.instantiators import instantiate
57
+
58
+ if not hasattr(self, 'optimizer'):
59
+ self.optimizer = None
60
+ if self.optimizer is None:
61
+ optimizer = optim.Adam(
62
+ self.parameters(), lr=1e-4, betas=(0.9, 0.999), eps=1e-8)
63
+ else:
64
+ # If optimizer is a config dict or partial, instantiate it with model parameters
65
+ if isinstance(self.optimizer, dict):
66
+ # Handle _partial_ configs - Hydra creates a functools.partial
67
+ optimizer_cfg = dict(self.optimizer)
68
+
69
+ # If using _target_ style (with or without _partial_)
70
+ if '_target_' in optimizer_cfg:
71
+ # Remove _partial_ flag if present (it was just to prevent instantiation)
72
+ optimizer_cfg.pop('_partial_', None)
73
+ optimizer_cfg.pop('_convert_', None)
74
+ optimizer_cfg['params'] = self.parameters()
75
+ optimizer = instantiate(optimizer_cfg)
76
+ # If using class_path style
77
+ elif 'class_path' in optimizer_cfg:
78
+ import importlib
79
+ module_path, class_name = optimizer_cfg['class_path'].rsplit('.', 1)
80
+ module = importlib.import_module(module_path)
81
+ optimizer_class = getattr(module, class_name)
82
+ init_args = optimizer_cfg.get('init_args', {})
83
+ init_args['params'] = self.parameters()
84
+ optimizer = optimizer_class(**init_args)
85
+ else:
86
+ # Fallback: assume it's a direct config
87
+ optimizer_cfg['params'] = self.parameters()
88
+ optimizer = instantiate(optimizer_cfg)
89
+ elif hasattr(self.optimizer, 'func') and hasattr(self.optimizer, 'keywords'):
90
+ # It's a functools.partial (from _partial_=true) - call it with params
91
+ optimizer = self.optimizer(params=self.parameters())
92
+ elif callable(self.optimizer):
93
+ # If it's a callable (old style), call it with parameters
94
+ optimizer = self.optimizer(self.parameters())
95
+ else:
96
+ # Fallback to default
97
+ optimizer = optim.Adam(
98
+ self.parameters(), lr=1e-4, betas=(0.9, 0.999), eps=1e-8)
99
+
100
+ if hasattr(self, 'scheduler') and self.scheduler is not None:
101
+ # copy of the scheduler applied to the optimizer
102
+ ## retrocompatibilty with old schedulers
103
+ if isinstance(self.scheduler, dict) and 'class_name' in self.scheduler.keys():
104
+ scheduler_class = eval(self.scheduler['class_name'])
105
+ scheduler_kwargs = self.scheduler.get('init_args', {})
106
+ scheduler = scheduler_class(optimizer, **scheduler_kwargs)
107
+ self.scheduler = scheduler # Store instantiated scheduler
108
+ # Return with proper Lightning configuration
109
+ return {
110
+ 'optimizer': optimizer,
111
+ 'lr_scheduler': {
112
+ 'scheduler': scheduler,
113
+ 'interval': 'step', # Step after optimizer steps (respects accumulate_grad_batches)
114
+ 'frequency': 1, # Step every optimizer step
115
+ }
116
+ }
117
+ elif isinstance(self.scheduler, dict):
118
+ # Handle configs that were prevented from instantiation
119
+ scheduler_cfg = dict(self.scheduler)
120
+
121
+ # If using class_path style (not _target_)
122
+ if 'class_path' in scheduler_cfg:
123
+ import importlib
124
+ module_path, class_name = scheduler_cfg['class_path'].rsplit('.', 1)
125
+ module = importlib.import_module(module_path)
126
+ scheduler_class = getattr(module, class_name)
127
+ init_args = scheduler_cfg.get('init_args', {})
128
+ init_args['optimizer'] = optimizer
129
+ scheduler = scheduler_class(**init_args)
130
+ # If using _target_ style
131
+ elif '_target_' in scheduler_cfg:
132
+ # Remove _partial_ flag and add optimizer
133
+ scheduler_cfg.pop('_partial_', None)
134
+ scheduler_cfg.pop('_convert_', None)
135
+ scheduler_cfg['optimizer'] = optimizer
136
+ scheduler = instantiate(scheduler_cfg)
137
+ else:
138
+ # Fallback: assume it's a direct config
139
+ scheduler_cfg['optimizer'] = optimizer
140
+ scheduler = instantiate(scheduler_cfg)
141
+ self.scheduler = scheduler # Store instantiated scheduler
142
+ # Return with proper Lightning configuration
143
+ return {
144
+ 'optimizer': optimizer,
145
+ 'lr_scheduler': {
146
+ 'scheduler': scheduler,
147
+ 'interval': 'step', # Step after optimizer steps (respects accumulate_grad_batches)
148
+ 'frequency': 1, # Step every optimizer step
149
+ }
150
+ }
151
+ elif hasattr(self.scheduler, 'func') and hasattr(self.scheduler, 'keywords'):
152
+ # It's a functools.partial (from _partial_=true) - call it with optimizer
153
+ scheduler = self.scheduler(optimizer=optimizer)
154
+ self.scheduler = scheduler # Store instantiated scheduler
155
+ # Return with proper Lightning configuration
156
+ return {
157
+ 'optimizer': optimizer,
158
+ 'lr_scheduler': {
159
+ 'scheduler': scheduler,
160
+ 'interval': 'step', # Step after optimizer steps (respects accumulate_grad_batches)
161
+ 'frequency': 1, # Step every optimizer step
162
+ }
163
+ }
164
+ else:
165
+ # If scheduler is already instantiated, just return it
166
+ # Return with proper Lightning configuration
167
+ return {
168
+ 'optimizer': optimizer,
169
+ 'lr_scheduler': {
170
+ 'scheduler': self.scheduler,
171
+ 'interval': 'step', # Step after optimizer steps (respects accumulate_grad_batches)
172
+ 'frequency': 1, # Step every optimizer step
173
+ }
174
+ }
175
+
176
+ return optimizer
177
+
178
+ def load_ckpt(self, ckpt_path, device = None, prefix = ''):
179
+
180
+ if device is None:
181
+ device = next(self.parameters()).device
182
+
183
+ if 's3://' in ckpt_path:
184
+ from s3torchconnector import S3Checkpoint
185
+ checkpoint= S3Checkpoint(region='us-east-1')
186
+ with checkpoint.reader(ckpt_path) as f:
187
+ state_dict = torch.load(f, map_location=device)['state_dict']
188
+ print(f"Model loaded from {ckpt_path}")
189
+ else:
190
+ state_dict = torch.load(ckpt_path, map_location=device)['state_dict']
191
+ print(f"Model loaded from {ckpt_path}")
192
+
193
+ print(state_dict)
194
+
195
+ try:
196
+ self.load_state_dict(state_dict)
197
+ print("Loaded full state dict")
198
+ except:
199
+ print("Could not load state dict, trying to load only ['encoder'] keys")
200
+
201
+ try:
202
+ from collections import OrderedDict
203
+ new_state_dict = OrderedDict()
204
+ for k in list(state_dict.keys()):
205
+ if prefix in k:
206
+ new_key = k.replace('encoder.','')
207
+ new_state_dict[new_key] = state_dict[k]
208
+
209
+ self.load_state_dict(new_state_dict)
210
+ print(f"Loaded only {prefix} keys")
211
+
212
+ except Exception as e:
213
+ print(f"Could not load state dict, error: {e}")
214
+
215
+
216
+
217
+ def freeze(self):
218
+ for param in self.parameters():
219
+ param.requires_grad = False
steerable_retrieval/models/encoders/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio and text encoder wrappers."""
2
+
3
+ from steerable_retrieval.models.encoders.clap import CLAPAudioEncoder, CLAPTextEncoder
4
+ from steerable_retrieval.models.encoders.muq import MuQAudioEncoder, MuQTextEncoder
5
+
6
+ __all__ = [
7
+ 'CLAPAudioEncoder',
8
+ 'CLAPTextEncoder',
9
+ 'MuQAudioEncoder',
10
+ 'MuQTextEncoder',
11
+ ]
steerable_retrieval/models/encoders/clap.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLAP encoder wrappers for audio and text."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ from transformers import ClapModel, ClapProcessor
6
+ from steerable_retrieval.models.base import BaseModule
7
+ import numpy as np
8
+
9
+ def _to_embedding_tensor(out):
10
+ """Return [batch, feature_dim] tensor from model output (tensor or HuggingFace output)."""
11
+ if isinstance(out, torch.Tensor):
12
+ return out
13
+ if hasattr(out, "pooler_output") and out.pooler_output is not None:
14
+ return out.pooler_output
15
+ if hasattr(out, "last_hidden_state"):
16
+ h = out.last_hidden_state
17
+ return h[:, 0] if h.dim() == 3 else h
18
+ raise TypeError(
19
+ f"Model returned {type(out).__name__}; expected tensor or pooler_output/last_hidden_state."
20
+ )
21
+
22
+ import logging
23
+
24
+
25
+ class CLAPAudioEncoder(BaseModule):
26
+ """CLAP audio encoder wrapper that returns dense features after projection head."""
27
+
28
+ def __init__(self, model_name="laion/larger_clap_music", device="cuda", freeze=True, ckpt_path=None, sampling_rate=48000, **kwargs):
29
+ super().__init__(ckpt_path=ckpt_path, freeze=freeze)
30
+ self.model_name = model_name
31
+ self.freeze = freeze
32
+ self.sampling_rate = sampling_rate
33
+
34
+ # Load CLAP model and processor
35
+ self.model = ClapModel.from_pretrained(model_name)
36
+ self.processor = ClapProcessor.from_pretrained(model_name)
37
+ # Set sampling rate to suppress warning
38
+ if hasattr(self.processor, 'feature_extractor'):
39
+ self.processor.feature_extractor.sampling_rate = sampling_rate
40
+
41
+ # Set to eval mode and freeze if requested
42
+ self.model.eval()
43
+ if freeze:
44
+ for param in self.model.parameters():
45
+ param.requires_grad = False
46
+
47
+
48
+
49
+ def forward(self, audio):
50
+ """
51
+ Forward pass through CLAP audio encoder.
52
+
53
+ Args:
54
+ audio: Audio input. Can be:
55
+ - Raw audio array (numpy array or torch tensor)
56
+ - Dictionary with 'array' key (from datasets)
57
+ - Already processed audio tensor
58
+
59
+ Returns:
60
+ audio_features: Dense audio features [batch, feature_dim]
61
+ """
62
+ # Process audio if needed
63
+ device = next(self.model.parameters()).device
64
+ if isinstance(audio, dict) and 'array' in audio:
65
+ # Handle dataset format
66
+ audio_array = audio['array']
67
+ inputs = self.processor(audio=audio_array, return_tensors="pt", sampling_rate=self.sampling_rate).to(device)
68
+ elif isinstance(audio, torch.Tensor):
69
+ # Handle batched audio tensors from DataLoader
70
+ # DataLoader stacks tensors, so we get [batch, 1, samples] or [batch, samples]
71
+ if audio.dim() == 3:
72
+ # [batch, 1, samples] -> squeeze middle dimension
73
+ audio = audio.squeeze(1)
74
+ elif audio.dim() == 2:
75
+ # [batch, samples] - already correct
76
+ pass
77
+ elif audio.dim() == 1:
78
+ # [samples] - single sample, add batch dim
79
+ audio = audio.unsqueeze(0)
80
+
81
+ # Convert to list of numpy arrays for CLAP processor
82
+ # CLAP processor expects list of 1D arrays
83
+ audio_list = [a.cpu().numpy() for a in audio]
84
+ inputs = self.processor(audio=audio_list, return_tensors="pt", sampling_rate=self.sampling_rate).to(device)
85
+ else:
86
+ # Assume it's a numpy array or list
87
+ inputs = self.processor(audio=audio, return_tensors="pt", sampling_rate=self.sampling_rate).to(device)
88
+
89
+
90
+ # make sure the inputs are on the correct device
91
+ for key, value in inputs.items():
92
+ # if numpy array, convert to tensor
93
+ if isinstance(value, np.ndarray):
94
+ value = torch.tensor(value).to(device)
95
+ inputs[key] = value
96
+
97
+ # Get dense audio features (after projection head)
98
+ if self.freeze:
99
+ with torch.no_grad():
100
+ out = self.model.get_audio_features(**inputs)
101
+ else:
102
+ out = self.model.get_audio_features(**inputs)
103
+ return _to_embedding_tensor(out)
104
+
105
+
106
+ class CLAPTextEncoder(BaseModule):
107
+ """CLAP text encoder wrapper that returns dense features after projection head."""
108
+
109
+ def __init__(self, model_name="laion/larger_clap_music", device="cuda", freeze=True, ckpt_path=None, **kwargs):
110
+ super().__init__(ckpt_path=ckpt_path, freeze=freeze)
111
+ self.model_name = model_name
112
+ self.device = device
113
+ self.freeze = freeze
114
+
115
+ # Load CLAP model and processor
116
+ self.model = ClapModel.from_pretrained(model_name).to(device)
117
+ self.processor = ClapProcessor.from_pretrained(model_name)
118
+
119
+ # Set to eval mode and freeze if requested
120
+ self.model.eval()
121
+ if freeze:
122
+ for param in self.model.parameters():
123
+ param.requires_grad = False
124
+
125
+ def forward(self, text):
126
+ """
127
+ Forward pass through CLAP text encoder.
128
+
129
+ Args:
130
+ text: Text input. Can be:
131
+ - String
132
+ - List of strings
133
+ - Already processed text
134
+
135
+ Returns:
136
+ text_features: Dense text features [batch, feature_dim]
137
+ """
138
+ # Process text if needed
139
+ if isinstance(text, str):
140
+ text = [text]
141
+
142
+ device = next(self.model.parameters()).device
143
+
144
+ # Process text through CLAP processor
145
+ inputs = self.processor(text=text, return_tensors="pt", padding=True, truncation=True).to(self.device)
146
+
147
+ # make sure the inputs are on the correct device
148
+ for key, value in inputs.items():
149
+ # if numpy array, convert to tensor
150
+ if isinstance(value, np.ndarray):
151
+ value = torch.tensor(value).to(device)
152
+ inputs[key] = value
153
+
154
+
155
+ # Get dense text features (after projection head)
156
+ with torch.set_grad_enabled(not self.freeze):
157
+ out = self.model.get_text_features(**inputs)
158
+ return _to_embedding_tensor(out)
steerable_retrieval/models/encoders/muq.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MuQ encoder wrappers for audio and text."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ from muq import MuQMuLan
6
+ from steerable_retrieval.models.base import BaseModule
7
+
8
+
9
+ class MuQAudioEncoder(BaseModule):
10
+ """MuQ audio encoder wrapper that returns dense features after projection head."""
11
+
12
+ def __init__(self, model_name="OpenMuQ/MuQ-MuLan-large", device="cuda", freeze=True, ckpt_path=None, sampling_rate=24000, **kwargs):
13
+ super().__init__(ckpt_path=ckpt_path, freeze=freeze)
14
+ self.model_name = model_name
15
+ self.device = device
16
+ self.freeze = freeze
17
+
18
+ # Load MuQ model
19
+ self.model = MuQMuLan.from_pretrained(model_name)
20
+ self.model = self.model.to(device).eval()
21
+
22
+ # Freeze if requested
23
+ if freeze:
24
+ for param in self.model.parameters():
25
+ param.requires_grad = False
26
+
27
+ self.sampling_rate = sampling_rate
28
+
29
+ def forward(self, audio):
30
+ """
31
+ Forward pass through MuQ audio encoder.
32
+
33
+ Args:
34
+ audio: Audio input. Can be:
35
+ - Raw audio tensor [batch, samples] or [samples]
36
+ - Numpy array
37
+ - Already processed audio
38
+
39
+ Returns:
40
+ audio_features: Dense audio features [batch, feature_dim]
41
+ """
42
+ # Ensure audio is a tensor on the correct device
43
+ if not isinstance(audio, torch.Tensor):
44
+ audio = torch.tensor(audio)
45
+
46
+ # Ensure audio is on the correct device
47
+ audio = audio.to(self.device)
48
+
49
+ # Handle batched audio tensors from DataLoader
50
+ # DataLoader stacks tensors, so we get [batch, 1, samples] or [batch, samples]
51
+ if audio.dim() == 3:
52
+ # [batch, 1, samples] -> squeeze middle dimension
53
+ audio = audio.squeeze(1)
54
+ elif audio.dim() == 2:
55
+ # [batch, samples] - already correct
56
+ pass
57
+ elif audio.dim() == 1:
58
+ # [samples] - single sample, add batch dim
59
+ audio = audio.unsqueeze(0)
60
+
61
+ # Get dense audio features (after projection head)
62
+ with torch.set_grad_enabled(not self.freeze):
63
+ out = self.model(wavs=audio)
64
+ # MuQ/HuggingFace models may return BaseModelOutputWithPooling; return tensor [B, D]
65
+ if isinstance(out, torch.Tensor):
66
+ return out
67
+ if hasattr(out, "pooler_output") and out.pooler_output is not None:
68
+ return out.pooler_output
69
+ if hasattr(out, "last_hidden_state"):
70
+ h = out.last_hidden_state
71
+ return h[:, 0] if h.dim() == 3 else h
72
+ raise TypeError(f"MuQ model returned {type(out).__name__}; expected tensor or pooler_output/last_hidden_state.")
73
+
74
+
75
+ class MuQTextEncoder(BaseModule):
76
+ """MuQ text encoder wrapper that returns dense features after projection head."""
77
+
78
+ def __init__(self, model_name="OpenMuQ/MuQ-MuLan-large", device="cuda", freeze=True, ckpt_path=None, **kwargs):
79
+ super().__init__(ckpt_path=ckpt_path, freeze=freeze)
80
+ self.model_name = model_name
81
+ self.device = device
82
+ self.freeze = freeze
83
+
84
+ # Load MuQ model
85
+ self.model = MuQMuLan.from_pretrained(model_name)
86
+ self.model = self.model.to(device).eval()
87
+
88
+ # Freeze if requested
89
+ if freeze:
90
+ for param in self.model.parameters():
91
+ param.requires_grad = False
92
+
93
+ def forward(self, text):
94
+ """
95
+ Forward pass through MuQ text encoder.
96
+
97
+ Args:
98
+ text: Text input. Can be:
99
+ - String
100
+ - List of strings
101
+
102
+ Returns:
103
+ text_features: Dense text features [batch, feature_dim]
104
+ """
105
+ # Ensure text is a list
106
+ if isinstance(text, str):
107
+ text = [text]
108
+
109
+ # Get dense text features (after projection head)
110
+ with torch.set_grad_enabled(not self.freeze):
111
+ out = self.model(texts=text)
112
+ # MuQ/HuggingFace models may return BaseModelOutputWithPooling; return tensor [B, D]
113
+ if isinstance(out, torch.Tensor):
114
+ return out
115
+ if hasattr(out, "pooler_output") and out.pooler_output is not None:
116
+ return out.pooler_output
117
+ if hasattr(out, "last_hidden_state"):
118
+ h = out.last_hidden_state
119
+ return h[:, 0] if h.dim() == 3 else h
120
+ raise TypeError(f"MuQ model returned {type(out).__name__}; expected tensor or pooler_output/last_hidden_state.")
steerable_retrieval/models/sae/__init__.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sparse Autoencoder (SAE) implementations."""
2
+
3
+ from steerable_retrieval.models.sae.sae import SAE, LightningSAE
4
+ from steerable_retrieval.models.sae.encoders import (
5
+ SAEEncoder,
6
+ VanillaSAEEncoder,
7
+ TopKSAEEncoder,
8
+ BatchTopKSAEEncoder,
9
+ MatryoshkaBatchTopKSAEEncoder,
10
+ JumpReLUSAEEncoder,
11
+ OMPEncoder,
12
+ MPEncoder,
13
+ )
14
+ from steerable_retrieval.models.sae.decoders import SAEDecoder
15
+ from steerable_retrieval.models.sae.penalties import (
16
+ VanillaPenalty,
17
+ TopKPenalty,
18
+ BatchTopKPenalty,
19
+ MatryoshkaBatchTopKPenalty,
20
+ L1Penalty,
21
+ JumpReLU,
22
+ StepFunction,
23
+ RectangleFunction,
24
+ )
25
+
26
+ __all__ = [
27
+ 'SAE',
28
+ 'LightningSAE',
29
+ 'SAEEncoder',
30
+ 'VanillaSAEEncoder',
31
+ 'TopKSAEEncoder',
32
+ 'BatchTopKSAEEncoder',
33
+ 'MatryoshkaBatchTopKSAEEncoder',
34
+ 'JumpReLUSAEEncoder',
35
+ 'OMPEncoder',
36
+ 'MPEncoder',
37
+ 'SAEDecoder',
38
+ 'VanillaPenalty',
39
+ 'TopKPenalty',
40
+ 'BatchTopKPenalty',
41
+ 'MatryoshkaBatchTopKPenalty',
42
+ 'L1Penalty',
43
+ 'JumpReLU',
44
+ 'StepFunction',
45
+ 'RectangleFunction',
46
+ ]
steerable_retrieval/models/sae/decoders.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SAE decoder classes that return only reconstruction."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+
6
+
7
+ class SAEDecoder(nn.Module):
8
+ """Base SAE decoder that takes activations and returns reconstruction."""
9
+
10
+ def __init__(self, dict_size, act_size, device='cuda',
11
+ input_unit_norm=False, seed=None, output_unit_norm=False):
12
+ """
13
+ Initialize decoder.
14
+
15
+ Args:
16
+ dict_size: Dictionary size (number of features)
17
+ act_size: Activation size (input/output feature dimension)
18
+ device: Device to use
19
+ input_unit_norm: Whether to apply unit norm postprocessing
20
+ seed: Random seed for initialization
21
+ """
22
+ super().__init__()
23
+ self.dict_size = dict_size
24
+ self.act_size = act_size
25
+ self.device = device
26
+ self.dtype = torch.float32 # Automatic dtype
27
+ self.input_unit_norm = input_unit_norm
28
+ self.output_unit_norm = output_unit_norm
29
+ if seed is not None:
30
+ torch.manual_seed(seed)
31
+
32
+ # Decoder weights - initialize randomly, can be set from encoder later
33
+ self.W_dec = nn.Parameter(
34
+ torch.nn.init.kaiming_uniform_(
35
+ torch.empty(dict_size, act_size)
36
+ )
37
+ )
38
+
39
+ # b_dec will be set separately to share with encoder
40
+ # Set it after initialization: decoder.b_dec = encoder.b_dec
41
+
42
+ self.to(self.dtype).to(device)
43
+
44
+ # For compatibility
45
+ self.in_channels = dict_size
46
+ self.out_channels = act_size
47
+
48
+ def forward(self, activations):
49
+ """
50
+ Forward pass through SAE decoder.
51
+
52
+ Args:
53
+ activations: Feature activations [batch, ..., dict_size]
54
+
55
+ Returns:
56
+ reconstruction: Reconstructed features [batch, ..., act_size]
57
+ """
58
+ if not hasattr(self, 'b_dec'):
59
+ raise RuntimeError("b_dec not set. This should be set automatically by SAE/LightningSAE during initialization.")
60
+
61
+ reconstruction = activations @ self.W_dec + self.b_dec
62
+
63
+ # Apply postprocessing if needed
64
+ if self.input_unit_norm:
65
+ # Note: We don't have x_mean and x_std here, so we skip postprocessing
66
+ # This is fine since preprocessing/postprocessing are typically used together
67
+ pass
68
+
69
+ if self.output_unit_norm:
70
+ reconstruction = reconstruction / reconstruction.norm(dim=-1, keepdim=True)
71
+
72
+ return reconstruction
73
+
74
+ @torch.no_grad()
75
+ def make_decoder_weights_and_grad_unit_norm(self):
76
+ """Normalize decoder weights and adjust gradients."""
77
+ W_dec_normed = self.W_dec / self.W_dec.norm(dim=-1, keepdim=True)
78
+ if self.W_dec.grad is not None:
79
+ W_dec_grad_proj = (self.W_dec.grad * W_dec_normed).sum(
80
+ -1, keepdim=True
81
+ ) * W_dec_normed
82
+ self.W_dec.grad -= W_dec_grad_proj
83
+ self.W_dec.data = W_dec_normed
steerable_retrieval/models/sae/encoders.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SAE encoder classes that return only activations."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+ try:
8
+ from overcomplete.optimization import batched_matrix_nnls
9
+ except ImportError:
10
+ batched_matrix_nnls = None
11
+
12
+ from steerable_retrieval.models.sae.penalties import (
13
+ VanillaPenalty, TopKPenalty, BatchTopKPenalty,
14
+ MatryoshkaBatchTopKPenalty, JumpReLU,
15
+ )
16
+
17
+
18
+ class SAEEncoder(nn.Module):
19
+ """Base SAE encoder that takes audio/text and returns activations."""
20
+
21
+ def __init__(self, act_size, dict_size, device='cuda',
22
+ input_unit_norm=False, seed=None, concat_dim=-1, **kwargs):
23
+ super().__init__()
24
+ self.act_size = act_size
25
+ self.dict_size = dict_size
26
+ self.device = device
27
+ self.dtype = torch.float32 # Automatic dtype
28
+ self.input_unit_norm = input_unit_norm
29
+ self.concat_dim = concat_dim
30
+ self.cfg = {
31
+ "act_size": act_size,
32
+ "dict_size": dict_size,
33
+ "device": device,
34
+ "dtype": self.dtype,
35
+ "input_unit_norm": input_unit_norm,
36
+ **kwargs
37
+ }
38
+
39
+ if seed is not None:
40
+ torch.manual_seed(seed)
41
+
42
+ # Encoder weights and biases
43
+ self.b_dec = nn.Parameter(torch.zeros(act_size))
44
+ self.b_enc = nn.Parameter(torch.zeros(dict_size))
45
+ self.W_enc = nn.Parameter(
46
+ torch.nn.init.kaiming_uniform_(
47
+ torch.empty(act_size, dict_size)
48
+ )
49
+ )
50
+
51
+ # Track inactive features
52
+ self.num_batches_not_active = torch.zeros((dict_size,)).to(device)
53
+
54
+ self.to(self.dtype).to(device)
55
+
56
+ # Required by SAE class
57
+ self.out_channels = dict_size
58
+
59
+ def forward(self, x):
60
+ """
61
+ Forward pass through SAE encoder.
62
+
63
+ Args:
64
+ x: Input features tensor [batch, ..., act_size]
65
+ Can be audio or text features (already encoded by audio_encoder/text_encoder)
66
+
67
+ Returns:
68
+ activations: Feature activations [batch, ..., dict_size]
69
+ """
70
+ # Encode to get activations
71
+ pre, z = self._encode(x)
72
+
73
+ self.update_inactive_features(z)
74
+
75
+ return pre, z
76
+
77
+ def _encode(self, x):
78
+ """Internal encode method - to be overridden by subclasses."""
79
+ raise NotImplementedError
80
+
81
+ def preprocess_input(self, x):
82
+ if self.input_unit_norm:
83
+ x_mean = x.mean(dim=-1, keepdim=True)
84
+ x = x - x_mean
85
+ # unit norm the input
86
+ x = x / (x.norm(dim=-1, keepdim=True) + 1e-6)
87
+ return x, x_mean, None
88
+ else:
89
+ return x, None, None
90
+
91
+ def update_inactive_features(self, acts):
92
+ self.num_batches_not_active += (acts.sum(0) == 0).float()
93
+ self.num_batches_not_active[acts.sum(0) > 0] = 0
94
+
95
+
96
+ class VanillaSAEEncoder(SAEEncoder):
97
+ """Vanilla SAE encoder with standard ReLU activation."""
98
+
99
+ def _encode(self, x):
100
+ x, x_mean, x_std = self.preprocess_input(x)
101
+ x_cent = x - self.b_dec
102
+ pre_activations = x_cent @ self.W_enc + self.b_enc
103
+ acts = F.relu(pre_activations)
104
+ return pre_activations, acts
105
+
106
+
107
+ class TopKSAEEncoder(SAEEncoder):
108
+ """TopK SAE encoder - keeps top k activations per sample."""
109
+
110
+ def __init__(self, act_size, dict_size, top_k, device='cuda',
111
+ input_unit_norm=False, seed=None, concat_dim=-1, **kwargs):
112
+ super().__init__(act_size, dict_size, device, input_unit_norm, seed, concat_dim, top_k=top_k, **kwargs)
113
+ self.top_k = top_k
114
+ self.penalty = TopKPenalty(top_k)
115
+
116
+ def _init_decoder_weights(self, W_dec):
117
+ # initialize the encoder weight to the transpose of the decoder weight
118
+ self.W_enc.data.copy_(W_dec.T)
119
+
120
+ def _encode(self, x):
121
+ x, _, _ = self.preprocess_input(x)
122
+ x_cent = x - self.b_dec
123
+ pre_activations = x_cent @ self.W_enc
124
+ acts = F.relu(pre_activations)
125
+ acts = self.penalty(acts)
126
+ return pre_activations, acts
127
+
128
+
129
+ class BatchTopKSAEEncoder(SAEEncoder):
130
+ """
131
+ Batch Top-k SAE encoder (Bussmann et al., 2024).
132
+
133
+ Retains only the top-k global activations across the entire batch.
134
+ During training, the k-th highest activation (over the flattened batch)
135
+ is used as the threshold and a running average is maintained with
136
+ exponential momentum. At eval time the running threshold is used,
137
+ making inference deterministic and batch-size independent.
138
+ """
139
+
140
+ def __init__(self, act_size, dict_size, top_k, threshold_momentum=0.9,
141
+ device='cuda', input_unit_norm=False, seed=None, concat_dim=-1, **kwargs):
142
+ super().__init__(act_size, dict_size, device, input_unit_norm, seed, concat_dim,
143
+ top_k=top_k, threshold_momentum=threshold_momentum, **kwargs)
144
+ self.top_k = top_k
145
+ self.penalty = BatchTopKPenalty(top_k, threshold_momentum=threshold_momentum)
146
+ self.register_buffer("running_threshold", None)
147
+ self.threshold_momentum = threshold_momentum
148
+ def _init_decoder_weights(self, W_dec):
149
+ self.W_enc.data.copy_(W_dec.T)
150
+
151
+ def _encode(self, x):
152
+ x, _, _ = self.preprocess_input(x)
153
+ x_cent = x - self.b_dec
154
+ pre_activations = x_cent @ self.W_enc
155
+ acts = F.relu(pre_activations)
156
+ threshold = self.penalty._get_threshold(acts)
157
+ threshold = self._update_threshold(threshold)
158
+ acts = self.penalty(acts, threshold)
159
+ return pre_activations, acts
160
+
161
+ def _rectify(self, acts):
162
+ threshold = self.penalty._get_threshold(acts)
163
+ threshold = self._update_threshold(threshold)
164
+ acts = self.penalty(acts, threshold)
165
+ return acts
166
+
167
+ def _update_threshold(self, threshold):
168
+ if self.training:
169
+ if self.running_threshold is None:
170
+ self.running_threshold = threshold.detach()
171
+ else:
172
+ self.running_threshold = (
173
+ self.threshold_momentum * self.running_threshold
174
+ + (1 - self.threshold_momentum) * threshold.detach()
175
+ )
176
+ return threshold
177
+ else:
178
+ if self.running_threshold is None:
179
+ self.running_threshold = threshold.detach()
180
+ return threshold
181
+ else:
182
+ return self.running_threshold
183
+
184
+
185
+
186
+
187
+
188
+ class MatryoshkaBatchTopKSAEEncoder(BatchTopKSAEEncoder):
189
+ """
190
+ Matryoshka Batch Top-k SAE encoder.
191
+
192
+ Combines nested feature groups (Matryoshka) with global batch-level TopK
193
+ sparsity. ``group_sizes`` defines the nested structure and implicitly sets
194
+ ``dict_size = sum(group_sizes)``. Features beyond the currently active
195
+ groups are zeroed out by the penalty.
196
+ """
197
+
198
+ def __init__(self, act_size, group_sizes, top_k, threshold_momentum=0.9,
199
+ device='cuda', input_unit_norm=False, seed=None, concat_dim=-1, **kwargs):
200
+ dict_size = sum(group_sizes)
201
+ super().__init__(act_size, dict_size, top_k, threshold_momentum,
202
+ device, input_unit_norm, seed, concat_dim, **kwargs)
203
+ self.group_sizes = group_sizes
204
+ # Replace the BatchTopKPenalty created by parent with Matryoshka variant
205
+ self.penalty = MatryoshkaBatchTopKPenalty(top_k, group_sizes, threshold_momentum)
206
+
207
+
208
+ class JumpReLUSAEEncoder(SAEEncoder):
209
+ """JumpReLU SAE encoder with learnable thresholds."""
210
+
211
+ def __init__(self, act_size, dict_size, bandwidth=0.1, device='cuda',
212
+ input_unit_norm=False, use_pre_enc_bias=False, seed=None, concat_dim=-1, **kwargs):
213
+ super().__init__(act_size, dict_size, device, input_unit_norm, seed, concat_dim,
214
+ bandwidth=bandwidth, use_pre_enc_bias=use_pre_enc_bias, **kwargs)
215
+ self.bandwidth = bandwidth
216
+ self.use_pre_enc_bias = use_pre_enc_bias
217
+ self.jumprelu = JumpReLU(feature_size=dict_size, bandwidth=bandwidth, device=device)
218
+
219
+ def _encode(self, x):
220
+ x, _, _ = self.preprocess_input(x)
221
+
222
+ if self.use_pre_enc_bias:
223
+ x = x - self.b_dec
224
+
225
+ pre_activations = x @ self.W_enc + self.b_enc
226
+ acts = self.jumprelu(acts)
227
+ return pre_activations, acts
228
+
229
+
230
+ class OMPEncoder(SAEEncoder):
231
+ """
232
+ Orthogonal Matching Pursuit SAE encoder.
233
+
234
+ Uses OMP to find sparse codes: at each iteration, select the atom most correlated
235
+ with the residual, then solve NNLS over selected atoms. Requires the decoder to be
236
+ set via set_decoder(decoder) so the encoder can use decoder.W_dec as the dictionary.
237
+ Encoding is non-differentiable (no_grad). Use penalty=None when using this encoder.
238
+ """
239
+
240
+ def __init__(self, act_size, dict_size, k=1, dropout=None, max_iter=10,
241
+ device='cuda', input_unit_norm=False, seed=None, concat_dim=-1, **kwargs):
242
+ assert isinstance(k, int) and k > 0, "k must be a positive integer."
243
+ if dropout is not None:
244
+ assert 0.0 <= dropout <= 1.0, "Dropout must be in range [0, 1]."
245
+ assert isinstance(max_iter, int) and max_iter > 0, "max_iter must be a positive integer."
246
+ if batched_matrix_nnls is None:
247
+ raise ImportError("OMPEncoder requires overcomplete.optimization.batched_matrix_nnls")
248
+ super().__init__(act_size, dict_size, device, input_unit_norm, seed, concat_dim, **kwargs)
249
+ self.k = k
250
+ self.dropout = dropout
251
+ self.max_iter = max_iter
252
+ self._decoder = None
253
+
254
+ def set_decoder(self, decoder):
255
+ """Set the decoder so this encoder can use its W_dec as the dictionary."""
256
+ self._decoder = decoder
257
+
258
+ def _encode(self, x):
259
+ if self._decoder is None:
260
+ raise RuntimeError("OMPEncoder requires decoder to be set. Call set_decoder(decoder) or use SAE/LightningSAE which does this automatically.")
261
+ W = self._decoder.W_dec # [dict_size, act_size]
262
+ device = W.device
263
+ dtype = W.dtype
264
+ k = self.k
265
+ max_iter = self.max_iter
266
+
267
+ if self.dropout is not None and self.training:
268
+ drop_mask = torch.bernoulli(
269
+ (1.0 - self.dropout) * torch.ones(W.shape[0], device=device, dtype=dtype)
270
+ )
271
+ W = W * drop_mask.unsqueeze(1)
272
+
273
+ x, _, _ = self.preprocess_input(x)
274
+ x = x.to(device=device, dtype=dtype)
275
+ batch_size = x.shape[0]
276
+ codes = torch.zeros(batch_size, self.dict_size, device=device, dtype=dtype)
277
+ residual = x.clone()
278
+ selected_atoms = None
279
+
280
+ with torch.no_grad():
281
+ for _ in range(k):
282
+ z_corr = residual @ W.T # [B, dict_size]
283
+ if selected_atoms is not None:
284
+ z_corr.scatter_(dim=1, index=selected_atoms, value=-torch.inf)
285
+ _, idx = torch.topk(z_corr, k=1, dim=1) # [B, 1]
286
+ selected_atoms = idx if selected_atoms is None else torch.cat([selected_atoms, idx], dim=1) # [B, num_selected]
287
+ W_sel = W[selected_atoms] # [B, num_selected, act_size]
288
+ Z_init = torch.gather(codes, 1, selected_atoms) # [B, num_selected]
289
+ codes_sel = batched_matrix_nnls(
290
+ W_sel, x, max_iter=max_iter, tol=1e-5, Z_init=Z_init
291
+ )
292
+ codes.scatter_(dim=1, index=selected_atoms, src=codes_sel)
293
+ residual = x - codes @ W
294
+
295
+ return None, codes
296
+
297
+ def train(self, mode=True):
298
+ if not mode:
299
+ self.dropout = None
300
+ return super().train(mode)
301
+
302
+
303
+ class MPEncoder(SAEEncoder):
304
+ """
305
+ Matching Pursuit SAE encoder.
306
+
307
+ Greedy MP: at each of k iterations, pick the atom most correlated with the
308
+ residual, add that contribution to the codes, and subtract it from the residual.
309
+ No NNLS (unlike OMP). Requires the decoder to be set via set_decoder(decoder).
310
+ Encoding is non-differentiable (no_grad). Use penalty=None when using this encoder.
311
+ """
312
+
313
+ def __init__(self, act_size, dict_size, k=1, dropout=None,
314
+ device='cuda', input_unit_norm=False, seed=None, concat_dim=-1, **kwargs):
315
+ assert isinstance(k, int) and k > 0, "k must be a positive integer."
316
+ if dropout is not None:
317
+ assert 0.0 <= dropout <= 1.0, "Dropout must be in range [0, 1]."
318
+ super().__init__(act_size, dict_size, device, input_unit_norm, seed, concat_dim, **kwargs)
319
+ self.k = k
320
+ self.dropout = dropout
321
+ self._decoder = None
322
+
323
+ def set_decoder(self, decoder):
324
+ """Set the decoder so this encoder can use its W_dec as the dictionary."""
325
+ self._decoder = decoder
326
+
327
+ def _encode(self, x):
328
+ if self._decoder is None:
329
+ raise RuntimeError(
330
+ "MPEncoder requires decoder to be set. Call set_decoder(decoder) or use SAE/LightningSAE which does this automatically."
331
+ )
332
+ W = self._decoder.W_dec # [dict_size, act_size]
333
+ device = W.device
334
+ dtype = W.dtype
335
+
336
+ if self.dropout is not None and self.training:
337
+ drop_w = torch.bernoulli(
338
+ (1.0 - self.dropout) * torch.ones(W.shape[0], 1, device=device, dtype=dtype)
339
+ )
340
+ W = W * drop_w
341
+
342
+ x, _, _ = self.preprocess_input(x)
343
+ x = x.to(device=device, dtype=dtype)
344
+ codes = torch.zeros(x.shape[0], self.dict_size, device=device, dtype=dtype)
345
+ residual = x.clone()
346
+
347
+ with torch.no_grad():
348
+ for _ in range(self.k):
349
+ z = residual @ W.T # [B, dict_size]
350
+ val, idx = torch.max(z, dim=1) # val [B], idx [B]
351
+ to_add = F.one_hot(idx, num_classes=self.dict_size).float() * val.unsqueeze(1)
352
+ codes = codes + to_add
353
+ residual = residual - to_add @ W
354
+
355
+ return None, codes
356
+
357
+ def train(self, mode=True):
358
+ if not mode:
359
+ self.dropout = None
360
+ return super().train(mode)
steerable_retrieval/models/sae/penalties.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Penalty functions for SAE activations."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+
7
+ import torch.autograd as autograd
8
+
9
+
10
+ class VanillaPenalty(nn.Module):
11
+ """No penalty - just pass through activations."""
12
+
13
+ def forward(self, activations):
14
+ return activations
15
+
16
+
17
+ class TopKPenalty(nn.Module):
18
+ """TopK penalty - keep only top k activations per sample."""
19
+
20
+ def __init__(self, top_k):
21
+ super().__init__()
22
+ self.top_k = top_k
23
+
24
+ def forward(self, activations):
25
+ acts_topk = torch.topk(activations, self.top_k, dim=-1)
26
+ acts_topk = torch.zeros_like(activations).scatter(
27
+ -1, acts_topk.indices, acts_topk.values
28
+ )
29
+ return acts_topk
30
+
31
+
32
+ class BatchTopKPenalty(nn.Module):
33
+ """
34
+ Batch Top-k penalty (Bussmann et al., 2024).
35
+
36
+ During training, finds the k-th highest activation across the flattened
37
+ batch and uses it as a threshold. A running average of this threshold is
38
+ maintained via exponential momentum so that at eval time the threshold is
39
+ deterministic and batch-size independent.
40
+ """
41
+
42
+ def __init__(self, top_k, threshold_momentum=0.9):
43
+ super().__init__()
44
+ self.top_k = top_k
45
+ self.threshold_momentum = threshold_momentum
46
+
47
+ def forward(self, activations, threshold=None):
48
+ mask = (activations >= threshold).float().detach()
49
+ return activations * mask
50
+
51
+ def _get_threshold(self, activations):
52
+ acts_topk = torch.topk(activations.flatten(), self.top_k * activations.shape[0], dim = -1)
53
+ return acts_topk.values[-1]
54
+
55
+
56
+
57
+ class MatryoshkaBatchTopKPenalty(BatchTopKPenalty):
58
+ """
59
+ Batch Top-k penalty with Matryoshka group masking.
60
+
61
+ After applying the global BatchTopK threshold, features beyond the
62
+ currently active groups are zeroed out. ``active_groups`` defaults to
63
+ all groups and can be reduced at inference time to use fewer features.
64
+ """
65
+
66
+ def __init__(self, top_k, group_sizes, threshold_momentum=0.9):
67
+ super().__init__(top_k, threshold_momentum)
68
+ self.group_sizes = group_sizes
69
+ self.group_indices = [0] + torch.cumsum(torch.tensor(group_sizes), dim=0).tolist()
70
+ self.active_groups = len(group_sizes)
71
+
72
+ def forward(self, activations, threshold=None):
73
+ acts = super().forward(activations, threshold)
74
+ max_idx = self.group_indices[self.active_groups]
75
+ if max_idx < acts.shape[-1]:
76
+ acts = acts.clone()
77
+ acts[..., max_idx:] = 0
78
+ return acts
79
+
80
+
81
+ class L1Penalty(nn.Module):
82
+ """L1 penalty - ReLU activation."""
83
+
84
+ def forward(self, activations):
85
+ return F.relu(activations)
86
+
87
+
88
+
89
+ class RectangleFunction(autograd.Function):
90
+ @staticmethod
91
+ def forward(ctx, x):
92
+ ctx.save_for_backward(x)
93
+ return ((x > -0.5) & (x < 0.5)).float()
94
+
95
+ @staticmethod
96
+ def backward(ctx, grad_output):
97
+ (x,) = ctx.saved_tensors
98
+ grad_input = grad_output.clone()
99
+ grad_input[(x <= -0.5) | (x >= 0.5)] = 0
100
+ return grad_input
101
+
102
+
103
+ class JumpReLUFunction(autograd.Function):
104
+ @staticmethod
105
+ def forward(ctx, x, log_threshold, bandwidth):
106
+ ctx.save_for_backward(x, log_threshold, torch.tensor(bandwidth))
107
+ threshold = torch.exp(log_threshold)
108
+ return x * (x > threshold).float()
109
+
110
+ @staticmethod
111
+ def backward(ctx, grad_output):
112
+ x, log_threshold, bandwidth_tensor = ctx.saved_tensors
113
+ bandwidth = bandwidth_tensor.item()
114
+ threshold = torch.exp(log_threshold)
115
+ x_grad = (x > threshold).float() * grad_output
116
+ threshold_grad = (
117
+ -(threshold / bandwidth)
118
+ * RectangleFunction.apply((x - threshold) / bandwidth)
119
+ * grad_output
120
+ )
121
+ return x_grad, threshold_grad, None
122
+
123
+
124
+ class JumpReLU(nn.Module):
125
+ def __init__(self, feature_size, bandwidth, device='cpu'):
126
+ super(JumpReLU, self).__init__()
127
+ self.log_threshold = nn.Parameter(torch.zeros(feature_size, device=device))
128
+ self.bandwidth = bandwidth
129
+
130
+ def forward(self, x):
131
+ return JumpReLUFunction.apply(x, self.log_threshold, self.bandwidth)
132
+
133
+
134
+ class StepFunction(autograd.Function):
135
+ @staticmethod
136
+ def forward(ctx, x, log_threshold, bandwidth):
137
+ ctx.save_for_backward(x, log_threshold, torch.tensor(bandwidth))
138
+ threshold = torch.exp(log_threshold)
139
+ return (x > threshold).float()
140
+
141
+ @staticmethod
142
+ def backward(ctx, grad_output):
143
+ x, log_threshold, bandwidth_tensor = ctx.saved_tensors
144
+ bandwidth = bandwidth_tensor.item()
145
+ threshold = torch.exp(log_threshold)
146
+ x_grad = torch.zeros_like(x)
147
+ threshold_grad = (
148
+ -(1.0 / bandwidth)
149
+ * RectangleFunction.apply((x - threshold) / bandwidth)
150
+ * grad_output
151
+ )
152
+ return x_grad, threshold_grad, None
steerable_retrieval/models/sae/sae.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import lightning as L
4
+ from steerable_retrieval.models.base import BaseModule
5
+ from steerable_retrieval.utils.pylogger import RankedLogger
6
+
7
+ log = RankedLogger(__name__, rank_zero_only=True)
8
+
9
+ # Loss key -> log prefix (term first: L0/Train/..., L1/val/dataset/...)
10
+ LOSS_KEY_TO_PREFIX = {
11
+ "l0_norm": "L0",
12
+ "l1_loss": "L1",
13
+ "l2_loss": "L2",
14
+ "l1_norm": "L1_norm",
15
+ "aux_loss": "aux_loss",
16
+ "loss": "loss",
17
+ "mse": "mse",
18
+ "r2": "r2",
19
+ "ndn": "ndn",
20
+ "min_l2_loss": "min_L2",
21
+ "max_l2_loss": "max_L2",
22
+ }
23
+
24
+ import logging
25
+
26
+ class SAE(BaseModule):
27
+
28
+ def __init__(self,
29
+ audio_encoder,
30
+ text_encoder,
31
+ sae_encoder,
32
+ sae_decoder,
33
+ ckpt_path = None,
34
+ freeze = False,
35
+ tied = False,
36
+ **kwargs):
37
+ super().__init__(ckpt_path=ckpt_path, freeze=freeze)
38
+ self.ckpt_path = ckpt_path
39
+ self.freeze = freeze
40
+
41
+ # Store encoder/decoder components as instance attributes
42
+ self.audio_encoder = audio_encoder
43
+ self.text_encoder = text_encoder
44
+ self.sae_encoder = sae_encoder
45
+ self.sae_decoder = sae_decoder
46
+
47
+ # Share b_dec between encoder and decoder
48
+ if not hasattr(self.sae_decoder, 'b_dec'):
49
+ self.sae_decoder.b_dec = self.sae_encoder.b_dec
50
+
51
+ # OMP-style encoders need the decoder's dictionary (W_dec) at encode time
52
+ if getattr(self.sae_encoder, 'set_decoder', None) is not None:
53
+ self.sae_encoder.set_decoder(self.sae_decoder)
54
+
55
+ if hasattr(self.sae_encoder, '_init_decoder_weights'):
56
+ self.sae_encoder._init_decoder_weights(
57
+ self.sae_decoder.W_dec
58
+ )
59
+
60
+ self.tied = tied
61
+
62
+ # get the number of concepts from the output of the sae_encoder
63
+ self.num_concepts = sae_encoder.out_channels
64
+
65
+ def forward(self, x):
66
+ """
67
+ Forward pass through SAE. Expects already-encoded features [B, act_size].
68
+ Encoding (audio_encoder / text_encoder) is done at training/validation step level.
69
+ """
70
+ if self.tied:
71
+ if hasattr(self.sae_encoder, '_init_decoder_weights'):
72
+ self.sae_encoder._init_decoder_weights(
73
+ self.sae_decoder.W_dec
74
+ )
75
+ pre, z = self.sae_encoder(x)
76
+ xhat = self.sae_decoder(z)
77
+ return x, z, xhat, pre
78
+
79
+ @torch.no_grad()
80
+ def inference(self, x, matryoshka_k=-1):
81
+ """
82
+ Inference pass. Expects already-encoded features [B, act_size].
83
+ Caller must encode with audio_encoder or text_encoder before calling.
84
+ """
85
+ pre, z = self.sae_encoder(x)
86
+ xhat = self.sae_decoder(z)
87
+ return x, z, xhat, pre
88
+
89
+ class LightningSAE(SAE, L.LightningModule):
90
+
91
+ def __init__(self,
92
+ audio_encoder,
93
+ text_encoder,
94
+ sae_encoder,
95
+ sae_decoder,
96
+ optimizer = None,
97
+ scheduler = None,
98
+ loss_fn = None,
99
+ ckpt_path = None,
100
+ freeze = False,
101
+ preextracted_features = False,
102
+ tied = False,
103
+ data_mixing_ratio = 0.5,
104
+ **kwargs):
105
+ L.LightningModule.__init__(self)
106
+ super(LightningSAE, self).__init__(
107
+ audio_encoder=audio_encoder,
108
+ text_encoder=text_encoder,
109
+ sae_encoder=sae_encoder,
110
+ sae_decoder=sae_decoder,
111
+ ckpt_path=ckpt_path,
112
+ freeze=freeze,
113
+ tied=tied,
114
+ **kwargs
115
+ )
116
+ self.optimizer = optimizer
117
+ self.scheduler = scheduler
118
+ self.loss_fn = loss_fn
119
+ self.preextracted_features = preextracted_features
120
+ self.reset_activations_dicts()
121
+ self.data_mixing_ratio = data_mixing_ratio
122
+
123
+ def state_dict(self, destination=None, prefix='', keep_vars=False):
124
+ """
125
+ Override state_dict to only save SAE encoder/decoder weights,
126
+ excluding audio_encoder and text_encoder.
127
+ """
128
+ state_dict = super().state_dict(destination=destination, prefix=prefix, keep_vars=keep_vars)
129
+
130
+ # Filter out audio_encoder and text_encoder parameters
131
+ filtered_state_dict = {}
132
+ for key, value in state_dict.items():
133
+ if not (key.startswith('audio_encoder.') or key.startswith('text_encoder.')):
134
+ filtered_state_dict[key] = value
135
+
136
+ if hasattr(self, 'bridges'):
137
+ filtered_state_dict['bridges'] = self.bridges
138
+ else:
139
+ logging.warning("No bridges found in state_dict")
140
+
141
+ return filtered_state_dict
142
+
143
+ def load_state_dict(self, state_dict, strict=True):
144
+ """
145
+ Override load_state_dict to only load SAE encoder/decoder weights.
146
+ """
147
+ # Restore non-parameter bridge artifacts saved by callbacks.
148
+ # These are plain dicts, not nn.Parameters/buffers, so we need to set them manually.
149
+ bridges = state_dict.pop('bridges', None)
150
+ if bridges is None:
151
+ logging.warning("No bridges found in state_dict")
152
+ if bridges is not None:
153
+ self.bridges = bridges
154
+
155
+ # Filter out audio_encoder and text_encoder from the state_dict if present
156
+ filtered_state_dict = {}
157
+ for key, value in state_dict.items():
158
+ if not (key.startswith('audio_encoder.') or key.startswith('text_encoder.')):
159
+ filtered_state_dict[key] = value
160
+
161
+ return super().load_state_dict(filtered_state_dict, strict=strict)
162
+
163
+ def reset_activations_dicts(self):
164
+ """Reset activation and embedding dictionaries for all dataloaders."""
165
+ # Initialize empty dicts; will be populated per dataloader_idx
166
+ self.val_activations = {}
167
+ self.test_activations = {}
168
+ self.val_embeddings = {}
169
+ self.test_embeddings = {}
170
+
171
+ def _get_dataset_name(self, dataloader_idx: int, mode: str) -> str:
172
+ """Get dataset name for a dataloader from datamodule.names (same logic as callbacks)."""
173
+ dm = getattr(self.trainer, "datamodule", None)
174
+ if dm is not None and hasattr(dm, "names"):
175
+ names = dm.names
176
+ if isinstance(names, dict) and mode in names:
177
+ name = names[mode].get(dataloader_idx)
178
+ if name is not None:
179
+ return str(name)
180
+ return f"dataloader_{dataloader_idx}"
181
+
182
+ def _get_or_init_dataloader_storage(self, dataloader_idx, mode='val'):
183
+ """Get or initialize the activation and embedding dicts for a specific dataloader_idx."""
184
+ act_store = self.val_activations if mode == 'val' else self.test_activations
185
+ emb_store = self.val_embeddings if mode == 'val' else self.test_embeddings
186
+ if dataloader_idx not in act_store:
187
+ act_store[dataloader_idx] = {'audio': [], 'text': []}
188
+ if dataloader_idx not in emb_store:
189
+ emb_store[dataloader_idx] = {'audio': [], 'text': []}
190
+ return act_store[dataloader_idx], emb_store[dataloader_idx]
191
+
192
+ def _forward_and_loss(self, encoded, loss_kw):
193
+ """Forward pass + loss computation. Returns (loss_dict, activations, pre_penalty_activations)."""
194
+ _, activations, reconstruction, pre_penalty_activations = self(encoded)
195
+ loss_kw = {**loss_kw, "pre_penalty_activations": pre_penalty_activations}
196
+ loss_dict = self.loss_fn(reconstruction, activations, encoded, **loss_kw)
197
+ return loss_dict, activations, pre_penalty_activations
198
+
199
+ def _log_loss_dict(self, loss_dict, stage, modality=None):
200
+ """Log all keys in loss_dict with appropriate prefixes."""
201
+ for key, val in loss_dict.items():
202
+ p = LOSS_KEY_TO_PREFIX.get(key, key)
203
+ suffix = f"/{modality}" if modality else ""
204
+ self.log(f'{p}/{stage}{suffix}', val, prog_bar=True, on_step=True, sync_dist=True)
205
+
206
+ def training_step(self, batch, batch_idx):
207
+ audio, text = batch['audio'], batch['prompt']
208
+ has_audio = audio is not None and self.data_mixing_ratio <1.0
209
+ has_text = text is not None and self.data_mixing_ratio >0.0
210
+ loss_kw = dict(
211
+ num_batches_not_active=getattr(self.sae_encoder, 'num_batches_not_active', None),
212
+ decoder=self.sae_decoder,
213
+ )
214
+
215
+ # Encode inputs
216
+ encoded_audio = (self.audio_encoder(audio) if not self.preextracted_features else audio) if has_audio else None
217
+ encoded_text = self.text_encoder(text) if has_text else None
218
+
219
+ if has_audio and has_text:
220
+ # Half-and-half: take half of each modality
221
+ ratio = self.data_mixing_ratio
222
+ n_text = int(len(text) * ratio) # if ratio = 1, only text is used
223
+ n_audio = len(audio) - n_text # if ratio = 0, only audio is used
224
+ encoded_audio, encoded_text = encoded_audio[:n_audio], encoded_text[:n_text]
225
+ encoded = torch.cat([encoded_audio, encoded_text], dim=0)
226
+ n_audio = encoded_audio.size(0)
227
+
228
+ _, activations, reconstruction, pre_penalty_activations = self(encoded)
229
+
230
+ # Split outputs and compute losses per modality
231
+ def split(t): return (t[:n_audio], t[n_audio:]) if t is not None else (None, None)
232
+ enc_a, enc_t = split(encoded)
233
+ act_a, act_t = split(activations)
234
+ rec_a, rec_t = split(reconstruction)
235
+ pre_a, pre_t = split(pre_penalty_activations)
236
+ modality_losses = {}
237
+ if enc_a is not None and enc_a.size(0) > 0:
238
+ modality_losses["audio"] = self.loss_fn(
239
+ rec_a, act_a, enc_a, **{**loss_kw, "pre_penalty_activations": pre_a}
240
+ )
241
+ self._log_loss_dict(modality_losses["audio"], "Train", "audio")
242
+ if enc_t is not None and enc_t.size(0) > 0:
243
+ modality_losses["text"] = self.loss_fn(
244
+ rec_t, act_t, enc_t, **{**loss_kw, "pre_penalty_activations": pre_t}
245
+ )
246
+ self._log_loss_dict(modality_losses["text"], "Train", "text")
247
+
248
+ if not modality_losses:
249
+ raise ValueError(
250
+ "Both audio and text slices are empty after applying data_mixing_ratio "
251
+ f"({self.data_mixing_ratio})."
252
+ )
253
+
254
+ # Log averaged total over modalities that are actually present.
255
+ first_loss_dict = next(iter(modality_losses.values()))
256
+ for key in first_loss_dict:
257
+ vals = [loss_dict[key] for loss_dict in modality_losses.values() if key in loss_dict]
258
+ if not vals:
259
+ continue
260
+ p = LOSS_KEY_TO_PREFIX.get(key, key)
261
+ self.log(
262
+ f'{p}/Train/total',
263
+ sum(vals) / len(vals),
264
+ prog_bar=True,
265
+ on_step=True,
266
+ sync_dist=True,
267
+ )
268
+ total_loss = sum(loss_dict["loss"] for loss_dict in modality_losses.values()) / len(modality_losses)
269
+ else:
270
+ # Single modality
271
+ encoded = encoded_audio if has_audio else encoded_text
272
+ modality = "audio" if has_audio else "text"
273
+ loss_dict, _, _ = self._forward_and_loss(encoded, loss_kw)
274
+ self._log_loss_dict(loss_dict, "Train", modality)
275
+ total_loss = loss_dict['loss']
276
+
277
+ self.log('loss/Train/total', total_loss, prog_bar=True, on_step=True, sync_dist=True)
278
+ return total_loss
279
+
280
+ def _eval_step(self, batch, batch_idx, dataloader_idx, stage):
281
+ """Shared logic for validation_step and test_step."""
282
+ audio, text = batch['audio'], batch['prompt']
283
+ dataset_name = self._get_dataset_name(dataloader_idx, stage)
284
+ acts, embs = self._get_or_init_dataloader_storage(dataloader_idx, mode=stage)
285
+ loss_kw = dict(
286
+ num_batches_not_active=getattr(self.sae_encoder, 'num_batches_not_active', None),
287
+ decoder=self.sae_decoder,
288
+ )
289
+ loss_dicts = {}
290
+
291
+ # Process each modality
292
+ for modality, data, encoder in [
293
+ ('audio', audio, lambda x: self.audio_encoder(x) if not self.preextracted_features else x),
294
+ ('text', text, self.text_encoder),
295
+ ]:
296
+ if data is None:
297
+ continue
298
+ encoded = encoder(data)
299
+ loss_dict, activations, _ = self._forward_and_loss(encoded, loss_kw)
300
+ loss_dicts[modality] = loss_dict
301
+ acts[modality].append(activations)
302
+ embs[modality].append(encoded.detach())
303
+ for key, val in loss_dict.items():
304
+ p = LOSS_KEY_TO_PREFIX.get(key, key)
305
+ self.log(f'{p}/{stage}/{dataset_name}/{modality}', val, sync_dist=True)
306
+
307
+ # Compute total loss
308
+ if not loss_dicts:
309
+ raise ValueError(f"Both audio and text are None in {stage} batch")
310
+ if len(loss_dicts) == 2:
311
+ for key in loss_dicts['audio']:
312
+ p = LOSS_KEY_TO_PREFIX.get(key, key)
313
+ avg = (loss_dicts['audio'][key] + loss_dicts['text'][key]) / 2
314
+ self.log(f'{p}/{stage}/{dataset_name}', avg, sync_dist=True)
315
+ total_loss = (loss_dicts['audio']['loss'] + loss_dicts['text']['loss']) / 2
316
+ else:
317
+ total_loss = next(iter(loss_dicts.values()))['loss']
318
+
319
+ self.log(f'loss/{stage}/{dataset_name}', total_loss, sync_dist=True)
320
+ return total_loss
321
+
322
+ def validation_step(self, batch, batch_idx, dataloader_idx=0):
323
+ return self._eval_step(batch, batch_idx, dataloader_idx, 'val')
324
+
325
+ def test_step(self, batch, batch_idx, dataloader_idx=0):
326
+ return self._eval_step(batch, batch_idx, dataloader_idx, 'test')
327
+
328
+ def on_validation_epoch_start(self):
329
+ super().on_validation_epoch_start()
330
+ self.reset_activations_dicts()
331
+
332
+ def on_test_epoch_start(self):
333
+ super().on_test_epoch_start()
334
+ self.reset_activations_dicts()
335
+
336
+ # def on_before_optimizer_step(self, optimizer):
337
+ # Ensure decoder directions stay unit-norm (TopK paper recipe)
338
+ # if hasattr(self.sae_decoder, "make_decoder_weights_and_grad_unit_norm"):
339
+ # self.sae_decoder.make_decoder_weights_and_grad_unit_norm()
340
+
341
+ def on_before_optimizer_step(self, optimizer):
342
+ with torch.no_grad():
343
+ self.sae_decoder.W_dec.data = self.sae_decoder.W_dec.data / (self.sae_decoder.W_dec.data.norm(dim=-1, keepdim=True) + 1e-8)
344
+
345
+
346
+
steerable_retrieval/models/utils/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model utilities."""
2
+
3
+ from steerable_retrieval.models.utils.losses import (
4
+ SAELoss,
5
+ VanillaSAELoss,
6
+ TopKSAELoss,
7
+ BatchTopKSAELoss,
8
+ JumpReLUSAELoss,
9
+ MatryoshkaBatchTopKSAELoss,
10
+ )
11
+
12
+ __all__ = [
13
+ 'SAELoss',
14
+ 'VanillaSAELoss',
15
+ 'TopKSAELoss',
16
+ 'BatchTopKSAELoss',
17
+ 'JumpReLUSAELoss',
18
+ 'MatryoshkaBatchTopKSAELoss',
19
+ ]
steerable_retrieval/models/utils/losses.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loss functions for SAE training."""
2
+
3
+ import torch
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+ from steerable_retrieval.models.sae.penalties import StepFunction
7
+
8
+
9
+ def r2_score(reconstruction: torch.Tensor, target: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
10
+ """Coefficient of determination (variance-weighted, aggregated over all outputs).
11
+
12
+ ``1 - SS_res / SS_tot`` with ``target`` as ground truth. Logged as a reconstruction
13
+ quality metric during SAE training.
14
+ """
15
+ reconstruction = reconstruction.float()
16
+ target = target.float()
17
+ ss_res = (target - reconstruction).pow(2).sum()
18
+ ss_tot = (target - target.mean(dim=0, keepdim=True)).pow(2).sum()
19
+ return 1.0 - ss_res / (ss_tot + eps)
20
+
21
+
22
+ class SAELoss(nn.Module):
23
+ """
24
+ Base loss function for SAE training.
25
+ Computes loss from reconstruction, activations, and target.
26
+ """
27
+
28
+ def __init__(self, l1_coeff=1e-3, l2_coeff=1.0):
29
+ super().__init__()
30
+ self.l1_coeff = l1_coeff
31
+ self.l2_coeff = l2_coeff
32
+
33
+ def forward(self, reconstruction, activations, target, matryoshka=None, num_batches_not_active=None, decoder=None, **kwargs):
34
+ """
35
+ Compute SAE loss.
36
+
37
+ Args:
38
+ reconstruction: Reconstructed features [batch, ..., act_size]
39
+ activations: Feature activations [batch, ..., dict_size]
40
+ target: Target features [batch, ..., act_size]
41
+ matryoshka: Matryoshka module (optional, for compatibility)
42
+ num_batches_not_active: Tracking of inactive features (optional, for compatibility)
43
+ decoder: SAE decoder (optional, used by TopK/BatchTopK aux loss)
44
+
45
+ Returns:
46
+ Dictionary with loss components
47
+ """
48
+ # L2 reconstruction loss (MSE scaled by l2_coeff)
49
+ rec_err = (reconstruction.float() - target.float()).pow(2)
50
+ mse = rec_err.mean()
51
+ l2_loss = self.l2_coeff * mse
52
+
53
+ r2 = r2_score(reconstruction, target)
54
+ # dead_codes_frac = dead_codes(activations)
55
+ # ndn = dead_codes_frac.mean() if hasattr(dead_codes_frac, "mean") else dead_codes_frac
56
+
57
+ alive = (activations > 0).any(dim=0)
58
+ dead_ratio = 1 - alive.float().mean()
59
+
60
+ # L1 sparsity loss
61
+ l1_norm = activations.float().abs().sum(-1).mean()
62
+ l1_loss = self.l1_coeff * l1_norm
63
+
64
+ # L0 norm (number of active features)
65
+ l0_norm = (activations > 0).float().sum(-1).mean()
66
+
67
+ # Total loss
68
+ loss = l2_loss + l1_loss
69
+
70
+ return {
71
+ 'loss': loss,
72
+ 'l2_loss': l2_loss,
73
+ 'l1_loss': l1_loss,
74
+ 'l0_norm': l0_norm,
75
+ 'l1_norm': l1_norm,
76
+ 'mse': mse.detach(),
77
+ 'r2': r2,
78
+ 'ndn': dead_ratio.detach(),
79
+ }
80
+
81
+
82
+ class VanillaSAELoss(SAELoss):
83
+ """Loss for VanillaSAE - standard L1 + L2."""
84
+ pass
85
+
86
+
87
+ class TopKSAELoss(SAELoss):
88
+ """Loss for TopKSAE - includes auxiliary loss for dead features."""
89
+
90
+ def __init__(self, l1_coeff=1e-3, l2_coeff=1.0, aux_penalty=1.0, top_k_aux=10, n_batches_to_dead=10, orthogonal_loss=0.0):
91
+ super().__init__(l1_coeff, l2_coeff)
92
+ self.aux_penalty = aux_penalty
93
+ self.top_k_aux = top_k_aux
94
+ self.n_batches_to_dead = n_batches_to_dead
95
+ self.orthogonal_coeff = orthogonal_loss
96
+ def forward(self, reconstruction, activations, target,
97
+ num_batches_not_active=None, matryoshka=None, decoder=None,
98
+ pre_penalty_activations=None):
99
+ loss_dict = super().forward(reconstruction, activations, target)
100
+ aux_loss = self._get_auxiliary_loss(
101
+ target=target,
102
+ reconstruction=reconstruction,
103
+ activations=activations,
104
+ # num_batches_not_active=num_batches_not_active,
105
+ decoder=decoder,
106
+ pre_penalty_activations=pre_penalty_activations,
107
+ )
108
+ loss_dict['aux_loss'] = aux_loss
109
+ loss_dict['orthogonal_loss'] = self.offdiag_gram_loss(D = decoder.W_dec)
110
+ loss_dict['loss'] = loss_dict['loss'] + aux_loss + loss_dict['orthogonal_loss'] * self.orthogonal_coeff
111
+ return loss_dict
112
+
113
+
114
+ def _get_auxiliary_loss(
115
+ self,
116
+ target,
117
+ reconstruction,
118
+ activations,
119
+ decoder=None,
120
+ pre_penalty_activations=None,
121
+ ):
122
+ # Match their behavior: if missing inputs, aux=0
123
+ if decoder is None or pre_penalty_activations is None or self.aux_penalty <= 0:
124
+ return torch.tensor(0.0, device=target.device, dtype=target.dtype)
125
+
126
+ # IMPORTANT: order matters (their comment) => residual = x - x_hat
127
+ residual = (target - reconstruction)
128
+
129
+ # Their AuxK uses relu(pre) and removes chosen codes by subtracting post-TopK codes
130
+ aux_src = F.relu(pre_penalty_activations) - activations
131
+
132
+ # Choose top half of *non-chosen* activations (after subtraction)
133
+ k = max(1, aux_src.shape[-1] // 2)
134
+ topk = torch.topk(aux_src, k=k, dim=-1)
135
+
136
+ aux_codes = torch.zeros_like(aux_src).scatter(-1, topk.indices, topk.values)
137
+
138
+ # Predict residual using full dictionary; no bias term
139
+ residual_hat = aux_codes @ decoder.W_dec # [B, act_size]
140
+
141
+ aux_mse = (residual - residual_hat).pow(2).mean()
142
+ return self.aux_penalty * aux_mse
143
+
144
+
145
+ @staticmethod
146
+ def offdiag_gram_loss(
147
+ D: torch.Tensor,
148
+ atoms_dim: int | None = None,
149
+ normalize: bool = True,
150
+ squared: bool = True,
151
+ reduction: str = "mean",
152
+ eps: float = 1e-8,
153
+ ) -> torch.Tensor:
154
+ """
155
+ Penalize off-diagonal entries of the (optionally normalized) Gram matrix.
156
+
157
+ This encourages *incoherence* (low pairwise cosine similarity) between atoms,
158
+ without requiring strict orthogonality (which is infeasible when overcomplete).
159
+
160
+ Parameters
161
+ ----------
162
+ D : Tensor
163
+ Dictionary/decoder matrix, shape [A, d] or [d, A] where A=#atoms/features.
164
+ Examples:
165
+ - If W_dec is [dict_size, act_size], then atoms_dim=0.
166
+ - If W_dec is [act_size, dict_size], then atoms_dim=1.
167
+ atoms_dim : {0,1} or None
168
+ Which dimension indexes atoms. If None, we infer it by assuming atoms are
169
+ the larger dimension (common in overcomplete SAEs).
170
+ normalize : bool
171
+ If True, L2-normalize each atom before computing Gram (cosine coherence).
172
+ squared : bool
173
+ If True use L2 penalty on off-diagonals (sum of squares). If False use L1.
174
+ reduction : {"mean","sum"}
175
+ How to reduce over off-diagonal entries.
176
+ eps : float
177
+ Numerical stability for normalization.
178
+
179
+ Returns
180
+ -------
181
+ loss : Tensor
182
+ Scalar tensor.
183
+ """
184
+ A = D.clone()
185
+
186
+ if normalize:
187
+ A = A / (A.norm(dim=1, keepdim=True).clamp_min(eps))
188
+
189
+ # Gram: [A, A]
190
+ G = A @ A.t()
191
+
192
+ # Off-diagonal mask
193
+ n = G.shape[0]
194
+ if n <= 1:
195
+ return G.new_zeros(())
196
+ off = ~torch.eye(n, dtype=torch.bool, device=G.device)
197
+
198
+ vals = G[off]
199
+ if squared:
200
+ vals = vals * vals
201
+ else:
202
+ vals = vals.abs()
203
+
204
+ if reduction == "mean":
205
+ return vals.mean()
206
+ elif reduction == "sum":
207
+ return vals.sum()
208
+ else:
209
+ raise ValueError("reduction must be 'mean' or 'sum'")
210
+
211
+
212
+ class BatchTopKSAELoss(TopKSAELoss):
213
+ """Loss for BatchTopKSAE - same as TopK but with batch-level sparsity."""
214
+ pass
215
+
216
+
217
+ class JumpReLUSAELoss(SAELoss):
218
+ """Loss for JumpReLUSAE - uses L0 norm instead of L1."""
219
+
220
+ def __init__(self, l1_coeff=1e-3, l2_coeff=1.0, bandwidth=0.1):
221
+ super().__init__(l1_coeff, l2_coeff)
222
+ self.bandwidth = bandwidth
223
+
224
+ def forward(self, reconstruction, activations, target, log_threshold=None, matryoshka=None, num_batches_not_active=None, decoder=None):
225
+ """
226
+ Compute JumpReLU SAE loss.
227
+
228
+ Args:
229
+ reconstruction: Reconstructed features
230
+ activations: Feature activations
231
+ target: Target features
232
+ log_threshold: Log threshold parameter from JumpReLU (optional)
233
+ matryoshka: Matryoshka module (optional, for compatibility)
234
+ num_batches_not_active: Tracking of inactive features (optional, for compatibility)
235
+ """
236
+ # L2 reconstruction loss
237
+ rec_err = (reconstruction.float() - target.float()).pow(2)
238
+ mse = rec_err.mean()
239
+ l2_loss = self.l2_coeff * mse
240
+ target_centered = target.float() - target.float().mean(dim=0)
241
+ baseline_mse = (target_centered.pow(2)).mean().clamp_min(1e-12)
242
+ fvu = (mse / baseline_mse).detach()
243
+ ndn = (activations.abs().sum(dim=0) == 0).float().mean().detach()
244
+
245
+ # L0 norm using StepFunction if log_threshold is provided
246
+ if log_threshold is not None:
247
+ l0 = StepFunction.apply(activations, log_threshold, self.bandwidth).sum(dim=-1).mean()
248
+ else:
249
+ l0 = (activations > 0).float().sum(-1).mean()
250
+
251
+ l0_loss = self.l1_coeff * l0
252
+ l1_loss = l0_loss # For JumpReLU, L1 loss is the same as L0 loss
253
+
254
+ loss = l2_loss + l1_loss
255
+
256
+ return {
257
+ 'loss': loss,
258
+ 'l2_loss': l2_loss,
259
+ 'l1_loss': l1_loss,
260
+ 'l0_norm': l0,
261
+ 'l1_norm': l0,
262
+ 'mse': mse.detach(),
263
+ 'fvu': fvu,
264
+ 'ndn': ndn,
265
+ }
266
+
267
+ class MatryoshkaBatchTopKSAELoss(SAELoss):
268
+ """Loss for Matryoshka BatchTopK SAE with progressive reconstruction losses.
269
+
270
+ Computes intermediate reconstructions by progressively decoding each
271
+ feature group through ``decoder.W_dec`` slices. Supports two weighting
272
+ strategies from Zaigrajew et al. (2025):
273
+
274
+ * **uniform** (UW): ``alpha_i = 1`` for all granularity levels.
275
+ * **reverse** (RW): ``alpha_i = h - i + 1``, giving higher weight to
276
+ sparser (earlier) levels so the model prioritises reconstruction
277
+ quality at low feature counts.
278
+
279
+ Also includes a TopK-style auxiliary loss for dead features.
280
+ """
281
+
282
+ def __init__(self, group_sizes, l1_coeff=1e-3, l2_coeff=1.0,
283
+ aux_penalty=1.0, top_k_aux=10, n_batches_to_dead=10,
284
+ weighting='uniform'):
285
+ super().__init__(l1_coeff, l2_coeff)
286
+ self.group_sizes = group_sizes
287
+ self.group_indices = [0] + torch.cumsum(torch.tensor(group_sizes), dim=0).tolist()
288
+ self.aux_penalty = aux_penalty
289
+ self.top_k_aux = top_k_aux
290
+ self.n_batches_to_dead = n_batches_to_dead
291
+
292
+ h = len(group_sizes)
293
+ if weighting == 'uniform':
294
+ self.alphas = [1.0] * h
295
+ elif weighting == 'reverse':
296
+ self.alphas = [float(h - i) for i in range(h)]
297
+ else:
298
+ raise ValueError(f"Unknown weighting strategy '{weighting}'. Use 'uniform' or 'reverse'.")
299
+ self.alpha_sum = sum(self.alphas)
300
+
301
+ def forward(self, reconstruction, activations, target,
302
+ decoder=None, pre_penalty_activations=None,
303
+ num_batches_not_active=None, **kwargs):
304
+ """
305
+ Compute Matryoshka BatchTopK loss with progressive intermediate losses.
306
+
307
+ Args:
308
+ reconstruction: Final reconstructed features [batch, act_size]
309
+ activations: Sparse feature activations [batch, dict_size]
310
+ target: Target features [batch, act_size]
311
+ decoder: SAE decoder (required -- provides W_dec and b_dec)
312
+ pre_penalty_activations: Pre-activation features before ReLU/TopK [batch, dict_size]
313
+ num_batches_not_active: Per-feature inactivity counter (optional)
314
+
315
+ Returns:
316
+ Dictionary with loss components.
317
+ """
318
+ target_f = target.float()
319
+
320
+ # -- Intermediate reconstruction losses (progressive decoding) -------
321
+ b_dec = decoder.b_dec if decoder is not None else torch.zeros_like(target_f[0])
322
+
323
+ # Baseline: reconstruction using only b_dec (no features active)
324
+ baseline_l2 = self.l2_coeff * (b_dec - target_f).pow(2).mean()
325
+
326
+ x_recon = b_dec # accumulates progressive reconstruction
327
+ intermediate_l2s = []
328
+ for i in range(len(self.group_sizes)):
329
+ start = self.group_indices[i]
330
+ end = self.group_indices[i + 1]
331
+ x_recon = activations[..., start:end] @ decoder.W_dec[start:end] + x_recon
332
+ intermediate_l2s.append(
333
+ self.l2_coeff * (x_recon.float() - target_f).pow(2).mean()
334
+ )
335
+
336
+ # Weighted mean L2: baseline + weighted intermediates, divided by
337
+ # (1 + sum_of_weights) to match the reference's (h+1) averaging.
338
+ weighted_l2 = baseline_l2 + sum(
339
+ a * l for a, l in zip(self.alphas, intermediate_l2s)
340
+ )
341
+ mean_l2 = weighted_l2 / (1.0 + self.alpha_sum)
342
+
343
+ l2_stack = torch.stack(intermediate_l2s)
344
+ min_l2 = l2_stack.min()
345
+ max_l2 = l2_stack.max()
346
+
347
+ # -- Final-reconstruction metrics (mse, r2, ndn) --------------------
348
+ mse = (reconstruction.float() - target_f).pow(2).mean()
349
+ r2 = r2_score(reconstruction, target)
350
+ alive = (activations > 0).any(dim=0)
351
+ ndn = 1 - alive.float().mean()
352
+
353
+ # -- Sparsity losses -------------------------------------------------
354
+ l1_norm = activations.float().abs().sum(-1).mean()
355
+ l1_loss = self.l1_coeff * l1_norm
356
+ l0_norm = (activations > 0).float().sum(-1).mean()
357
+
358
+ # -- Auxiliary loss for dead features ---------------------------------
359
+ aux_loss = self._get_auxiliary_loss(
360
+ target=target,
361
+ reconstruction=reconstruction,
362
+ activations=activations,
363
+ decoder=decoder,
364
+ pre_penalty_activations=pre_penalty_activations,
365
+ )
366
+
367
+ loss = mean_l2 + l1_loss + aux_loss
368
+
369
+ return {
370
+ 'loss': loss,
371
+ 'l2_loss': mean_l2,
372
+ 'min_l2_loss': min_l2.detach(),
373
+ 'max_l2_loss': max_l2.detach(),
374
+ 'l1_loss': l1_loss,
375
+ 'l0_norm': l0_norm,
376
+ 'l1_norm': l1_norm,
377
+ 'aux_loss': aux_loss,
378
+ 'mse': mse.detach(),
379
+ 'r2': r2,
380
+ 'ndn': ndn.detach(),
381
+ }
382
+
383
+ def _get_auxiliary_loss(self, target, reconstruction, activations,
384
+ decoder=None, pre_penalty_activations=None):
385
+ """TopK-style auxiliary loss: predict the residual using dead features."""
386
+ if decoder is None or pre_penalty_activations is None or self.aux_penalty <= 0:
387
+ return torch.tensor(0.0, device=target.device, dtype=target.dtype)
388
+
389
+ residual = target - reconstruction
390
+ aux_src = F.relu(pre_penalty_activations) - activations
391
+
392
+ k = max(1, aux_src.shape[-1] // 2)
393
+ topk = torch.topk(aux_src, k=k, dim=-1)
394
+ aux_codes = torch.zeros_like(aux_src).scatter(-1, topk.indices, topk.values)
395
+
396
+ residual_hat = aux_codes @ decoder.W_dec
397
+ aux_mse = (residual - residual_hat).pow(2).mean()
398
+ return self.aux_penalty * aux_mse
steerable_retrieval/models/utils/schedulers.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #write a pytorch learning rate scheduler for cosine decay with linear warmup
2
+
3
+ from torch.optim import Optimizer
4
+ import torch
5
+ import math
6
+ import torch.optim as optim
7
+ import torch.nn as nn
8
+ from tqdm import tqdm
9
+
10
+ class CosineDecayWithLinearWarmup(torch.optim.lr_scheduler._LRScheduler):
11
+ def __init__(self, optimizer, warmup_steps = 5000, max_steps = 200000, base_lr = 0.0001, final_lr = 1e-6, last_step=-1):
12
+ self.warmup_steps = warmup_steps
13
+ self.max_steps = max_steps
14
+ self.base_lr = base_lr
15
+ self.final_lr = final_lr
16
+ self.last_step = last_step
17
+ super(CosineDecayWithLinearWarmup, self).__init__(optimizer, last_step)
18
+
19
+ def get_lr(self):
20
+ if self.last_step < self.warmup_steps:
21
+ #warmup from 0 to base_lr
22
+ return [self.last_step / self.warmup_steps * base_lr for base_lr in self.base_lrs]
23
+ else:
24
+ return [self.final_lr + 0.5 * (base_lr - self.final_lr) * (1 + math.cos(math.pi * (self.last_step - self.warmup_steps) / (self.max_steps - self.warmup_steps))) for base_lr in self.base_lrs]
25
+
26
+ def step(self, step=None):
27
+ if step is None:
28
+ step = self.last_step + 1
29
+ self.last_step = step
30
+ for param_group, lr in zip(self.optimizer.param_groups, self.get_lr()):
31
+ param_group['lr'] = lr
32
+
33
+ # write a dummy test for the scheduler with 5k warmup, 200k max steps, a dummy network and optimizer, and return the step and lr history
34
+ @staticmethod
35
+ def test():
36
+ net = nn.Linear(10, 1)
37
+ optimizer = optim.Adam(net.parameters(), lr=0.0001)
38
+ scheduler = CosineDecayWithLinearWarmup(optimizer, warmup_steps = 5000, max_steps = 200000, base_lr = 0.0001, final_lr = 1e-6)
39
+ step_lr_history = []
40
+ for step in tqdm(range(0, 200000)):
41
+ optimizer.step()
42
+ scheduler.step()
43
+ step_lr_history.append((step, optimizer.param_groups[0]['lr']))
44
+ return step_lr_history
steerable_retrieval/steer/__init__.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sparse steerable retrieval -- public API.
2
+
3
+ Open-vocabulary concept control for dense music retrieval via sparse inversion in a
4
+ trained SAE. See :class:`~steerable_retrieval.steer.slider.Slider` for the main entry point.
5
+ """
6
+
7
+ from steerable_retrieval.steer.inversion import (
8
+ InversionResult,
9
+ MahalanobisPrior,
10
+ fit_mahalanobis_prior,
11
+ invert_concept,
12
+ load_default_prior,
13
+ mahalanobis_distance,
14
+ )
15
+ from steerable_retrieval.steer.retrieval import topk_cosine_neighbors
16
+ from steerable_retrieval.steer.steering import build_edit_mask, idf_weights, steer_sparse
17
+ from steerable_retrieval.steer.slider import Slider
18
+
19
+ __all__ = [
20
+ "Slider",
21
+ "InversionResult",
22
+ "MahalanobisPrior",
23
+ "fit_mahalanobis_prior",
24
+ "load_default_prior",
25
+ "invert_concept",
26
+ "mahalanobis_distance",
27
+ "build_edit_mask",
28
+ "idf_weights",
29
+ "steer_sparse",
30
+ "topk_cosine_neighbors",
31
+ ]
steerable_retrieval/steer/inversion.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sparse inversion for open-vocabulary concept attribution.
2
+
3
+ This is the core method of *"Steering dense music retrieval with open-vocabulary
4
+ concept discovery"* (ISMIR 2026). Given a free-form concept's text embedding
5
+ ``z_c`` in a joint music--text space, we recover a **sparse code** whose decoded
6
+ audio embedding reconstructs ``z_c`` while staying close to the empirical audio
7
+ manifold (a Mahalanobis prior). Two solvers are provided:
8
+
9
+ * ``method="adam"`` -- gradient descent on the latent pre-activations through the
10
+ SAE's own sparsifying operator (the general, differentiable variant).
11
+ * ``method="fista"`` -- a fast linear-inverse solver exploiting the linear decoder
12
+ (~20 ms/inversion on CPU; used for the interactive demo).
13
+
14
+ The recovered support is the concept "slider": the set of sparse features that,
15
+ when amplified or suppressed, steer retrieval along the concept axis.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Optional
22
+
23
+ import numpy as np
24
+ import torch
25
+ import torch.nn.functional as F
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # Mahalanobis audio-manifold prior
30
+ # --------------------------------------------------------------------------- #
31
+ @dataclass
32
+ class MahalanobisPrior:
33
+ """Gaussian prior over L2-normalized audio embeddings.
34
+
35
+ ``mean`` is ``[1, d]`` and ``precision`` is ``[d, d]`` (inverse covariance).
36
+ Used to keep inverted concepts near the region occupied by real audio.
37
+ """
38
+
39
+ mean: torch.Tensor
40
+ precision: torch.Tensor
41
+
42
+ def to(self, device=None, dtype=None) -> "MahalanobisPrior":
43
+ return MahalanobisPrior(
44
+ mean=self.mean.to(device=device, dtype=dtype),
45
+ precision=self.precision.to(device=device, dtype=dtype),
46
+ )
47
+
48
+ def save(self, path, *, meta: Optional[dict] = None) -> None:
49
+ """Save the prior as a compact ``.npz`` (mean, precision, optional JSON meta)."""
50
+ arrs = {
51
+ "mean": self.mean.detach().cpu().numpy().astype(np.float32),
52
+ "precision": self.precision.detach().cpu().numpy().astype(np.float32),
53
+ }
54
+ if meta:
55
+ import json
56
+
57
+ arrs["meta"] = np.frombuffer(json.dumps(meta).encode("utf-8"), dtype=np.uint8)
58
+ np.savez_compressed(path, **arrs)
59
+
60
+ @classmethod
61
+ def load(cls, path, *, device=None, dtype: torch.dtype = torch.float32) -> "MahalanobisPrior":
62
+ """Load a prior saved by :meth:`save`."""
63
+ with np.load(path, allow_pickle=False) as data:
64
+ mean = torch.from_numpy(data["mean"]).to(device=device, dtype=dtype)
65
+ precision = torch.from_numpy(data["precision"]).to(device=device, dtype=dtype)
66
+ if mean.dim() == 1:
67
+ mean = mean.unsqueeze(0)
68
+ return cls(mean=mean, precision=precision)
69
+
70
+
71
+ def load_default_prior(name: str = "muq_mulan_music4all", *, device=None) -> MahalanobisPrior:
72
+ """Load a Mahalanobis prior shipped with the package (a library constant).
73
+
74
+ The default (``muq_mulan_music4all``) is fit once on Music4All MuQ-MuLan audio
75
+ embeddings and packaged under ``steerable_retrieval/assets/``. It is a fixed
76
+ distributional constant of the audio manifold — independent of whatever corpus
77
+ a caller later retrieves over.
78
+ """
79
+ from importlib import resources
80
+
81
+ res = resources.files("steerable_retrieval.assets").joinpath(f"{name}_prior.npz")
82
+ with resources.as_file(res) as p:
83
+ return MahalanobisPrior.load(p, device=device)
84
+
85
+
86
+ def fit_mahalanobis_prior(
87
+ embeddings,
88
+ *,
89
+ cov_eps: float = 1e-3,
90
+ shrinkage: float = 0.05,
91
+ device=None,
92
+ dtype: torch.dtype = torch.float32,
93
+ ) -> MahalanobisPrior:
94
+ """Fit a shrinkage Mahalanobis prior to a set of audio embeddings.
95
+
96
+ Args:
97
+ embeddings: ``[N, d]`` array/tensor of audio embeddings (need not be normalized).
98
+ cov_eps: diagonal jitter added to the covariance for invertibility.
99
+ shrinkage: convex shrinkage toward a scaled identity (Ledoit-Wolf style).
100
+ """
101
+ x = np.asarray(
102
+ embeddings.detach().cpu().numpy() if isinstance(embeddings, torch.Tensor) else embeddings,
103
+ dtype=np.float64,
104
+ )
105
+ x = x / np.clip(np.linalg.norm(x, axis=1, keepdims=True), 1e-12, None)
106
+ mean = x.mean(axis=0, keepdims=True)
107
+ centered = x - mean
108
+ denom = max(int(centered.shape[0] - 1), 1)
109
+ cov = (centered.T @ centered) / float(denom)
110
+ trace_scale = float(np.trace(cov) / cov.shape[0]) if cov.shape[0] > 0 else 1.0
111
+ cov = (1.0 - shrinkage) * cov + shrinkage * trace_scale * np.eye(cov.shape[0])
112
+ cov = cov + cov_eps * np.eye(cov.shape[0])
113
+ precision = np.linalg.inv(cov)
114
+ return MahalanobisPrior(
115
+ mean=torch.from_numpy(mean).to(device=device, dtype=dtype),
116
+ precision=torch.from_numpy(precision).to(device=device, dtype=dtype),
117
+ )
118
+
119
+
120
+ def mahalanobis_distance(x: torch.Tensor, prior: MahalanobisPrior) -> torch.Tensor:
121
+ """Squared Mahalanobis distance of (L2-normalized) ``x`` to the prior, per row."""
122
+ x = F.normalize(x, dim=-1)
123
+ delta = x - prior.mean
124
+ return torch.einsum("bi,ij,bj->b", delta, prior.precision, delta)
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Re-applying the SAE's sparsifying operator Pi to arbitrary latent logits
129
+ # --------------------------------------------------------------------------- #
130
+ def apply_encoder_sparse_transform(
131
+ model, latent_logits: torch.Tensor, use_encoder_penalty: bool = True
132
+ ) -> torch.Tensor:
133
+ """Apply the SAE encoder's sparsifying nonlinearity ``Pi`` to ``latent_logits``.
134
+
135
+ This lets the Adam inversion optimize *pre-activations* while keeping the exact
136
+ training-time sparsity structure of the model (TopK / BatchTopK / JumpReLU / ReLU).
137
+ """
138
+ encoder = model.sae_encoder
139
+ encoder_name = type(encoder).__name__
140
+ if encoder_name == "VanillaSAEEncoder":
141
+ return F.relu(latent_logits)
142
+ if encoder_name == "TopKSAEEncoder":
143
+ acts = F.relu(latent_logits)
144
+ return encoder.penalty(acts) if use_encoder_penalty else acts
145
+ if encoder_name in {"BatchTopKSAEEncoder", "MatryoshkaBatchTopKSAEEncoder"}:
146
+ acts = F.relu(latent_logits)
147
+ if not use_encoder_penalty:
148
+ return acts
149
+ threshold = getattr(encoder, "running_threshold", None)
150
+ if threshold is None:
151
+ threshold = encoder.penalty._get_threshold(acts)
152
+ return encoder.penalty(acts, threshold)
153
+ if encoder_name == "JumpReLUSAEEncoder":
154
+ return encoder.jumprelu(latent_logits) if use_encoder_penalty else F.relu(latent_logits)
155
+ raise NotImplementedError(
156
+ f"Latent-logit inversion is not implemented for encoder type {encoder_name}."
157
+ )
158
+
159
+
160
+ # --------------------------------------------------------------------------- #
161
+ # FISTA solver for the linear inverse problem with a Mahalanobis term
162
+ # --------------------------------------------------------------------------- #
163
+ def soft_threshold(x: torch.Tensor, tau: float) -> torch.Tensor:
164
+ return torch.sign(x) * torch.clamp(torch.abs(x) - tau, min=0.0)
165
+
166
+
167
+ def fista_mahalanobis(
168
+ A: torch.Tensor,
169
+ y: torch.Tensor,
170
+ mu: torch.Tensor,
171
+ M: torch.Tensor,
172
+ lam: float,
173
+ gamma: float,
174
+ n_iter: int = 200,
175
+ L: Optional[float] = None,
176
+ power_iters: int = 30,
177
+ enforce_nonnegative: bool = True,
178
+ ) -> torch.Tensor:
179
+ """Solve ``min_x 0.5||Ax - y||^2 + 0.5*gamma*(Ax-mu)^T M (Ax-mu) + lam||x||_1``.
180
+
181
+ ``A`` is ``W_dec^T`` (``[d, m]``); ``x`` is the sparse code (``[m]``). Non-negativity
182
+ matches ReLU-style SAE codes. ``L`` (Lipschitz constant) is estimated by power
183
+ iteration on the Hessian ``A^T A + gamma A^T M A`` when not provided.
184
+ """
185
+ n, m = A.shape
186
+ del n
187
+ x = torch.zeros(m, device=A.device, dtype=A.dtype)
188
+ z = x.clone()
189
+ t = 1.0
190
+ if L is None:
191
+ v = torch.randn(m, device=A.device, dtype=A.dtype)
192
+ v = v / v.norm().clamp_min(1e-12)
193
+ for _ in range(power_iters):
194
+ Hv = A.T @ (A @ v) + gamma * (A.T @ (M @ (A @ v)))
195
+ v = Hv / Hv.norm().clamp_min(1e-12)
196
+ Hv = A.T @ (A @ v) + gamma * (A.T @ (M @ (A @ v)))
197
+ L = torch.dot(v, Hv)
198
+ eta = 1.0 / float(torch.as_tensor(L).item())
199
+ for _ in range(n_iter):
200
+ Az = A @ z
201
+ grad = A.T @ (Az - y) + gamma * (A.T @ (M @ (Az - mu)))
202
+ x_new = soft_threshold(z - eta * grad, lam * eta)
203
+ if enforce_nonnegative:
204
+ x_new = F.relu(x_new)
205
+ t_new = (1 + (1 + 4 * t * t) ** 0.5) / 2
206
+ z = x_new + ((t - 1) / t_new) * (x_new - x)
207
+ x, t = x_new, t_new
208
+ return x
209
+
210
+
211
+ # --------------------------------------------------------------------------- #
212
+ # Inversion result + the two solvers + a dispatcher
213
+ # --------------------------------------------------------------------------- #
214
+ @dataclass
215
+ class InversionResult:
216
+ """Output of a concept inversion.
217
+
218
+ ``sparse_values`` is the recovered sparse code over the SAE dictionary ``[m]``;
219
+ ``support_mask`` is the boolean support (active features). ``final_text_cosine``
220
+ and ``final_mahalanobis_distance`` are diagnostics of solution quality.
221
+ """
222
+
223
+ sparse_values: torch.Tensor
224
+ support_mask: torch.Tensor
225
+ final_text_cosine: float
226
+ final_mahalanobis_distance: float
227
+ method: str
228
+
229
+
230
+ def _decoder_matrices(model, device, dtype):
231
+ """Return ``A = W_dec^T`` (``[d, m]``) and ``b_dec`` (``[d]``)."""
232
+ A = model.sae_decoder.W_dec.detach().to(device=device, dtype=dtype).T
233
+ b_dec = model.sae_decoder.b_dec.detach().to(device=device, dtype=dtype).view(-1)
234
+ return A, b_dec
235
+
236
+
237
+ def invert_concept_adam(
238
+ model,
239
+ text_embedding: torch.Tensor,
240
+ prior: MahalanobisPrior,
241
+ *,
242
+ init_sparse: Optional[torch.Tensor] = None,
243
+ num_steps: int = 500,
244
+ lr: float = 1e-3,
245
+ reg_strength: float = 1e-4,
246
+ use_encoder_penalty: bool = True,
247
+ activation_threshold: float = 1e-6,
248
+ ) -> InversionResult:
249
+ """Adam inversion: optimize latent pre-activations through the SAE operator ``Pi``.
250
+
251
+ ``init_sparse`` (``[1, m]`` or ``[m]``) initializes the latent logits; the paper
252
+ initializes from the sparse code of the nearest audio neighbour to ``z_c`` for
253
+ stable convergence (see :func:`nearest_audio_sparse_init`).
254
+ """
255
+ device = text_embedding.device
256
+ dtype = text_embedding.dtype
257
+ prior = prior.to(device=device, dtype=dtype)
258
+ if init_sparse is None:
259
+ init = torch.zeros((1, model.sae_encoder.dict_size), device=device, dtype=dtype)
260
+ else:
261
+ init = init_sparse.view(1, -1).to(device=device, dtype=dtype)
262
+
263
+ latent_logits = torch.nn.Parameter(init.clone())
264
+ optimizer = torch.optim.AdamW([latent_logits], lr=lr)
265
+ for _ in range(num_steps):
266
+ optimizer.zero_grad(set_to_none=True)
267
+ z_sparse = apply_encoder_sparse_transform(model, latent_logits, use_encoder_penalty)
268
+ z_hat = model.sae_decoder(z_sparse)
269
+ text_cos = F.cosine_similarity(z_hat, text_embedding, dim=-1).mean()
270
+ mahal = mahalanobis_distance(z_hat, prior).mean()
271
+ loss = (1.0 - text_cos) + reg_strength * mahal
272
+ loss.backward()
273
+ optimizer.step()
274
+
275
+ with torch.no_grad():
276
+ z_sparse = apply_encoder_sparse_transform(model, latent_logits, use_encoder_penalty)
277
+ z_hat = model.sae_decoder(z_sparse)
278
+ sparse_values = z_sparse.squeeze(0).detach().cpu()
279
+ support_mask = (sparse_values.abs() > activation_threshold).bool()
280
+ final_cos = float(F.cosine_similarity(z_hat, text_embedding, dim=-1).mean().item())
281
+ final_mahal = float(mahalanobis_distance(z_hat, prior).mean().item())
282
+ return InversionResult(sparse_values, support_mask, final_cos, final_mahal, "adam")
283
+
284
+
285
+ def invert_concept_fista(
286
+ model,
287
+ text_embedding: torch.Tensor,
288
+ prior: MahalanobisPrior,
289
+ *,
290
+ num_steps: int = 250,
291
+ l1_lambda: float = 0.01,
292
+ gamma: float = 1e-4,
293
+ lipschitz: Optional[float] = None,
294
+ power_iters: int = 30,
295
+ enforce_nonnegative: bool = True,
296
+ use_encoder_penalty: bool = True,
297
+ apply_encoder_penalty_after: bool = True,
298
+ activation_threshold: float = 1e-6,
299
+ ) -> InversionResult:
300
+ """FISTA inversion exploiting the linear decoder (fast; used by the live demo)."""
301
+ device = text_embedding.device
302
+ dtype = text_embedding.dtype
303
+ prior = prior.to(device=device, dtype=dtype)
304
+ A, b_dec = _decoder_matrices(model, device, dtype)
305
+ y = text_embedding.squeeze(0).detach().to(device=device, dtype=dtype) - b_dec
306
+ mu = prior.mean.squeeze(0).detach().to(device=device, dtype=dtype) - b_dec
307
+ M = prior.precision.detach().to(device=device, dtype=dtype)
308
+
309
+ x = fista_mahalanobis(
310
+ A, y, mu, M, l1_lambda, gamma,
311
+ n_iter=num_steps, L=lipschitz,
312
+ power_iters=power_iters, enforce_nonnegative=enforce_nonnegative,
313
+ )
314
+ z_sparse = x.unsqueeze(0)
315
+ if apply_encoder_penalty_after:
316
+ z_sparse = apply_encoder_sparse_transform(model, z_sparse, use_encoder_penalty)
317
+
318
+ with torch.no_grad():
319
+ z_hat = model.sae_decoder(z_sparse)
320
+ sparse_values = z_sparse.squeeze(0).detach().cpu()
321
+ support_mask = (sparse_values.abs() > activation_threshold).bool()
322
+ final_cos = float(F.cosine_similarity(z_hat, text_embedding, dim=-1).mean().item())
323
+ final_mahal = float(mahalanobis_distance(z_hat, prior).mean().item())
324
+ return InversionResult(sparse_values, support_mask, final_cos, final_mahal, "fista")
325
+
326
+
327
+ def nearest_audio_sparse_init(
328
+ model,
329
+ text_embedding: torch.Tensor,
330
+ audio_embeddings: np.ndarray,
331
+ audio_norms: Optional[np.ndarray] = None,
332
+ ) -> torch.Tensor:
333
+ """Sparse code of the audio embedding nearest (cosine) to ``text_embedding``.
334
+
335
+ Recommended initialization for Adam inversion (paper Section 3.3).
336
+ """
337
+ text_norm = F.normalize(text_embedding.detach(), dim=-1).squeeze(0).cpu().numpy().astype(np.float32)
338
+ audio_embeddings = np.asarray(audio_embeddings, dtype=np.float32)
339
+ if audio_norms is None:
340
+ audio_norms = np.clip(np.linalg.norm(audio_embeddings, axis=1), 1e-12, None)
341
+ sims = (audio_embeddings @ text_norm) / audio_norms
342
+ best = int(np.argmax(sims))
343
+ device = model.sae_decoder.W_dec.device
344
+ batch = torch.from_numpy(audio_embeddings[best][None]).to(device)
345
+ with torch.inference_mode():
346
+ _, z, _, _ = model.inference(batch)
347
+ return z.detach().squeeze(0)
348
+
349
+
350
+ def prior_mean_sparse_init(model, prior: MahalanobisPrior) -> torch.Tensor:
351
+ """Sparse code of the prior's mean embedding — a manifold-centered Adam init.
352
+
353
+ Used when no ``audio_embeddings`` are available for a nearest-neighbour init, so
354
+ Adam inversion still starts from a plausible audio point rather than zeros.
355
+ """
356
+ device = model.sae_decoder.W_dec.device
357
+ mean = prior.mean.to(device=device, dtype=torch.float32)
358
+ with torch.inference_mode():
359
+ _, z, _, _ = model.inference(mean)
360
+ return z.detach().squeeze(0)
361
+
362
+
363
+ def invert_concept(
364
+ model,
365
+ text_embedding: torch.Tensor,
366
+ prior: MahalanobisPrior,
367
+ *,
368
+ method: str = "adam",
369
+ audio_embeddings: Optional[np.ndarray] = None,
370
+ **kwargs,
371
+ ) -> InversionResult:
372
+ """Dispatch to the requested inversion solver.
373
+
374
+ For ``method="adam"`` the optimization is initialized from the nearest audio
375
+ neighbour when ``audio_embeddings`` is provided (recommended by the paper), and
376
+ otherwise from the prior mean — so inversion needs only the prior, not a corpus.
377
+ """
378
+ method = method.lower()
379
+ if method == "adam":
380
+ if kwargs.get("init_sparse") is None:
381
+ kwargs["init_sparse"] = (
382
+ nearest_audio_sparse_init(model, text_embedding, audio_embeddings)
383
+ if audio_embeddings is not None
384
+ else prior_mean_sparse_init(model, prior)
385
+ )
386
+ return invert_concept_adam(model, text_embedding, prior, **kwargs)
387
+ if method == "fista":
388
+ return invert_concept_fista(model, text_embedding, prior, **kwargs)
389
+ raise ValueError(f"Unknown inversion method {method!r}; use 'adam' or 'fista'.")
steerable_retrieval/steer/loading.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loading trained SAEs for the steering API.
2
+
3
+ A :class:`SteerableSAE` is the minimal model surface the :class:`~steerable_retrieval.steer.slider.Slider`
4
+ needs: a trained ``sae_encoder`` + ``sae_decoder`` and a ``text_encoder`` mapping concept
5
+ strings to embeddings in the same joint space. This module resolves such a model from a
6
+ checkpoint (local path or HuggingFace repo).
7
+
8
+ NOTE: the released pretrained checkpoint (BatchTopK SAE on MuQ / music4all) is not published
9
+ yet. Until then, construct a model in-memory and pass it via ``Slider(..., model=model)``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+
19
+
20
+ class SteerableSAE(nn.Module):
21
+ """Minimal SAE wrapper for inference-time steering (no training deps).
22
+
23
+ Args:
24
+ sae_encoder: a trained SAE encoder (``steerable_retrieval.models.sae.encoders``).
25
+ sae_decoder: a trained SAE decoder (``steerable_retrieval.models.sae.decoders``).
26
+ text_encoder: callable mapping ``list[str] -> [B, d]`` embeddings (e.g. the MuQ
27
+ text tower), living in the same joint space as the SAE's audio inputs.
28
+ """
29
+
30
+ def __init__(self, sae_encoder, sae_decoder, text_encoder):
31
+ super().__init__()
32
+ self.sae_encoder = sae_encoder
33
+ self.sae_decoder = sae_decoder
34
+ self.text_encoder = text_encoder
35
+ if not hasattr(self.sae_decoder, "b_dec"):
36
+ self.sae_decoder.b_dec = self.sae_encoder.b_dec
37
+
38
+ @torch.no_grad()
39
+ def inference(self, x):
40
+ """Encode dense features to a sparse code and back. Returns ``(x, z, xhat, pre)``."""
41
+ pre, z = self.sae_encoder(x)
42
+ xhat = self.sae_decoder(z)
43
+ return x, z, xhat, pre
44
+
45
+
46
+ def _resolve_local_run(checkpoint: str, subfolder: Optional[str] = None):
47
+ """Return (ckpt_path, config_path) for a checkpoint, downloading from the Hub if
48
+ ``checkpoint`` is a ``org/repo`` id rather than a local path.
49
+
50
+ A Lightning run stores its Hydra config at ``<run>/.hydra/config.yaml`` and its
51
+ checkpoints under ``<run>/checkpoints/``. We use that config to rebuild the SAE
52
+ modules before loading the (encoder/decoder-only) weights. For a Hub repo carrying
53
+ several models, ``subfolder`` (e.g. ``"L0-20"``) selects which one.
54
+ """
55
+ import os
56
+
57
+ if os.path.exists(checkpoint):
58
+ ckpt_path = os.path.abspath(checkpoint)
59
+ run_dir = os.path.dirname(os.path.dirname(ckpt_path)) # .../checkpoints/x.ckpt -> run
60
+ cfg_path = os.path.join(run_dir, ".hydra", "config.yaml")
61
+ if not os.path.exists(cfg_path):
62
+ # allow a config.yaml sitting next to the checkpoint
63
+ alt = os.path.join(os.path.dirname(ckpt_path), "config.yaml")
64
+ cfg_path = alt if os.path.exists(alt) else cfg_path
65
+ return ckpt_path, cfg_path
66
+
67
+ # Otherwise treat it as a HuggingFace repo id: expects last.ckpt + config.yaml
68
+ # (optionally under `subfolder`).
69
+ from huggingface_hub import hf_hub_download
70
+
71
+ pre = f"{subfolder}/" if subfolder else ""
72
+ ckpt_path = hf_hub_download(checkpoint, filename=f"{pre}last.ckpt")
73
+ try:
74
+ cfg_path = hf_hub_download(checkpoint, filename=f"{pre}config.yaml")
75
+ except Exception:
76
+ cfg_path = hf_hub_download(checkpoint, filename=f"{pre}.hydra/config.yaml")
77
+ return ckpt_path, cfg_path
78
+
79
+
80
+ def load_steerable_sae(
81
+ checkpoint: Optional[str],
82
+ *,
83
+ model_class: Optional[str] = None,
84
+ device: str = "cpu",
85
+ text_encoder=None,
86
+ build_text_encoder: bool = True,
87
+ config_path: Optional[str] = None,
88
+ subfolder: Optional[str] = None,
89
+ ) -> SteerableSAE:
90
+ """Resolve a :class:`SteerableSAE` from a trained Lightning checkpoint.
91
+
92
+ Args:
93
+ checkpoint: local path to a ``.ckpt`` (its run's ``.hydra/config.yaml`` is used
94
+ to rebuild the SAE modules), or a HuggingFace ``org/repo`` id carrying
95
+ ``last.ckpt`` + ``config.yaml``.
96
+ device: where to place the model.
97
+ text_encoder: a ready callable ``list[str] -> [B, d]``. If ``None`` and
98
+ ``build_text_encoder`` is True, the text tower from the run config (e.g.
99
+ MuQ-MuLan) is instantiated; if False, ``text_encoder`` stays ``None`` (useful
100
+ for steering/retrieval that never embeds new text).
101
+ config_path: override the run config path explicitly.
102
+ """
103
+ from hydra.utils import instantiate
104
+ from omegaconf import OmegaConf
105
+
106
+ ckpt_path, resolved_cfg = _resolve_local_run(checkpoint, subfolder=subfolder)
107
+ cfg = OmegaConf.load(config_path or resolved_cfg)
108
+
109
+ enc = instantiate(cfg.model.sae_encoder, device=device)
110
+ dec = instantiate(cfg.model.sae_decoder, device=device)
111
+ if not hasattr(dec, "b_dec"):
112
+ dec.b_dec = enc.b_dec
113
+
114
+ state = torch.load(ckpt_path, map_location=device)
115
+ sd = state.get("state_dict", state)
116
+ enc_sd = {k[len("sae_encoder."):]: v for k, v in sd.items() if k.startswith("sae_encoder.")}
117
+ dec_sd = {k[len("sae_decoder."):]: v for k, v in sd.items() if k.startswith("sae_decoder.")}
118
+ if not enc_sd or not dec_sd:
119
+ raise ValueError(
120
+ f"No sae_encoder/sae_decoder weights found in {ckpt_path}. "
121
+ f"Available prefixes: {sorted({k.split('.')[0] for k in sd})}"
122
+ )
123
+ enc.load_state_dict(enc_sd, strict=False)
124
+ dec.load_state_dict(dec_sd, strict=False)
125
+
126
+ if text_encoder is None and build_text_encoder:
127
+ text_encoder = instantiate(cfg.model.text_encoder, device=device)
128
+
129
+ model = SteerableSAE(enc, dec, text_encoder)
130
+ model.to(device).eval()
131
+ return model
steerable_retrieval/steer/prior_fit.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fit and ship the default Mahalanobis manifold prior.
2
+
3
+ The prior is a fixed distributional constant of the audio manifold, fit once on a
4
+ sample of pre-extracted audio embeddings and packaged with the library so the
5
+ :class:`~steerable_retrieval.steer.slider.Slider` works with no data from the caller.
6
+ It is deliberately independent of any retrieval corpus.
7
+
8
+ CLI (produces the packaged asset):
9
+
10
+ python -m steerable_retrieval.steer.prior_fit \
11
+ --manifest /path/to/embedding_lookup_manifest.csv \
12
+ --out steerable_retrieval/assets/muq_mulan_music4all_prior.npz \
13
+ --n-samples 20000
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import csv
20
+ import random
21
+ from typing import Optional
22
+
23
+ import numpy as np
24
+
25
+ from steerable_retrieval.steer.inversion import MahalanobisPrior, fit_mahalanobis_prior
26
+
27
+
28
+ def load_embeddings_from_manifest(
29
+ manifest_csv: str,
30
+ *,
31
+ n_samples: int = 20000,
32
+ split: Optional[str] = "train",
33
+ seed: int = 0,
34
+ path_col: str = "audio_embedding_path",
35
+ status_col: str = "audio_embedding_status",
36
+ ) -> np.ndarray:
37
+ """Sample ``n_samples`` audio-embedding ``.npy`` paths from a manifest and stack them."""
38
+ paths = []
39
+ with open(manifest_csv, newline="") as fh:
40
+ reader = csv.DictReader(fh)
41
+ for row in reader:
42
+ if split and row.get("split") != split:
43
+ continue
44
+ if status_col in row and row[status_col] not in ("ok", "", None):
45
+ continue
46
+ p = row.get(path_col)
47
+ if p:
48
+ paths.append(p)
49
+
50
+ rng = random.Random(seed)
51
+ if n_samples and len(paths) > n_samples:
52
+ paths = rng.sample(paths, n_samples)
53
+
54
+ embs = []
55
+ for p in paths:
56
+ try:
57
+ embs.append(np.asarray(np.load(p), dtype=np.float32).reshape(-1))
58
+ except Exception:
59
+ continue
60
+ if not embs:
61
+ raise RuntimeError(f"No embeddings loaded from {manifest_csv}")
62
+ return np.stack(embs, axis=0)
63
+
64
+
65
+ def fit_and_save(
66
+ manifest_csv: str,
67
+ out_path: str,
68
+ *,
69
+ n_samples: int = 20000,
70
+ split: Optional[str] = "train",
71
+ model: str = "OpenMuQ/MuQ-MuLan-large",
72
+ ) -> MahalanobisPrior:
73
+ X = load_embeddings_from_manifest(manifest_csv, n_samples=n_samples, split=split)
74
+ prior = fit_mahalanobis_prior(X)
75
+ prior.save(
76
+ out_path,
77
+ meta={"model": model, "n_samples": int(X.shape[0]), "dim": int(X.shape[1]), "split": split, "source": "music4all"},
78
+ )
79
+ print(f"Fit prior on {X.shape[0]} embeddings (dim {X.shape[1]}) -> {out_path}")
80
+ return prior
81
+
82
+
83
+ def main():
84
+ ap = argparse.ArgumentParser(description=__doc__)
85
+ ap.add_argument("--manifest", required=True, help="embedding manifest CSV")
86
+ ap.add_argument("--out", required=True, help="output .npz path")
87
+ ap.add_argument("--n-samples", type=int, default=20000)
88
+ ap.add_argument("--split", default="train")
89
+ ap.add_argument("--model", default="OpenMuQ/MuQ-MuLan-large")
90
+ args = ap.parse_args()
91
+ fit_and_save(args.manifest, args.out, n_samples=args.n_samples, split=args.split, model=args.model)
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
steerable_retrieval/steer/retrieval.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cosine nearest-neighbour retrieval over a dense embedding corpus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Tuple
6
+
7
+ import torch
8
+ import torch.nn.functional as F
9
+
10
+
11
+ def topk_cosine_neighbors(
12
+ query: torch.Tensor,
13
+ gallery: torch.Tensor,
14
+ k: int,
15
+ *,
16
+ exclude_idx: Optional[int] = None,
17
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
18
+ """Return ``(indices, scores)`` of the top-``k`` cosine neighbours of ``query``.
19
+
20
+ Args:
21
+ query: ``[d]`` or ``[1, d]`` dense query embedding.
22
+ gallery: ``[N, d]`` corpus of dense embeddings.
23
+ exclude_idx: optional gallery index to exclude (e.g. the seed track itself).
24
+ """
25
+ query = F.normalize(query.reshape(1, -1), dim=-1).squeeze(0)
26
+ gallery = F.normalize(gallery, dim=-1)
27
+ sims = gallery @ query
28
+ if exclude_idx is not None:
29
+ sims[exclude_idx] = -1e9
30
+ n_avail = gallery.shape[0] - (1 if exclude_idx is not None else 0)
31
+ k = min(int(k), int(n_avail))
32
+ vals, idx = torch.topk(sims, k=k, largest=True, sorted=True)
33
+ return idx.detach().cpu(), vals.detach().cpu()
steerable_retrieval/steer/slider.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The :class:`Slider` -- the public entry point for sparse steerable retrieval.
2
+
3
+ A ``Slider`` binds a free-form text concept to a sparse edit direction in a trained
4
+ SAE, obtained by sparse inversion (:mod:`steerable_retrieval.steer.inversion`). Once
5
+ built it can steer any query embedding along the concept axis and retrieve over a
6
+ corpus::
7
+
8
+ slider = Slider("distorted guitar", model=model) # uses the packaged manifold prior
9
+ z_edited = slider.steer(z, alpha=1.0) # alpha < 0 suppresses
10
+ idx, scores = slider.retrieve(z, corpus, alpha=1.0, k=10)
11
+
12
+ The Mahalanobis **manifold prior** is a fixed distributional constant shipped with the
13
+ library (fit on Music4All MuQ-MuLan embeddings) -- it is *independent of the retrieval
14
+ corpus*. To adapt it to your own data, call :meth:`Slider.fit_prior` explicitly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Optional, Tuple, Union
20
+
21
+ import numpy as np
22
+ import torch
23
+
24
+ from steerable_retrieval.steer.inversion import (
25
+ InversionResult,
26
+ MahalanobisPrior,
27
+ fit_mahalanobis_prior,
28
+ invert_concept,
29
+ load_default_prior,
30
+ )
31
+ from steerable_retrieval.steer.retrieval import topk_cosine_neighbors
32
+ from steerable_retrieval.steer.steering import build_edit_mask, decode_normalized, steer_sparse
33
+
34
+ # Filled once the main MuQ/music4all SAE is trained and uploaded to HuggingFace.
35
+ DEFAULT_MODEL_CLASS: Optional[str] = None
36
+ DEFAULT_CHECKPOINT: Optional[str] = None
37
+ # Name of the packaged manifold prior loaded when no prior is supplied.
38
+ DEFAULT_PRIOR_NAME = "muq_mulan_music4all"
39
+
40
+ ArrayLike = Union[np.ndarray, torch.Tensor]
41
+
42
+
43
+ def _resolve_model(model, model_class, checkpoint):
44
+ if model is not None:
45
+ return model
46
+ if model_class is None and checkpoint is None and DEFAULT_CHECKPOINT is None:
47
+ raise ValueError(
48
+ "No model provided and no default checkpoint is available yet. "
49
+ "Pass an explicit `model=` (a trained SAE with .sae_encoder/.sae_decoder/"
50
+ ".text_encoder), or wait for the released pretrained checkpoint."
51
+ )
52
+ from steerable_retrieval.steer.loading import load_steerable_sae
53
+
54
+ return load_steerable_sae(
55
+ checkpoint or DEFAULT_CHECKPOINT, model_class=model_class or DEFAULT_MODEL_CLASS
56
+ )
57
+
58
+
59
+ class Slider:
60
+ """A concept slider over a trained SAE's sparse feature space."""
61
+
62
+ def __init__(
63
+ self,
64
+ concept: str,
65
+ *,
66
+ model=None,
67
+ model_class: Optional[str] = DEFAULT_MODEL_CLASS,
68
+ checkpoint: Optional[str] = DEFAULT_CHECKPOINT,
69
+ prior: Optional[MahalanobisPrior] = None,
70
+ audio_embeddings: Optional[ArrayLike] = None,
71
+ method: str = "adam",
72
+ K: Optional[int] = None,
73
+ idf: Optional[torch.Tensor] = None,
74
+ norm_bundle: str = "norm",
75
+ device: Optional[Union[str, torch.device]] = None,
76
+ activation_threshold: float = 1e-6,
77
+ prior_name: str = DEFAULT_PRIOR_NAME,
78
+ **inversion_kwargs,
79
+ ):
80
+ """Build a slider for ``concept``.
81
+
82
+ Args:
83
+ concept: free-form text concept, e.g. ``"distorted guitar"``.
84
+ model: a trained SAE exposing ``sae_encoder``, ``sae_decoder``, ``text_encoder``.
85
+ model_class, checkpoint: alternative to ``model`` -- resolve a pretrained SAE.
86
+ prior: an explicit :class:`MahalanobisPrior`. If omitted, the packaged
87
+ default prior (``prior_name``) is used -- a fixed manifold constant,
88
+ *not* fit from any corpus. Use :meth:`fit_prior` to adapt it.
89
+ audio_embeddings: optional ``[N, d]`` audio embeddings used *only* to
90
+ initialize Adam inversion from the nearest audio neighbour. They do
91
+ **not** set the prior.
92
+ method: ``"adam"`` (default) or ``"fista"``.
93
+ K: cap the concept support to the top-``K`` features (``None`` = all active).
94
+ idf: optional IDF weights over dictionary features (down-weights hub neurons).
95
+ norm_bundle: edit-direction normalization (``"norm"`` = unit L2, recommended).
96
+ prior_name: which packaged prior to load when ``prior`` is omitted.
97
+ """
98
+ self.concept = concept
99
+ self.method = method
100
+ self.K = K
101
+ self.model = _resolve_model(model, model_class, checkpoint)
102
+ self.device = torch.device(device) if device is not None else self._infer_device()
103
+ self._idf = idf
104
+ self._norm_bundle = norm_bundle
105
+ self._activation_threshold = activation_threshold
106
+ self._inversion_kwargs = inversion_kwargs
107
+ self._audio_np = _to_numpy(audio_embeddings) if audio_embeddings is not None else None
108
+
109
+ # Prior precedence: explicit > packaged default constant. (audio_embeddings
110
+ # is an init aid only and never becomes the prior -- the prior is decoupled
111
+ # from any corpus. Call fit_prior() to adapt it on purpose.)
112
+ if prior is not None:
113
+ self.prior = prior.to(device=self.device)
114
+ else:
115
+ self.prior = load_default_prior(prior_name, device=self.device)
116
+ self._check_prior_dim()
117
+
118
+ self._text_embedding = self._embed_text(concept)
119
+ self._recompute()
120
+
121
+ # -- prior --------------------------------------------------------------- #
122
+ def _check_prior_dim(self) -> None:
123
+ d_model = int(getattr(self.model.sae_decoder, "act_size", self.model.sae_decoder.W_dec.shape[-1]))
124
+ d_prior = int(self.prior.mean.shape[-1])
125
+ if d_prior != d_model:
126
+ raise ValueError(
127
+ f"Manifold prior dim ({d_prior}) != model embedding dim ({d_model}). "
128
+ "Pass a matching `prior=`, fit one via fit_prior(embeddings), or set the "
129
+ "correct `prior_name`."
130
+ )
131
+
132
+ def fit_prior(self, embeddings: ArrayLike) -> "Slider":
133
+ """Refit the manifold prior to ``embeddings`` and recompute the slider.
134
+
135
+ Opt-in coupling: by default the slider uses the packaged prior, which is
136
+ independent of any corpus. Pass your retrieval corpus (or any representative
137
+ set) here to adapt the manifold prior to your own data. Returns ``self``.
138
+ """
139
+ self.prior = fit_mahalanobis_prior(_to_numpy(embeddings), device=self.device)
140
+ self._check_prior_dim()
141
+ self._recompute()
142
+ return self
143
+
144
+ def _recompute(self) -> None:
145
+ """Re-run inversion + rebuild the edit mask (after a prior/param change)."""
146
+ self.inversion: InversionResult = invert_concept(
147
+ self.model,
148
+ self._text_embedding,
149
+ self.prior,
150
+ method=self.method,
151
+ audio_embeddings=self._audio_np,
152
+ activation_threshold=self._activation_threshold,
153
+ **self._inversion_kwargs,
154
+ )
155
+ self.mask = build_edit_mask(
156
+ self.inversion.sparse_values,
157
+ dict_size=self.model.sae_encoder.dict_size,
158
+ K=self.K,
159
+ idf=self._idf,
160
+ norm_bundle=self._norm_bundle,
161
+ activation_threshold=self._activation_threshold,
162
+ )
163
+
164
+ # -- introspection ------------------------------------------------------- #
165
+ @property
166
+ def support(self) -> torch.Tensor:
167
+ """Indices of the active concept features (the slider's support)."""
168
+ return torch.nonzero(self.mask.abs() > 0, as_tuple=False).flatten()
169
+
170
+ def __len__(self) -> int:
171
+ return int(self.support.numel())
172
+
173
+ def __repr__(self) -> str:
174
+ return (
175
+ f"Slider(concept={self.concept!r}, method={self.method!r}, "
176
+ f"|support|={len(self)}, text_cos={self.inversion.final_text_cosine:.3f})"
177
+ )
178
+
179
+ # -- steering ------------------------------------------------------------ #
180
+ def steer(self, z: ArrayLike, alpha: float = 1.0) -> torch.Tensor:
181
+ """Steer dense query embedding(s) ``z`` along the concept axis.
182
+
183
+ ``alpha > 0`` amplifies the concept, ``alpha < 0`` suppresses it. Returns the
184
+ L2-normalized edited dense embedding(s) with the same leading shape as ``z``.
185
+ """
186
+ sparse, squeeze = self._encode_sparse(z)
187
+ mask = self.mask.to(device=sparse.device, dtype=sparse.dtype)
188
+ edited = steer_sparse(sparse, mask, alpha)
189
+ dense = decode_normalized(self.model, edited)
190
+ return dense.squeeze(0) if squeeze else dense
191
+
192
+ def amplify(self, z: ArrayLike, alpha: float = 1.0) -> torch.Tensor:
193
+ return self.steer(z, abs(alpha))
194
+
195
+ def suppress(self, z: ArrayLike, alpha: float = 1.0) -> torch.Tensor:
196
+ return self.steer(z, -abs(alpha))
197
+
198
+ # -- retrieval ----------------------------------------------------------- #
199
+ def retrieve(
200
+ self,
201
+ z: ArrayLike,
202
+ corpus: ArrayLike,
203
+ *,
204
+ alpha: float = 1.0,
205
+ k: int = 10,
206
+ exclude_idx: Optional[int] = None,
207
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
208
+ """Steer ``z`` by ``alpha`` then return top-``k`` cosine neighbours in ``corpus``.
209
+
210
+ ``corpus`` is an arbitrary set of dense embeddings to search over -- it is
211
+ independent of the manifold prior. Returns ``(indices, scores)`` into ``corpus``.
212
+ """
213
+ edited = self.steer(z, alpha)
214
+ gallery = _to_tensor(corpus, self.device)
215
+ return topk_cosine_neighbors(edited.to(self.device), gallery, k, exclude_idx=exclude_idx)
216
+
217
+ # -- internals ----------------------------------------------------------- #
218
+ def _infer_device(self) -> torch.device:
219
+ return self.model.sae_decoder.W_dec.device
220
+
221
+ def _embed_text(self, concept: str) -> torch.Tensor:
222
+ with torch.inference_mode():
223
+ emb = self.model.text_encoder([concept])
224
+ return emb.detach().clone().to(self.device)
225
+
226
+ def _encode_sparse(self, z: ArrayLike) -> Tuple[torch.Tensor, bool]:
227
+ t = _to_tensor(z, self.device)
228
+ squeeze = t.dim() == 1
229
+ if squeeze:
230
+ t = t.unsqueeze(0)
231
+ with torch.inference_mode():
232
+ _, sparse, _, _ = self.model.inference(t)
233
+ return sparse.clone(), squeeze
234
+
235
+
236
+ def _to_numpy(x: ArrayLike) -> np.ndarray:
237
+ if isinstance(x, torch.Tensor):
238
+ return x.detach().cpu().numpy().astype(np.float32)
239
+ return np.asarray(x, dtype=np.float32)
240
+
241
+
242
+ def _to_tensor(x: ArrayLike, device) -> torch.Tensor:
243
+ if isinstance(x, torch.Tensor):
244
+ return x.to(device=device, dtype=torch.float32)
245
+ return torch.from_numpy(np.asarray(x, dtype=np.float32)).to(device)
steerable_retrieval/steer/steering.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Steering operations: turn an inverted concept support into an edit direction.
2
+
3
+ The concept support (from :mod:`steerable_retrieval.steer.inversion`) is converted to a masked,
4
+ optionally IDF-reweighted, L2-normalized direction over the SAE dictionary. Steering
5
+ adds (amplify) or subtracts (suppress) a scaled copy of that direction from a query's
6
+ sparse code, then decodes back to the dense space for retrieval.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ import torch
14
+ import torch.nn.functional as F
15
+
16
+
17
+ def idf_weights(sparse_codes: torch.Tensor, *, eps: float = 1e-6, thresh: float = 1e-6) -> torch.Tensor:
18
+ """IDF weights over dictionary features from a corpus of sparse codes.
19
+
20
+ ``w_k = log(N / (df_k + eps) + 1)`` where ``df_k`` is the number of documents
21
+ (audio samples) in which feature ``k`` is active. Down-weights generic hub neurons.
22
+ """
23
+ num_docs = sparse_codes.shape[0]
24
+ df = (sparse_codes.abs() > thresh).float().sum(dim=0)
25
+ return torch.log(torch.tensor(float(num_docs)) / (df + eps) + 1.0)
26
+
27
+
28
+ def apply_idf(values: torch.Tensor, idf: Optional[torch.Tensor]) -> torch.Tensor:
29
+ values = values.detach().cpu().float().view(-1)
30
+ if idf is not None and idf.shape[0] == values.shape[0]:
31
+ values = values * idf.detach().cpu().float().view(-1)
32
+ return values
33
+
34
+
35
+ def normalize_bundle(weights: torch.Tensor, mode: str = "norm", eps: float = 1e-6) -> torch.Tensor:
36
+ """Normalize support weights so ``alpha`` is the sole edit-strength knob.
37
+
38
+ ``mode="norm"`` (default) makes the edit a unit-L2 direction, so support size
39
+ controls *which* coordinates move and ``alpha`` controls *how far*.
40
+ """
41
+ weights = weights.detach().cpu().float()
42
+ if not mode or weights.numel() == 0:
43
+ return weights
44
+ if mode == "prob":
45
+ total = float(weights.abs().sum().item())
46
+ return weights if total <= eps else weights / total
47
+ if mode == "normsum":
48
+ total = float(weights.abs().sum().item())
49
+ return weights / (total + eps) * float(weights.numel())
50
+ weight_norm = float(weights.norm().item())
51
+ return weights if weight_norm <= eps else weights / weight_norm
52
+
53
+
54
+ def build_edit_mask(
55
+ sparse_values: torch.Tensor,
56
+ dict_size: int,
57
+ *,
58
+ K: Optional[int] = None,
59
+ idf: Optional[torch.Tensor] = None,
60
+ norm_bundle: str = "norm",
61
+ activation_threshold: float = 1e-6,
62
+ ) -> torch.Tensor:
63
+ """Build a dense ``[dict_size]`` edit direction from recovered sparse values.
64
+
65
+ Selects the top-``K`` active features (by |value|, after optional IDF reweighting),
66
+ normalizes them, and scatters them into a full-width mask.
67
+ """
68
+ values = apply_idf(sparse_values, idf)
69
+ active = torch.where(values.abs() > activation_threshold)[0]
70
+ if active.numel() == 0:
71
+ return torch.zeros(dict_size, dtype=torch.float32)
72
+ active_vals = values[active]
73
+ k = active.numel() if K is None else min(int(K), int(active.numel()))
74
+ order = torch.topk(active_vals.abs(), k=k).indices
75
+ idx = active[order]
76
+ vals = normalize_bundle(active_vals[order], mode=norm_bundle)
77
+ mask = torch.zeros(dict_size, dtype=torch.float32)
78
+ mask[idx.long()] = vals.float()
79
+ return mask
80
+
81
+
82
+ def steer_sparse(sparse: torch.Tensor, mask: torch.Tensor, alpha: float, *, clamp_min_zero: bool = True) -> torch.Tensor:
83
+ """Steer a sparse code along the edit direction.
84
+
85
+ ``alpha > 0`` amplifies the concept, ``alpha < 0`` suppresses it. Suppression
86
+ clamps at zero to respect ReLU-style non-negative SAE codes.
87
+ """
88
+ mask = mask.to(device=sparse.device, dtype=sparse.dtype)
89
+ new_sparse = sparse + alpha * mask
90
+ if clamp_min_zero and alpha < 0:
91
+ new_sparse = new_sparse.clamp_min(0.0)
92
+ return new_sparse
93
+
94
+
95
+ def decode_normalized(model, sparse: torch.Tensor) -> torch.Tensor:
96
+ """Decode a sparse code to the dense space and L2-normalize (retrieval geometry)."""
97
+ dense = model.sae_decoder(sparse)
98
+ return F.normalize(dense, dim=-1)
steerable_retrieval/train.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any, Dict, List, Optional, Tuple
3
+
4
+ from dora import get_xp, hydra_main
5
+ import hydra
6
+
7
+ import lightning as L
8
+ import rootutils
9
+ import torch
10
+ from lightning import Callback, LightningDataModule, LightningModule, Trainer
11
+ from lightning.pytorch.loggers import Logger
12
+ from omegaconf import DictConfig
13
+ import logging
14
+ from pathlib import Path
15
+
16
+
17
+ rootutils.setup_root(__file__, indicator=".project-root", pythonpath=True)
18
+ # ------------------------------------------------------------------------------------ #
19
+ # the setup_root above is equivalent to:
20
+ # - adding project root dir to PYTHONPATH
21
+ # (so you don't need to force user to install project as a package)
22
+ # (necessary before importing any local modules e.g. `from gdr import utils`)
23
+ # - setting up PROJECT_ROOT environment variable
24
+ # (which is used as a base for paths in "configs/paths/default.yaml")
25
+ # (this way all filepaths are the same no matter where you run the code)
26
+ # - loading environment variables from ".env" in root dir
27
+ #
28
+ # you can remove it if you:
29
+ # 1. either install project as a package or move entry files to project root dir
30
+ # 2. set `root_dir` to "." in "configs/paths/default.yaml"
31
+ #
32
+ # more info: https://github.com/ashleve/rootutils
33
+ # ------------------------------------------------------------------------------------ #
34
+
35
+ from steerable_retrieval.utils import (
36
+ RankedLogger,
37
+ extras,
38
+ get_metric_value,
39
+ instantiate_callbacks,
40
+ instantiate_loggers,
41
+ log_hyperparameters,
42
+ register_resolvers,
43
+ task_wrapper,
44
+ )
45
+
46
+ log = RankedLogger(__name__, rank_zero_only=True)
47
+ register_resolvers()
48
+
49
+
50
+ @task_wrapper
51
+ def train(cfg: DictConfig) -> Tuple[Dict[str, Any], Dict[str, Any]]:
52
+ """Trains the model. Can additionally evaluate on a testset, using best weights obtained during
53
+ training.
54
+
55
+ This method is wrapped in optional @task_wrapper decorator, that controls the behavior during
56
+ failure. Useful for multiruns, saving info about the crash, etc.
57
+
58
+ :param cfg: A DictConfig configuration composed by Hydra.
59
+ :return: A tuple with metrics and dict with all instantiated objects.
60
+ """
61
+ # set seed for random number generators in pytorch, numpy and python.random
62
+ if cfg.get("seed"):
63
+ L.seed_everything(cfg.seed, workers=True)
64
+
65
+ log.info(f"Instantiating datamodule <{cfg.data._target_}>")
66
+ datamodule = hydra.utils.instantiate(cfg.data)
67
+
68
+ log.info(f"Instantiating model <{cfg.model._target_}>")
69
+ model: LightningModule = hydra.utils.instantiate(cfg.model)
70
+ # model.xp = get_xp()
71
+
72
+ log.info("Instantiating callbacks...")
73
+ callbacks: List[Callback] = instantiate_callbacks(cfg.get("callbacks"))
74
+ log.info(f"Callbacks: {callbacks}")
75
+
76
+ log.info("Instantiating loggers...")
77
+ logger: List[Logger] = instantiate_loggers(cfg.get("logger"))
78
+
79
+ log.info(f"Instantiating trainer <{cfg.trainer._target_}>")
80
+ trainer: Trainer = hydra.utils.instantiate(cfg.trainer, logger=logger, callbacks=callbacks)
81
+
82
+ object_dict = {
83
+ "cfg": cfg,
84
+ "datamodule": datamodule,
85
+ "model": model,
86
+ "callbacks": callbacks,
87
+ "logger": logger,
88
+ "trainer": trainer,
89
+ }
90
+
91
+ if logger:
92
+ log.info("Logging hyperparameters!")
93
+ log_hyperparameters(object_dict)
94
+
95
+ # automatically resume from latest checkpoint if exists and ckpt_path not manually specified
96
+ # TODO: discuss cfg.resume, this is anti-dora but maybe it's useful
97
+ ckpt_path = cfg.get("ckpt_path")
98
+ cfg.resume = cfg.resume or os.environ.get("USE_MPI")
99
+
100
+ if '/opt/ml/' in cfg.paths.ckpt_dir:
101
+ was_s3 = True
102
+ else:
103
+ was_s3 = False
104
+
105
+
106
+ logging.info("="*100)
107
+ # logging.info(os.listdir('/opt/ml/input/data')) if os.path.exists('/opt/ml/input/data') else logging.info("No data found in /opt/ml/input/data")
108
+ # log tree of /opt/ml/input/data
109
+
110
+ def tree_str(
111
+ path=".",
112
+ max_depth=None,
113
+ max_files=2,
114
+ ignore={".git", "__pycache__"}
115
+ ):
116
+ lines = []
117
+ path = Path(path)
118
+
119
+ def _walk(p, prefix="", level=0):
120
+ if max_depth is not None and level > max_depth:
121
+ return
122
+
123
+ entries = [e for e in p.iterdir() if e.name not in ignore]
124
+
125
+ dirs = sorted((e for e in entries if e.is_dir()), key=lambda x: x.name.lower())
126
+ files = sorted((e for e in entries if e.is_file()), key=lambda x: x.name.lower())
127
+
128
+ shown_files = files[:max_files]
129
+ omitted_files = len(files) - len(shown_files)
130
+
131
+ combined = dirs + shown_files
132
+
133
+ for i, entry in enumerate(combined):
134
+ is_last = i == len(combined) - 1
135
+ connector = "└── " if is_last else "├── "
136
+ lines.append(prefix + connector + entry.name)
137
+
138
+ if entry.is_dir():
139
+ extension = " " if is_last else "│ "
140
+ _walk(entry, prefix + extension, level + 1)
141
+
142
+ if omitted_files > 0:
143
+ lines.append(prefix + f"└── … ({omitted_files} more files)")
144
+
145
+ _walk(path)
146
+ return "\n".join(lines)
147
+
148
+ logging.info("="*100)
149
+ data_root = cfg.paths.get('data_dir') # config-provided data path (was hardcoded /opt/ml on SageMaker)
150
+ if data_root and os.path.exists(data_root):
151
+ logging.info(tree_str(data_root))
152
+ logging.info("="*100)
153
+
154
+ if os.path.exists(cfg.paths.ckpt_dir) and cfg.resume:
155
+ candidates = [os.path.join(cfg.paths.ckpt_dir, ckpt_file) for ckpt_file in os.listdir(cfg.paths.ckpt_dir) if ckpt_file.endswith(".ckpt")]
156
+ if candidates:
157
+ # get the last modified ckpt else get last.ckpt, reason is that s3 downloads are not in order of creation
158
+ # ckpt_path = max(candidates, key=os.path.getmtime) if not was_s3 else
159
+
160
+ if was_s3:
161
+ ckpt_path = os.path.join(cfg.paths.ckpt_dir, "last.ckpt")
162
+ if "last.ckpt" not in os.listdir(cfg.paths.ckpt_dir):
163
+ log.warning("last.ckpt not found in s3 ckpt_dir. Training from scratch!")
164
+ ckpt_path = None
165
+ else:
166
+ ckpt_path = max(candidates, key=os.path.getmtime)
167
+ log.info(f"Resuming from checkpoint {ckpt_path}...")
168
+
169
+ # ckpt_path = os.path.join(cfg.paths.ckpt_dir, "last.ckpt") if "last.ckpt" in os.listdir(cfg.paths.ckpt_dir) else None
170
+ log.info(f"Resuming from checkpoint {ckpt_path}...")
171
+ else:
172
+ log.info(ckpt_path, "is empty. Training from scratch!")
173
+
174
+
175
+ trainer.true_accumulate_grad_batches, trainer.accumulate_grad_batches = trainer.accumulate_grad_batches, 1
176
+ model.gradient_clip_val, trainer.gradient_clip_val = trainer.gradient_clip_val, None
177
+
178
+ if cfg.get("train"):
179
+ log.info("Starting training!")
180
+ with torch.backends.cuda.sdp_kernel(enable_flash=True, enable_math=True, enable_mem_efficient=True):
181
+ trainer.fit(model=model, datamodule=datamodule, ckpt_path=ckpt_path)
182
+
183
+ train_metrics = trainer.callback_metrics
184
+
185
+ if cfg.get("test"):
186
+ log.info("Starting testing!")
187
+ # Get best checkpoint path if checkpoint callback exists
188
+ if hasattr(trainer, 'checkpoint_callback') and trainer.checkpoint_callback is not None:
189
+ ckpt_path = trainer.checkpoint_callback.best_model_path
190
+ if ckpt_path == "":
191
+ log.warning("Best ckpt not found! Using current weights for testing...")
192
+ ckpt_path = None
193
+ else:
194
+ log.warning("No checkpoint callback found! Using current weights for testing...")
195
+ ckpt_path = None
196
+ trainer.test(model=model, datamodule=datamodule, ckpt_path=ckpt_path)
197
+ log.info(f"Best ckpt path: {ckpt_path}")
198
+
199
+ test_metrics = trainer.callback_metrics
200
+
201
+ # merge train and test metrics
202
+ metric_dict = {**train_metrics, **test_metrics}
203
+
204
+ return metric_dict, object_dict
205
+
206
+ return {}, object_dict
207
+
208
+
209
+ @hydra_main(version_base="1.3", config_path="../configs", config_name="train.yaml")
210
+ def main(cfg: DictConfig) -> Optional[float]:
211
+ """Main entry point for training.
212
+
213
+ :param cfg: DictConfig configuration composed by Hydra.
214
+ :return: Optional[float] with optimized metric value.
215
+ """
216
+ # handle A100 GPUs
217
+ if torch.cuda.is_available() and ("A100" in torch.cuda.get_device_name() or "A5000" in torch.cuda.get_device_name()):
218
+ torch.set_float32_matmul_precision("high")
219
+
220
+ # avoid annoying multiprocessing errors
221
+ torch.multiprocessing.set_sharing_strategy('file_system')
222
+
223
+ # prevent annoying warning
224
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
225
+
226
+ # apply extra utilities
227
+ # (e.g. ask for tags if none are provided in cfg, print cfg tree, etc.)
228
+ extras(cfg)
229
+
230
+ # train the model
231
+ metric_dict, _ = train(cfg)
232
+
233
+ # safely retrieve metric value for hydra-based hyperparameter optimization
234
+ metric_value = get_metric_value(
235
+ metric_dict=metric_dict, metric_name=cfg.get("optimized_metric")
236
+ )
237
+
238
+ # return optimized metric
239
+ return metric_value
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
steerable_retrieval/utils/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from steerable_retrieval.utils.instantiators import instantiate_callbacks, instantiate_loggers
2
+ from steerable_retrieval.utils.logging_utils import log_hyperparameters
3
+ from steerable_retrieval.utils.pylogger import RankedLogger
4
+ from steerable_retrieval.utils.resolvers import register_resolvers
5
+ from steerable_retrieval.utils.rich_utils import enforce_tags, print_config_tree
6
+ from steerable_retrieval.utils.utils import extras, get_metric_value, task_wrapper
steerable_retrieval/utils/copy.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ r"""Utils for copying data to the temporary directory of the compute node
2
+ """
3
+
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from filelock import FileLock
9
+
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ def copy_data(origin: Path, destination: Path):
15
+ cmd = "cp -r {origin} {destination}"
16
+ os.system(cmd.format(origin=origin, destination=destination))
17
+
18
+ def copy_to_compute_node(data_path: str | Path,
19
+ local_dir: str | Path = "/local/job"):
20
+ if not isinstance(data_path, Path):
21
+ data_path = Path(data_path)
22
+
23
+ if not isinstance(local_dir, Path):
24
+ local_dir = Path(local_dir)
25
+
26
+ # If we are not in a SLURM job, this function is a no-op
27
+ job_id = os.environ.get("SLURM_JOB_ID")
28
+ if job_id is None:
29
+ return data_path
30
+
31
+ # First, we check if the data we're looking for is already in the compute node
32
+ origin_folder = None
33
+
34
+ for folder in local_dir.iterdir():
35
+ if not os.access(folder, os.R_OK):
36
+ continue
37
+
38
+ subfolder = folder / data_path.name
39
+ if subfolder.exists():
40
+ origin_folder = subfolder
41
+ break
42
+
43
+ # Set the destination folder
44
+ dest_folder = local_dir / job_id / data_path.name
45
+ if dest_folder.exists():
46
+ log.info(f"Data found in {dest_folder}.")
47
+ return dest_folder
48
+
49
+ dest_folder.mkdir()
50
+
51
+ if origin_folder is None:
52
+ # In that case, we will copy from the login node directly (slow...)
53
+ origin_folder = data_path
54
+
55
+ else:
56
+ # Wait for the origin folder to have been fully filled to copy from the compute node (much faster!)
57
+ log.info(f"Data found in {origin_folder}. Waiting for the lock to be released...")
58
+ with FileLock(origin_folder / "lock"):
59
+ pass
60
+
61
+ # Lock the destination folder and copy the data inside it
62
+ log.info(f"Copying data from {origin_folder} to {dest_folder}...")
63
+ with FileLock(dest_folder / "lock"):
64
+ copy_data(origin_folder, dest_folder)
65
+
66
+ log.info("Done.")
67
+ return dest_folder
steerable_retrieval/utils/ema.py ADDED
File without changes