Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +32 -7
- app.py +142 -0
- core.py +98 -0
- requirements.txt +15 -0
- steerable_retrieval/__init__.py +8 -0
- steerable_retrieval/assets/__init__.py +1 -0
- steerable_retrieval/assets/muq_mulan_music4all_prior.npz +3 -0
- steerable_retrieval/callbacks/__init__.py +19 -0
- steerable_retrieval/callbacks/alignment.py +253 -0
- steerable_retrieval/callbacks/energy.py +1543 -0
- steerable_retrieval/callbacks/save.py +129 -0
- steerable_retrieval/callbacks/utils.py +36 -0
- steerable_retrieval/dataloading/__init__.py +1 -0
- steerable_retrieval/dataloading/dataloaders.py +319 -0
- steerable_retrieval/dataloading/datasets.py +437 -0
- steerable_retrieval/dataloading/loading_utils.py +131 -0
- steerable_retrieval/experiments/README.md +35 -0
- steerable_retrieval/experiments/__init__.py +2 -0
- steerable_retrieval/experiments/common.py +343 -0
- steerable_retrieval/experiments/concepts/__init__.py +2 -0
- steerable_retrieval/experiments/concepts/extract.py +899 -0
- steerable_retrieval/experiments/concepts/get_concepts.py +497 -0
- steerable_retrieval/experiments/notebook_cache.py +90 -0
- steerable_retrieval/experiments/run_concept_isolation.py +71 -0
- steerable_retrieval/experiments/run_stability.py +152 -0
- steerable_retrieval/extract_dataset.py +295 -0
- steerable_retrieval/models/__init__.py +2 -0
- steerable_retrieval/models/base.py +219 -0
- steerable_retrieval/models/encoders/__init__.py +11 -0
- steerable_retrieval/models/encoders/clap.py +158 -0
- steerable_retrieval/models/encoders/muq.py +120 -0
- steerable_retrieval/models/sae/__init__.py +46 -0
- steerable_retrieval/models/sae/decoders.py +83 -0
- steerable_retrieval/models/sae/encoders.py +360 -0
- steerable_retrieval/models/sae/penalties.py +152 -0
- steerable_retrieval/models/sae/sae.py +346 -0
- steerable_retrieval/models/utils/__init__.py +19 -0
- steerable_retrieval/models/utils/losses.py +398 -0
- steerable_retrieval/models/utils/schedulers.py +44 -0
- steerable_retrieval/steer/__init__.py +31 -0
- steerable_retrieval/steer/inversion.py +389 -0
- steerable_retrieval/steer/loading.py +131 -0
- steerable_retrieval/steer/prior_fit.py +95 -0
- steerable_retrieval/steer/retrieval.py +33 -0
- steerable_retrieval/steer/slider.py +245 -0
- steerable_retrieval/steer/steering.py +98 -0
- steerable_retrieval/train.py +243 -0
- steerable_retrieval/utils/__init__.py +6 -0
- steerable_retrieval/utils/copy.py +67 -0
- steerable_retrieval/utils/ema.py +0 -0
README.md
CHANGED
|
@@ -1,13 +1,38 @@
|
|
| 1 |
---
|
| 2 |
-
title: Steerable Retrieval
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|