rootxhacker commited on
Commit
e40c6c0
·
verified ·
1 Parent(s): bd74813

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app.py +69 -0
  2. hobbylm/sae.py +106 -0
app.py CHANGED
@@ -157,6 +157,23 @@ def _load_image_models():
157
  return dit, lat, lat_std, sf, ae, tok, clip
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  # --------------------------------------------------------------------------- chat
161
 
162
  def _build_prompt(repo, message, history):
@@ -380,6 +397,46 @@ def how_it_works(prompt, layer):
380
  model.to("cpu")
381
 
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  # --------------------------------------------------------------------------- UI
384
 
385
  INTRO = """# 🪶 HobbyLM Playground
@@ -456,5 +513,17 @@ with gr.Blocks(title="HobbyLM Playground", theme=gr.themes.Soft()) as demo:
456
  hiw_load = gr.Plot(label="Expert load (balancing)")
457
  hiw_btn.click(how_it_works, [hiw_prompt, hiw_layer], [hiw_heat, hiw_load, hiw_summary])
458
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  if __name__ == "__main__":
460
  demo.queue(max_size=20).launch()
 
157
  return dit, lat, lat_std, sf, ae, tok, clip
158
 
159
 
160
+ SAE_REPO = "rootxhacker/HobbyLM-SAE"
161
+
162
+
163
+ def _load_sae():
164
+ key = ("sae",)
165
+ if key in _cache:
166
+ return _cache[key]
167
+ import json
168
+ from hobbylm.sae import TopKSAE, SAEConfig
169
+ meta = json.load(open(hf_hub_download(SAE_REPO, "meta.json")))
170
+ labels = json.load(open(hf_hub_download(SAE_REPO, "labels.json")))
171
+ sae = TopKSAE(SAEConfig(**meta["cfg"])).eval()
172
+ sae.load_state_dict(load_file(hf_hub_download(SAE_REPO, "sae.safetensors")))
173
+ _cache[key] = (sae, meta, labels)
174
+ return _cache[key]
175
+
176
+
177
  # --------------------------------------------------------------------------- chat
178
 
179
  def _build_prompt(repo, message, history):
 
397
  model.to("cpu")
398
 
399
 
400
+ # --------------------------------------------------------------------------- how it works (SAE features)
401
+
402
+ @spaces.GPU(duration=90)
403
+ def sae_features(prompt, topn):
404
+ dev = _device()
405
+ enc = _enc()
406
+ try:
407
+ sae, meta, labels = _load_sae()
408
+ except Exception as e:
409
+ return f"⚠️ SAE not available yet: {e}"
410
+ model, _ = _load_llm("HobbyLM-Base")
411
+ model.to(dev); sae.to(dev)
412
+ layer, scale = meta["layer"], float(meta["scale"])
413
+ topn = int(topn)
414
+ try:
415
+ ids = enc.encode_ordinary(prompt or "I love listening to music while coding software.")[:48]
416
+ if not ids:
417
+ ids = enc.encode_ordinary("Hello world")
418
+ toks = torch.tensor([ids], device=dev)
419
+ with torch.no_grad():
420
+ h = model(toks, capture_layer=layer).float() * scale
421
+ z = sae.encode(h.reshape(-1, sae.cfg.d_in)) # (S, m)
422
+ md = ("Each token's residual is decomposed into a few **interpretable features** from the SAE "
423
+ "dictionary. Below: per token, the strongest features (auto-labelled by the tokens they "
424
+ "fire on most).\n\n| token | top active features  ·  *(label · strength)* |\n|---|---|\n")
425
+ for s, tid in enumerate(ids):
426
+ v, f = z[s].topk(min(topn, z.shape[1]))
427
+ tok_str = enc.decode([tid]).replace("|", "¦").replace("\n", "⏎").strip() or "·"
428
+ parts = []
429
+ for val, fi in zip(v.tolist(), f.tolist()):
430
+ if val <= 1e-4:
431
+ continue
432
+ lab = labels.get(str(int(fi)), {}).get("label") or f"feat#{int(fi)}"
433
+ parts.append(f"**{lab}** ({val:.1f})")
434
+ md += f"| `{tok_str}` | {' · '.join(parts) or '—'} |\n"
435
+ return md
436
+ finally:
437
+ model.to("cpu"); sae.to("cpu")
438
+
439
+
440
  # --------------------------------------------------------------------------- UI
441
 
442
  INTRO = """# 🪶 HobbyLM Playground
 
513
  hiw_load = gr.Plot(label="Expert load (balancing)")
514
  hiw_btn.click(how_it_works, [hiw_prompt, hiw_layer], [hiw_heat, hiw_load, hiw_summary])
515
 
516
+ with gr.Tab("🧠 What it represents"):
517
+ gr.Markdown(
518
+ "A **sparse autoencoder** (SAE) trained on HobbyLM-Base's layer-8 residual stream pulls apart each "
519
+ "activation into a handful of **interpretable features** from a 12,288-entry dictionary. Type text and "
520
+ "see which concepts light up on each token — words, synonym clusters, syntax, formatting. This is "
521
+ "*mechanistic interpretability*: looking at what the model actually represents inside.")
522
+ sae_prompt = gr.Textbox(label="Text", value="I love listening to music while coding software.")
523
+ sae_top = gr.Slider(2, 8, value=4, step=1, label="Features shown per token")
524
+ sae_btn = gr.Button("Show features", variant="primary")
525
+ sae_out = gr.Markdown()
526
+ sae_btn.click(sae_features, [sae_prompt, sae_top], sae_out)
527
+
528
  if __name__ == "__main__":
529
  demo.queue(max_size=20).launch()
hobbylm/sae.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Top-k Sparse Autoencoder for mechanistic interpretability of HobbyLM activations.
2
+
3
+ A top-k SAE (Gao et al. 2024 / EleutherAI `sae`) over the residual stream: it reconstructs an
4
+ activation x as a sparse, non-negative combination of a learned overcomplete dictionary, keeping only
5
+ the k largest latents active. No L1 coefficient to tune (sparsity is exactly k), and an auxiliary loss
6
+ resurrects dead features so the dictionary stays fully used.
7
+
8
+ x_centered = x - b_dec
9
+ z = TopK( relu(x_centered @ W_enc + b_enc) ) # exactly k non-zeros
10
+ x_hat = z @ W_dec + b_dec # W_dec rows are unit-norm
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+ from torch import Tensor
20
+
21
+
22
+ @dataclass
23
+ class SAEConfig:
24
+ d_in: int = 768 # model activation dim (residual stream)
25
+ d_sae: int = 12288 # dictionary size (16x expansion)
26
+ k: int = 32 # active latents per token
27
+ k_aux: int = 256 # dead-latent auxiliary reconstruction width
28
+ dead_after: int = 2_000_000 # a feature unused for this many tokens is "dead"
29
+ aux_coef: float = 1.0 / 32.0 # weight on the auxk resurrection loss
30
+
31
+
32
+ def _topk(pre: Tensor, k: int) -> Tensor:
33
+ """Keep the k largest values per row (after the relu in the caller), zero the rest."""
34
+ vals, idx = pre.topk(k, dim=-1)
35
+ out = torch.zeros_like(pre)
36
+ return out.scatter_(-1, idx, vals)
37
+
38
+
39
+ class TopKSAE(nn.Module):
40
+ def __init__(self, cfg: SAEConfig):
41
+ super().__init__()
42
+ self.cfg = cfg
43
+ d, m = cfg.d_in, cfg.d_sae
44
+ self.b_dec = nn.Parameter(torch.zeros(d))
45
+ self.b_enc = nn.Parameter(torch.zeros(m))
46
+ # decoder rows unit-norm; encoder initialised as the decoder transpose (standard tied init)
47
+ W_dec = F.normalize(torch.randn(m, d), dim=1)
48
+ self.W_dec = nn.Parameter(W_dec)
49
+ self.W_enc = nn.Parameter(W_dec.t().clone())
50
+ # tokens since each latent last fired (for dead-feature tracking); not a learned param
51
+ self.register_buffer("last_fired", torch.zeros(m, dtype=torch.long))
52
+ self.register_buffer("n_seen", torch.zeros((), dtype=torch.long))
53
+
54
+ # ---- encode / decode ----
55
+ def encode(self, x: Tensor) -> Tensor:
56
+ pre = (x - self.b_dec) @ self.W_enc + self.b_enc
57
+ return _topk(F.relu(pre), self.cfg.k) # (..., d_sae), exactly k non-zeros
58
+
59
+ def decode(self, z: Tensor) -> Tensor:
60
+ return z @ self.W_dec + self.b_dec
61
+
62
+ def forward(self, x: Tensor):
63
+ """Returns (x_hat, z, loss_dict). x: (N, d_in)."""
64
+ pre = F.relu((x - self.b_dec) @ self.W_enc + self.b_enc)
65
+ z = _topk(pre, self.cfg.k)
66
+ x_hat = self.decode(z)
67
+ recon = F.mse_loss(x_hat, x)
68
+
69
+ # ---- dead-feature bookkeeping + auxk resurrection ----
70
+ with torch.no_grad():
71
+ fired = (z > 0).any(dim=0) # (d_sae,)
72
+ self.last_fired += x.shape[0]
73
+ self.last_fired[fired] = 0
74
+ self.n_seen += x.shape[0]
75
+ dead = self.last_fired > self.cfg.dead_after
76
+ aux = x.new_zeros(())
77
+ n_dead = int(dead.sum())
78
+ if n_dead > 0 and self.cfg.k_aux > 0:
79
+ # reconstruct the residual error using only the top-k_aux DEAD latents (revives them)
80
+ resid = x - x_hat.detach()
81
+ pre_dead = pre.masked_fill(~dead[None, :], 0.0)
82
+ z_aux = _topk(pre_dead, min(self.cfg.k_aux, n_dead))
83
+ resid_hat = z_aux @ self.W_dec # no b_dec: model the centred residual
84
+ aux = F.mse_loss(resid_hat, resid)
85
+ loss = recon + self.cfg.aux_coef * aux
86
+ else:
87
+ loss = recon
88
+ return x_hat, z, {"loss": loss, "recon": recon.detach(), "aux": aux.detach(),
89
+ "n_dead": n_dead}
90
+
91
+ # ---- keep decoder rows unit-norm (call after each optimizer step) ----
92
+ @torch.no_grad()
93
+ def normalize_decoder(self):
94
+ self.W_dec.data = F.normalize(self.W_dec.data, dim=1)
95
+
96
+ @torch.no_grad()
97
+ def set_decoder_to_geometric_mean(self, x: Tensor):
98
+ """Initialise b_dec to the data mean (standard) — call once on the first activation batch."""
99
+ self.b_dec.data = x.mean(dim=0)
100
+
101
+
102
+ def fraction_variance_explained(x: Tensor, x_hat: Tensor) -> float:
103
+ """1 - ||x - x_hat||^2 / ||x - mean(x)||^2 (per-batch FVU complement)."""
104
+ num = (x - x_hat).pow(2).sum()
105
+ den = (x - x.mean(0, keepdim=True)).pow(2).sum().clamp_min(1e-8)
106
+ return float((1.0 - num / den).detach())