open-interpretability 0.0.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. open_interpretability-0.0.20.dist-info/METADATA +157 -0
  2. open_interpretability-0.0.20.dist-info/RECORD +256 -0
  3. open_interpretability-0.0.20.dist-info/WHEEL +4 -0
  4. open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
  5. open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
  6. openinterp/__init__.py +6 -0
  7. openinterp/agents/__init__.py +25 -0
  8. openinterp/agents/base.py +229 -0
  9. openinterp/agents/judge.py +149 -0
  10. openinterp/agents/proposer.py +471 -0
  11. openinterp/agents/proposer_profiles.py +79 -0
  12. openinterp/agents/review_measurements.py +173 -0
  13. openinterp/agents/reviewer.py +498 -0
  14. openinterp/agreement.py +63 -0
  15. openinterp/audit_options.py +79 -0
  16. openinterp/baseline.py +254 -0
  17. openinterp/benchmark.py +450 -0
  18. openinterp/blends.py +239 -0
  19. openinterp/builder/__init__.py +6 -0
  20. openinterp/builder/agents.py +260 -0
  21. openinterp/builder/aligned.py +41 -0
  22. openinterp/builder/charts.py +411 -0
  23. openinterp/builder/circuit_pages.py +37 -0
  24. openinterp/builder/circuits.py +264 -0
  25. openinterp/builder/compiler.py +495 -0
  26. openinterp/builder/corpora.py +98 -0
  27. openinterp/builder/coverage.py +46 -0
  28. openinterp/builder/dashboard.py +255 -0
  29. openinterp/builder/disk_memo.py +65 -0
  30. openinterp/builder/experiment.py +105 -0
  31. openinterp/builder/heat.py +121 -0
  32. openinterp/builder/hrefs.py +71 -0
  33. openinterp/builder/learned_baseline.py +204 -0
  34. openinterp/builder/model_facts.py +138 -0
  35. openinterp/builder/neuronpedia_export.py +107 -0
  36. openinterp/builder/parallel.py +100 -0
  37. openinterp/builder/parquet_export.py +458 -0
  38. openinterp/builder/pieces.py +127 -0
  39. openinterp/builder/pii.py +211 -0
  40. openinterp/builder/registry_stats.py +144 -0
  41. openinterp/builder/research.py +109 -0
  42. openinterp/builder/sections.py +28 -0
  43. openinterp/builder/significance.py +103 -0
  44. openinterp/builder/sister_graph.py +180 -0
  45. openinterp/builder/standing.py +185 -0
  46. openinterp/builder/statuses.py +34 -0
  47. openinterp/builder/stopped.py +91 -0
  48. openinterp/builder/stops.py +30 -0
  49. openinterp/builder/superpositions.py +102 -0
  50. openinterp/builder/templates/_agent_role_cells.html +9 -0
  51. openinterp/builder/templates/_causal_chart.html +67 -0
  52. openinterp/builder/templates/_circuit.html +8 -0
  53. openinterp/builder/templates/_dashboard.html +133 -0
  54. openinterp/builder/templates/_experiment.html +500 -0
  55. openinterp/builder/templates/_macros.html +82 -0
  56. openinterp/builder/templates/_patchscope.html +33 -0
  57. openinterp/builder/templates/_prior_art.html +24 -0
  58. openinterp/builder/templates/_specificity.html +21 -0
  59. openinterp/builder/templates/_steering.html +34 -0
  60. openinterp/builder/templates/_superposition.html +62 -0
  61. openinterp/builder/templates/_vocab_chart.html +88 -0
  62. openinterp/builder/templates/_wiring.html +44 -0
  63. openinterp/builder/templates/about.html +95 -0
  64. openinterp/builder/templates/agent.html +69 -0
  65. openinterp/builder/templates/agents.html +80 -0
  66. openinterp/builder/templates/base.html +103 -0
  67. openinterp/builder/templates/circuit.html +64 -0
  68. openinterp/builder/templates/circuits.html +46 -0
  69. openinterp/builder/templates/experiment.html +21 -0
  70. openinterp/builder/templates/hub.html +16 -0
  71. openinterp/builder/templates/index.html +387 -0
  72. openinterp/builder/templates/layer.html +69 -0
  73. openinterp/builder/templates/learned-baseline.html +44 -0
  74. openinterp/builder/templates/model.html +151 -0
  75. openinterp/builder/templates/models.html +47 -0
  76. openinterp/builder/templates/neuron.html +234 -0
  77. openinterp/builder/templates/paper.html +39 -0
  78. openinterp/builder/templates/redirect.html +1 -0
  79. openinterp/builder/templates/research.html +31 -0
  80. openinterp/builder/templates/user.html +54 -0
  81. openinterp/builder/templates/users.html +39 -0
  82. openinterp/builder/templates/wiki/experiment.md +271 -0
  83. openinterp/builder/templates/wiki/index.md +32 -0
  84. openinterp/builder/templates/wiki/neuron.md +30 -0
  85. openinterp/builder/templates/wiki/stop.md +36 -0
  86. openinterp/builder/tree.py +470 -0
  87. openinterp/builder/users.py +142 -0
  88. openinterp/builder/wiki.py +427 -0
  89. openinterp/checks.py +429 -0
  90. openinterp/cli.py +494 -0
  91. openinterp/cli_baseline.py +332 -0
  92. openinterp/cli_benchmark.py +300 -0
  93. openinterp/cli_build.py +128 -0
  94. openinterp/cli_check.py +51 -0
  95. openinterp/cli_concepts.py +76 -0
  96. openinterp/cli_corpus.py +252 -0
  97. openinterp/cli_costs.py +257 -0
  98. openinterp/cli_coverage.py +76 -0
  99. openinterp/cli_crossref.py +86 -0
  100. openinterp/cli_errata.py +142 -0
  101. openinterp/cli_evidence.py +495 -0
  102. openinterp/cli_evidence_store.py +93 -0
  103. openinterp/cli_explain.py +101 -0
  104. openinterp/cli_loop.py +125 -0
  105. openinterp/cli_memory.py +28 -0
  106. openinterp/cli_neuronpedia.py +175 -0
  107. openinterp/cli_pause.py +45 -0
  108. openinterp/cli_pole_effects.py +133 -0
  109. openinterp/cli_probing.py +119 -0
  110. openinterp/cli_registry.py +348 -0
  111. openinterp/cli_reproduce.py +446 -0
  112. openinterp/cli_review.py +302 -0
  113. openinterp/cli_scan.py +223 -0
  114. openinterp/cli_score.py +446 -0
  115. openinterp/cli_sisters.py +86 -0
  116. openinterp/cli_submission.py +217 -0
  117. openinterp/cli_superpositions.py +118 -0
  118. openinterp/cli_wiring.py +54 -0
  119. openinterp/codeid.py +73 -0
  120. openinterp/concepts.py +400 -0
  121. openinterp/core/__init__.py +59 -0
  122. openinterp/core/agent_names.py +76 -0
  123. openinterp/core/attribution.py +101 -0
  124. openinterp/core/cache_dirs.py +15 -0
  125. openinterp/core/causal.py +82 -0
  126. openinterp/core/confounds.py +103 -0
  127. openinterp/core/contributors.py +26 -0
  128. openinterp/core/corpora.py +126 -0
  129. openinterp/core/costs.py +430 -0
  130. openinterp/core/draws.py +109 -0
  131. openinterp/core/errata.py +244 -0
  132. openinterp/core/evidence.py +160 -0
  133. openinterp/core/examples.py +93 -0
  134. openinterp/core/exceptions.py +29 -0
  135. openinterp/core/explanation.py +130 -0
  136. openinterp/core/fence.py +42 -0
  137. openinterp/core/heavy_lock.py +114 -0
  138. openinterp/core/identifiers.py +198 -0
  139. openinterp/core/isolation.py +149 -0
  140. openinterp/core/ledger.py +130 -0
  141. openinterp/core/licenses.py +28 -0
  142. openinterp/core/manifold.py +117 -0
  143. openinterp/core/partners.py +32 -0
  144. openinterp/core/patchscope.py +59 -0
  145. openinterp/core/paths.py +40 -0
  146. openinterp/core/pause.py +96 -0
  147. openinterp/core/pricing.py +178 -0
  148. openinterp/core/prior_art.py +44 -0
  149. openinterp/core/probing.py +146 -0
  150. openinterp/core/profiles.py +221 -0
  151. openinterp/core/project.py +28 -0
  152. openinterp/core/run_environment.py +38 -0
  153. openinterp/core/schemas.py +484 -0
  154. openinterp/core/specificity.py +49 -0
  155. openinterp/core/stats.py +172 -0
  156. openinterp/core/steering.py +49 -0
  157. openinterp/core/strict.py +19 -0
  158. openinterp/core/takedowns.py +136 -0
  159. openinterp/core/transcripts.py +130 -0
  160. openinterp/core/wiring.py +80 -0
  161. openinterp/corpus.py +476 -0
  162. openinterp/corpus_cache.py +145 -0
  163. openinterp/corpus_layouts.py +108 -0
  164. openinterp/corpus_presets.py +168 -0
  165. openinterp/corpus_rebuild.py +93 -0
  166. openinterp/coverage.py +155 -0
  167. openinterp/crossref.py +442 -0
  168. openinterp/data/__init__.py +9 -0
  169. openinterp/data/offensive_words.txt +58 -0
  170. openinterp/data/public_suffix_list.dat +15771 -0
  171. openinterp/data/sample_corpus.txt +40 -0
  172. openinterp/draw_steps.py +359 -0
  173. openinterp/errata_checks.py +129 -0
  174. openinterp/evidence.py +462 -0
  175. openinterp/evidence_branch.py +119 -0
  176. openinterp/evidence_lenses.py +75 -0
  177. openinterp/experiment_loop.py +357 -0
  178. openinterp/explain.py +252 -0
  179. openinterp/half_precision.py +79 -0
  180. openinterp/harness/__init__.py +37 -0
  181. openinterp/harness/ane/__init__.py +9 -0
  182. openinterp/harness/ane/backend.py +324 -0
  183. openinterp/harness/ane/cache.py +276 -0
  184. openinterp/harness/ane/chunks.py +163 -0
  185. openinterp/harness/ane/convert.py +116 -0
  186. openinterp/harness/ane/forward.py +258 -0
  187. openinterp/harness/ane/qualified.py +40 -0
  188. openinterp/harness/annotations.py +329 -0
  189. openinterp/harness/attribution.py +96 -0
  190. openinterp/harness/causal.py +264 -0
  191. openinterp/harness/confounds.py +184 -0
  192. openinterp/harness/covariance.py +301 -0
  193. openinterp/harness/crossref.py +195 -0
  194. openinterp/harness/cuda_devices.py +88 -0
  195. openinterp/harness/dead.py +124 -0
  196. openinterp/harness/devices.py +401 -0
  197. openinterp/harness/dictionaries.py +496 -0
  198. openinterp/harness/dreaming.py +116 -0
  199. openinterp/harness/environment.py +64 -0
  200. openinterp/harness/explanation_scores.py +371 -0
  201. openinterp/harness/features.py +276 -0
  202. openinterp/harness/first_position.py +137 -0
  203. openinterp/harness/heads.py +226 -0
  204. openinterp/harness/hooks.py +499 -0
  205. openinterp/harness/input_attribution.py +367 -0
  206. openinterp/harness/jlens.py +183 -0
  207. openinterp/harness/layer_cache.py +301 -0
  208. openinterp/harness/layer_major.py +274 -0
  209. openinterp/harness/lookahead.py +93 -0
  210. openinterp/harness/manifold.py +200 -0
  211. openinterp/harness/memory.py +471 -0
  212. openinterp/harness/moe.py +134 -0
  213. openinterp/harness/patcher.py +322 -0
  214. openinterp/harness/patchscope.py +177 -0
  215. openinterp/harness/probing.py +383 -0
  216. openinterp/harness/scan.py +462 -0
  217. openinterp/harness/scan_backend.py +201 -0
  218. openinterp/harness/sister_passes.py +463 -0
  219. openinterp/harness/sisters.py +363 -0
  220. openinterp/harness/sites.py +101 -0
  221. openinterp/harness/specificity.py +107 -0
  222. openinterp/harness/steering.py +159 -0
  223. openinterp/harness/superpositions.py +116 -0
  224. openinterp/harness/units.py +203 -0
  225. openinterp/harness/wiring.py +432 -0
  226. openinterp/harness/wiring_activations.py +285 -0
  227. openinterp/langid.py +73 -0
  228. openinterp/names.py +129 -0
  229. openinterp/neuronpedia_baseline.py +88 -0
  230. openinterp/neuronpedia_review.py +148 -0
  231. openinterp/planner.py +466 -0
  232. openinterp/planner_effects.py +89 -0
  233. openinterp/planner_heads.py +92 -0
  234. openinterp/planner_models.py +174 -0
  235. openinterp/planner_sisters.py +122 -0
  236. openinterp/planner_states.py +57 -0
  237. openinterp/planner_superpositions.py +114 -0
  238. openinterp/pole_effects.py +102 -0
  239. openinterp/prior_art.py +143 -0
  240. openinterp/reproduce.py +450 -0
  241. openinterp/revision.py +45 -0
  242. openinterp/runner_cache.py +35 -0
  243. openinterp/score_controls.py +169 -0
  244. openinterp/sensitive.py +243 -0
  245. openinterp/show.py +236 -0
  246. openinterp/signing.py +209 -0
  247. openinterp/sisters.py +476 -0
  248. openinterp/superposition_finders.py +225 -0
  249. openinterp/sync/__init__.py +5 -0
  250. openinterp/sync/r2.py +280 -0
  251. openinterp/syntax.py +399 -0
  252. openinterp/takedown_checks.py +76 -0
  253. openinterp/toxicity.py +158 -0
  254. openinterp/verdict_regression.py +79 -0
  255. openinterp/wiring_backfill.py +170 -0
  256. openinterp/wordnet.py +362 -0
@@ -0,0 +1,363 @@
1
+ """Sister neurons: the poles of another model whose activity tracks a pole's.
2
+
3
+ Both models read the same corpus. They are compared where both have just read the same
4
+ text: at *shared boundaries*. These are character offsets where a token ends in both
5
+ models' tokenizations. Models that share a tokenizer share every boundary. Across
6
+ tokenizers (GPT-2 and Qwen2.5), most word ends are shared. Both models are causal, so
7
+ each sees exactly the same text up to a shared boundary. So a pole of one and a pole of
8
+ the other give two series over the same positions.
9
+
10
+ Their Pearson correlation says how far they fire together. Take a pole whose best match
11
+ correlates strongly, and is in turn that match's best partner in the first model (a
12
+ *mutual* best match). It is a candidate for the same feature in both models. Gurnee et
13
+ al. (2024, "Universal Neurons in GPT2 Language Models") match neurons across seeds this
14
+ way.
15
+
16
+ Each series is a pole strength (:func:`~openinterp.harness.hooks.pole_strength`), so a
17
+ neuron's two poles are matched separately. A text's first token is left out. Attention
18
+ there sees only the token itself, and many units take extreme values on it that would
19
+ dominate the correlation.
20
+
21
+ ``rotated`` puts a match in context. It is the best correlation that the pole reaches
22
+ with any unit of a Haar-random rotation of the match's layer.
23
+ (``docs/analyses/null-calibration.md`` rotates a basis in the same way.) That is the same
24
+ number of candidates, mixing the same activations, but not the model's own neurons. A
25
+ sister well above it is a fact about the other model's neuron basis, not only about what
26
+ the layer can represent.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import os
33
+ from collections.abc import Callable, Iterable, Iterator, Sequence
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+
37
+ import torch
38
+
39
+ from openinterp.core.schemas import Pole
40
+ from openinterp.harness.hooks import NeuronHookRunner
41
+ from openinterp.harness.scan import BATCH_TOKENS, plan_batches
42
+
43
+ POLES: tuple[Pole, Pole] = ("positive", "negative")
44
+
45
+ # A unit: (layer, neuron, pole).
46
+ Unit = tuple[int, int, Pole]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Match:
51
+ """One pole of the other model and its Pearson correlation with the source pole."""
52
+
53
+ layer: int
54
+ neuron_idx: int
55
+ pole: Pole
56
+ pearson: float
57
+
58
+
59
+ def pole_columns(acts: torch.Tensor) -> torch.Tensor:
60
+ """``[n, w]`` signed activations → ``[n, 2w]`` pole strengths, positive poles first."""
61
+ return torch.cat([acts.clamp(min=0), (-acts).clamp(min=0)], dim=1)
62
+
63
+
64
+ def _unit(layer: int, column: int, width: int) -> Unit:
65
+ return layer, column % width, POLES[column // width]
66
+
67
+
68
+ class Correlations:
69
+ """Streaming Pearson correlation between ``P`` source series and target columns.
70
+
71
+ The targets are every column of some matrices, one per layer. Each batch's sums are
72
+ taken on the device in the inputs' dtype (MPS has no float64). The running totals are
73
+ float64 on the CPU.
74
+ """
75
+
76
+ def __init__(self, n_sources: int) -> None:
77
+ self.n = 0
78
+ self.sx = torch.zeros(n_sources, dtype=torch.float64)
79
+ self.sxx = torch.zeros(n_sources, dtype=torch.float64)
80
+ self.sy: dict[int, torch.Tensor] = {}
81
+ self.syy: dict[int, torch.Tensor] = {}
82
+ self.sxy: dict[int, torch.Tensor] = {}
83
+
84
+ def add_sources(self, x: torch.Tensor) -> None:
85
+ """Count one batch's ``[n, P]`` source rows; call once per batch."""
86
+ self.n += x.shape[0]
87
+ self.sx += x.sum(0).cpu().double()
88
+ self.sxx += (x * x).sum(0).cpu().double()
89
+
90
+ def add_targets(self, key: int, x: torch.Tensor, y: torch.Tensor) -> None:
91
+ """Pair the batch's ``[n, P]`` sources with target ``key``'s ``[n, C]`` columns."""
92
+ xy = (x.to(y.dtype).T @ y).cpu().double()
93
+ if key not in self.sxy:
94
+ self.sxy[key] = torch.zeros_like(xy)
95
+ self.sy[key] = torch.zeros(y.shape[1], dtype=torch.float64)
96
+ self.syy[key] = torch.zeros(y.shape[1], dtype=torch.float64)
97
+ self.sxy[key] += xy
98
+ self.sy[key] += y.sum(0).cpu().double()
99
+ self.syy[key] += (y * y).sum(0).cpu().double()
100
+
101
+ def pearson(self, key: int) -> torch.Tensor:
102
+ """``[P, C]`` correlations with target ``key``; 0 where either side is constant."""
103
+ n = max(self.n, 1)
104
+ mx, my = self.sx / n, self.sy[key] / n
105
+ cov = self.sxy[key] / n - mx[:, None] * my[None, :]
106
+ var = (self.sxx / n - mx**2)[:, None] * (self.syy[key] / n - my**2)[None, :]
107
+ denom = var.clamp(min=0).sqrt()
108
+ return torch.where(denom > 1e-12, cov / denom, torch.zeros_like(cov)).clamp(-1, 1)
109
+
110
+
111
+ ROTATION_CACHE_ENV = "OPENINTERP_ROTATION_CACHE" # a folder, or "0" to turn the cache off
112
+
113
+
114
+ def _seed(model_name: str, layer: int) -> int:
115
+ digest = hashlib.sha256(f"open-interpretability sisters {model_name} {layer}".encode()).digest()
116
+ return int.from_bytes(digest[:8])
117
+
118
+
119
+ def rotation(model_name: str, layer: int, width: int) -> torch.Tensor:
120
+ """Return a Haar-random orthogonal ``[width, width]`` matrix.
121
+
122
+ It is the QR of a Gaussian, with signs fixed. It is seeded by model and layer, so a
123
+ rerun draws the same one. The float64 QR takes minutes for a wide layer, so the result
124
+ is cached on disk (:func:`rotation_cache`, issue #3646).
125
+ """
126
+ path = rotation_cache(model_name, layer, width)
127
+ if path is not None and path.exists():
128
+ return torch.load(path, weights_only=True)
129
+ generator = torch.Generator().manual_seed(_seed(model_name, layer))
130
+ q, r = torch.linalg.qr(torch.randn(width, width, generator=generator, dtype=torch.float64))
131
+ matrix = (q * torch.sign(torch.diagonal(r))).float()
132
+ if path is not None:
133
+ path.parent.mkdir(parents=True, exist_ok=True)
134
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
135
+ torch.save(matrix, tmp)
136
+ tmp.replace(path) # atomic: a reader sees no file or a whole one
137
+ return matrix
138
+
139
+
140
+ def rotation_cache(model_name: str, layer: int, width: int) -> Path | None:
141
+ """Where :func:`rotation` caches a matrix, or ``None`` when the cache is off.
142
+
143
+ The folder is ``$OPENINTERP_ROTATION_CACHE``, else ``~/.cache/openinterp/rotations``.
144
+ The value ``0`` turns the cache off. The name holds the torch version, because the
145
+ QR's result could change with it. A float32 rotation takes width² × 4 bytes.
146
+ """
147
+ folder = os.environ.get(ROTATION_CACHE_ENV)
148
+ if folder == "0":
149
+ return None
150
+ root = Path(folder) if folder else Path.home() / ".cache" / "openinterp" / "rotations"
151
+ name = f"{model_name.replace('/', '--')}--L{layer}--w{width}--seed{_seed(model_name, layer)}"
152
+ return root / f"{name}--torch{torch.__version__}.pt"
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Tokens:
157
+ """One text as one model reads it: token ids, and where each token ends.
158
+
159
+ ``ends`` is the character offset that each token ends at. It is -1 for a token that
160
+ covers no characters, such as a special token.
161
+ """
162
+
163
+ ids: list[int]
164
+ ends: list[int]
165
+
166
+
167
+ def _context(runner: NeuronHookRunner) -> int | None:
168
+ config = getattr(runner.model, "config", None)
169
+ return getattr(config, "n_positions", None) or getattr(config, "max_position_embeddings", None)
170
+
171
+
172
+ def tokenize(runner: NeuronHookRunner, text: str) -> Tokens:
173
+ """``text`` as ``runner`` reads it, cut at the model's context length."""
174
+ enc = runner.tokenizer(text, return_offsets_mapping=True)
175
+ ends = [end if end > start else -1 for start, end in enc["offset_mapping"]]
176
+ limit = _context(runner)
177
+ return Tokens(list(enc["input_ids"])[:limit], ends[:limit])
178
+
179
+
180
+ def shared_boundaries(a: Sequence[int], b: Sequence[int]) -> tuple[list[int], list[int]]:
181
+ """Return the positions in two tokenizations where both have just read the same text.
182
+
183
+ ``a`` and ``b`` are token end offsets. The positions are tokens that end at the same
184
+ character. Where several tokens end at one character, the last counts. Each text's
185
+ first token is left out.
186
+ """
187
+ last_a = {end: i for i, end in enumerate(a) if end >= 0 and i > 0}
188
+ last_b = {end: i for i, end in enumerate(b) if end >= 0 and i > 0}
189
+ shared = sorted(last_a.keys() & last_b.keys())
190
+ return [last_a[e] for e in shared], [last_b[e] for e in shared]
191
+
192
+
193
+ def paired_rows(
194
+ a: NeuronHookRunner,
195
+ b: NeuronHookRunner,
196
+ texts: Sequence[str],
197
+ layers_a: Iterable[int],
198
+ layers_b: Iterable[int],
199
+ batch_tokens: int = BATCH_TOKENS,
200
+ ) -> Iterator[tuple[dict[int, torch.Tensor], dict[int, torch.Tensor]]]:
201
+ """Yield both models' activations ``{layer: [n, width]}``, row for row.
202
+
203
+ The rows are the shared boundaries of each group of documents. Each document is one
204
+ forward pass of each model.
205
+ """
206
+ docs = [(tokenize(a, t), tokenize(b, t)) for t in texts]
207
+ docs = [(x, y) for x, y in docs if x.ids and y.ids]
208
+ lengths = [max(len(x.ids), len(y.ids)) for x, y in docs]
209
+ layers_a, layers_b, start = sorted(set(layers_a)), sorted(set(layers_b)), 0
210
+ for size in plan_batches(lengths, batch_tokens):
211
+ group, start = docs[start : start + size], start + size
212
+ pairs = [shared_boundaries(x.ends, y.ends) for x, y in group]
213
+ yield (
214
+ _gather(a, [x.ids for x, _ in group], [p[0] for p in pairs], layers_a),
215
+ _gather(b, [y.ids for _, y in group], [p[1] for p in pairs], layers_b),
216
+ )
217
+
218
+
219
+ def _gather(runner: NeuronHookRunner, seqs, positions, layers) -> dict[int, torch.Tensor]:
220
+ """Run ``seqs`` (right-padded) and take ``layers``' activations at ``positions``."""
221
+ width = max(map(len, seqs))
222
+ pad = getattr(runner.tokenizer, "pad_token_id", None) or 0
223
+ ids = torch.tensor([s + [pad] * (width - len(s)) for s in seqs])
224
+ mask = torch.tensor([[1] * len(s) + [0] * (width - len(s)) for s in seqs])
225
+ _, cache = runner.run_with_cache({"input_ids": ids, "attention_mask": mask}, layers)
226
+ rows = [(row, p) for row, pos in enumerate(positions) for p in pos]
227
+ index = torch.tensor(rows, dtype=torch.long).reshape(-1, 2).to(runner.device)
228
+ return {layer: cache[layer][index[:, 0], index[:, 1]].float() for layer in layers}
229
+
230
+
231
+ def _source_rows(rows: dict[int, torch.Tensor], units: Sequence[Unit]) -> torch.Tensor:
232
+ """``[n, len(units)]`` pole strengths of ``units``."""
233
+ cols = []
234
+ for layer, idx, pole in units:
235
+ act = rows[layer][:, idx]
236
+ cols.append(act.clamp(min=0) if pole == "positive" else (-act).clamp(min=0))
237
+ return torch.stack(cols, dim=1)
238
+
239
+
240
+ def _top(corr: Correlations, keys: Iterable[int], widths: dict[int, int], k: int):
241
+ """Each source's ``k`` best target units over ``keys``, by Pearson."""
242
+ best: list[list[Match]] = [[] for _ in range(corr.sx.shape[0])]
243
+ for key in keys:
244
+ r = corr.pearson(key)
245
+ values, cols = r.topk(min(k, r.shape[1]), dim=1)
246
+ for i in range(r.shape[0]):
247
+ for value, col in zip(values[i].tolist(), cols[i].tolist(), strict=True):
248
+ layer, idx, pole = _unit(key, col, widths[key])
249
+ best[i].append(Match(layer, idx, pole, value))
250
+ return [sorted(m, key=lambda m: -m.pearson)[:k] for m in best]
251
+
252
+
253
+ @dataclass(frozen=True)
254
+ class Sisters:
255
+ """One source pole's best matches in the other model.
256
+
257
+ ``back`` is each match's own best partner back in the source model. ``rotated`` is the
258
+ rotated-basis reference.
259
+ """
260
+
261
+ matches: list[Match]
262
+ back: list[Match] # per match: its best partner among the source model's units
263
+ rotated: float # best Pearson with a unit of a rotated basis of matches[0]'s layer
264
+
265
+
266
+ def find_sisters(
267
+ source: NeuronHookRunner,
268
+ other: NeuronHookRunner,
269
+ units: Sequence[Unit],
270
+ texts: Sequence[str],
271
+ *,
272
+ other_name: str,
273
+ k: int = 5,
274
+ batch_tokens: int = BATCH_TOKENS,
275
+ progress=None,
276
+ ) -> tuple[dict[Unit, Sisters], int]:
277
+ """Return each of ``units`` (in ``source``) with its sisters in ``other``, over ``texts``.
278
+
279
+ Also returns how many token positions were paired. There are two passes over the
280
+ corpus. The first finds each unit's ``k`` best poles over all of ``other``'s layers.
281
+ The second finds each of those poles' best partner over all of ``source``'s layers,
282
+ and the rotated reference.
283
+ """
284
+ units = list(dict.fromkeys(units))
285
+ pair = _Pair(source, other, texts, batch_tokens, progress or (lambda *_: None))
286
+ matches = _forward(pair, units, k)
287
+ back_of, rotated, tokens = _backward(pair, units, matches, other_name)
288
+ out = {}
289
+ for i, unit in enumerate(units):
290
+ back = [back_of[(m.layer, m.neuron_idx, m.pole)] for m in matches[i]]
291
+ out[unit] = Sisters(matches[i], back, rotated.get(i, 0.0))
292
+ return out, tokens
293
+
294
+
295
+ @dataclass(frozen=True)
296
+ class _Pair:
297
+ source: NeuronHookRunner
298
+ other: NeuronHookRunner
299
+ texts: Sequence[str]
300
+ batch_tokens: int
301
+ tick: Callable[[str, int], None]
302
+
303
+ def rows(self, source_layers: Iterable[int], other_layers: Iterable[int]):
304
+ return paired_rows(
305
+ self.source, self.other, self.texts, source_layers, other_layers, self.batch_tokens
306
+ )
307
+
308
+ @staticmethod
309
+ def widths(runner: NeuronHookRunner) -> dict[int, int]:
310
+ return {layer: runner.mlp_width(layer) for layer in range(runner.n_layers)}
311
+
312
+
313
+ def _forward(pair: _Pair, units: Sequence[Unit], k: int) -> list[list[Match]]:
314
+ """Pass 1: each unit's ``k`` best poles over every layer of the other model."""
315
+ every = range(pair.other.n_layers)
316
+ corr = Correlations(len(units))
317
+ for src, oth in pair.rows({layer for layer, _, _ in units}, every):
318
+ x = _source_rows(src, units)
319
+ corr.add_sources(x)
320
+ for layer, acts in oth.items():
321
+ corr.add_targets(layer, x.to(acts.device), pole_columns(acts))
322
+ pair.tick("forward", corr.n)
323
+ return _top(corr, every, _Pair.widths(pair.other), k)
324
+
325
+
326
+ def _backward(pair: _Pair, units: Sequence[Unit], matches: list[list[Match]], name: str):
327
+ """Run pass 2, and return its three results.
328
+
329
+ The first is each match's best pole over every source layer (``{unit: Match}``). The
330
+ second is each unit's rotated reference at its top match's layer (``{unit index: r}``).
331
+ The third is how many positions were paired.
332
+ """
333
+ found = list(dict.fromkeys((m.layer, m.neuron_idx, m.pole) for ms in matches for m in ms))
334
+ by_layer: dict[int, list[int]] = {}
335
+ for i, ms in enumerate(matches):
336
+ if ms:
337
+ by_layer.setdefault(ms[0].layer, []).append(i)
338
+ widths = _Pair.widths(pair.other)
339
+ device = pair.other.device
340
+ turns = {layer: rotation(name, layer, widths[layer]).to(device) for layer in by_layer}
341
+ back = Correlations(len(found))
342
+ rotated = {layer: Correlations(len(idx)) for layer, idx in by_layer.items()}
343
+ every = range(pair.source.n_layers)
344
+ other_layers = {layer for layer, _, _ in found} | set(by_layer)
345
+ for src, oth in pair.rows(every, other_layers):
346
+ x = _source_rows(oth, found)
347
+ back.add_sources(x)
348
+ for layer, acts in src.items():
349
+ back.add_targets(layer, x.to(acts.device), pole_columns(acts))
350
+ own = _source_rows(src, units)
351
+ for layer, idx in by_layer.items():
352
+ xs = own[:, idx]
353
+ rotated[layer].add_sources(xs)
354
+ rotated[layer].add_targets(
355
+ layer, xs.to(device), pole_columns(oth[layer] @ turns[layer])
356
+ )
357
+ pair.tick("backward", back.n)
358
+ best = _top(back, every, _Pair.widths(pair.source), 1)
359
+ reference = {}
360
+ for layer, idx in by_layer.items():
361
+ r = rotated[layer].pearson(layer).amax(dim=1)
362
+ reference.update({i: float(v) for i, v in zip(idx, r.tolist(), strict=True)})
363
+ return {unit: m[0] for unit, m in zip(found, best, strict=True)}, reference, back.n
@@ -0,0 +1,101 @@
1
+ """Where a sparse dictionary reads its input and writes its output in the model.
2
+
3
+ A ``dictionary_interventions`` entry of :meth:`NeuronHookRunner.hooks` is ``(site, fn)``.
4
+ The hooks read the dictionary's input x at ``site`` (see ``DictionaryProfile``). Then
5
+ they call ``fn(x, out)`` with what the dictionary reconstructs, which ``fn`` may replace.
6
+ That is the MLP's write to the residual stream: the down projection's output, or the
7
+ norm after it where there is one. For a residual-stream SAE, it is the residual stream
8
+ after the layer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from contextlib import suppress
14
+
15
+ import torch
16
+ from torch import nn
17
+
18
+ from openinterp.core.schemas import DictionarySite
19
+ from openinterp.harness.hooks import (
20
+ DictionaryIntervention,
21
+ Inputs,
22
+ NeuronHookRunner,
23
+ _StopForward,
24
+ )
25
+
26
+
27
+ def normalize_without_affine(norm: nn.Module, x: torch.Tensor) -> torch.Tensor:
28
+ """Return what ``norm`` makes of ``x`` before its gain and bias, in float32.
29
+
30
+ For LayerNorm, ``x`` is standardised. For RMSNorm, it is divided by its root mean
31
+ square.
32
+ """
33
+ eps = getattr(norm, "eps", None) or getattr(norm, "variance_epsilon", None) or 1e-6
34
+ x = x.float()
35
+ if isinstance(norm, nn.LayerNorm):
36
+ return torch.nn.functional.layer_norm(x, x.shape[-1:], eps=eps)
37
+ return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps)
38
+
39
+
40
+ def site_hooks(
41
+ runner: NeuronHookRunner, layer: int, site: DictionarySite, fn: DictionaryIntervention
42
+ ) -> list:
43
+ """Return the hooks for one ``dictionary_interventions`` entry.
44
+
45
+ They read x at ``site``, then call ``fn(x, out)`` on the MLP's write to the residual
46
+ stream. For ``resid_post``, ``out`` is the residual stream after the layer, which is
47
+ also x.
48
+ """
49
+ if site == "resid_post":
50
+
51
+ def residual(_module, _args, out):
52
+ hidden = out[0] if isinstance(out, tuple) else out
53
+ replaced = fn(hidden, hidden)
54
+ if replaced is None:
55
+ return None
56
+ return (replaced, *out[1:]) if isinstance(out, tuple) else replaced
57
+
58
+ return [runner.layers[layer].register_forward_hook(residual)]
59
+ seen: list[torch.Tensor] = []
60
+ handles = []
61
+ if site in ("resid_mid", "mlp_in", "mlp_in_normalized"):
62
+ norm = runner.mlp_norm(layer) if site == "mlp_in_normalized" else None
63
+
64
+ def record(_module, args):
65
+ seen.append(args[0] if norm is None else normalize_without_affine(norm, args[0]))
66
+
67
+ module = runner.mlp(layer) if site == "mlp_in" else runner.mlp_norm(layer)
68
+ handles.append(module.register_forward_pre_hook(record))
69
+ elif site == "mlp_hidden":
70
+
71
+ def record_hidden(_module, args):
72
+ seen.append(args[0])
73
+
74
+ handles.append(runner.down_proj(layer).register_forward_pre_hook(record_hidden))
75
+ elif site != "mlp_out":
76
+ raise ValueError(f"unknown dictionary site {site!r}")
77
+
78
+ def output(_module, _args, out):
79
+ return fn(out if site == "mlp_out" else seen.pop(), out)
80
+
81
+ handles.append(runner.mlp_write(layer).register_forward_hook(output))
82
+ return handles
83
+
84
+
85
+ @torch.no_grad()
86
+ def dictionary_io(
87
+ runner: NeuronHookRunner, inputs: Inputs, layer: int, site: DictionarySite
88
+ ) -> tuple[torch.Tensor, torch.Tensor]:
89
+ """Return a dictionary's input at ``site`` and the output that it reconstructs.
90
+
91
+ Both are ``[batch, seq, d]`` (see :func:`site_hooks`). The forward pass stops there.
92
+ """
93
+ captured: list[tuple[torch.Tensor, torch.Tensor]] = []
94
+
95
+ def capture(x: torch.Tensor, out: torch.Tensor) -> None:
96
+ captured.append((x.detach().clone(), out.detach().clone()))
97
+ raise _StopForward
98
+
99
+ with suppress(_StopForward):
100
+ runner.run_with_interventions(inputs, dictionary_interventions={layer: (site, capture)})
101
+ return captured[-1]
@@ -0,0 +1,107 @@
1
+ """Measure the WordNet specificity check (:class:`~openinterp.core.specificity.WordnetSpecificity`).
2
+
3
+ Each word goes into every carrier sentence in ``CARRIERS``. Its strength in one carrier
4
+ is the unit's peak over the tokens that overlap the word's characters. Its recorded
5
+ strength is the mean over the carriers. No carrier starts with the word, so a unit with
6
+ a first-token floor still has the word's tokens measured.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ from openinterp import wordnet
14
+ from openinterp.core.schemas import ACTIVATION_THRESHOLD
15
+ from openinterp.core.specificity import WordnetSpecificity, WordStrength
16
+ from openinterp.harness.hooks import NeuronHookRunner
17
+ from openinterp.harness.units import Unit
18
+
19
+ CARRIERS = (
20
+ "Here is a word: {}.",
21
+ "They talked about {} for a while.",
22
+ "The next entry in the list is {}.",
23
+ )
24
+ MAX_WORDS = 40 # words on each side, most frequent first
25
+ MIN_WORDS = 5 # fewer on either side and there is nothing to compare
26
+
27
+
28
+ def word_strength(runner: NeuronHookRunner, unit: Unit, carrier: str, word: str) -> float | None:
29
+ """Return the unit's peak over ``word``'s tokens in ``carrier``.
30
+
31
+ ``None`` when no measured token overlaps the word.
32
+ """
33
+ text = carrier.format(word)
34
+ start = carrier.index("{}")
35
+ end = start + len(word)
36
+ offsets = runner.tokenizer(text, return_offsets_mapping=True)["offset_mapping"]
37
+ strengths = unit.strengths(runner, text)[0]
38
+ if len(offsets) != strengths.numel():
39
+ return None
40
+ first = int(unit.skip_first)
41
+ held = [
42
+ float(strengths[i])
43
+ for i, (a, b) in enumerate(offsets)
44
+ if i >= first and a < end and b > start
45
+ ]
46
+ return max(held) if held else None
47
+
48
+
49
+ def mean_strength(runner: NeuronHookRunner, unit: Unit, word: str) -> float | None:
50
+ """The mean of :func:`word_strength` over ``CARRIERS``, or ``None`` if none measures it."""
51
+ found = [s for c in CARRIERS if (s := word_strength(runner, unit, c, word)) is not None]
52
+ return sum(found) / len(found) if found else None
53
+
54
+
55
+ def measure(runner: NeuronHookRunner, unit: Unit, words: Sequence[str]) -> list[WordStrength]:
56
+ """Each word's strength, rounded; words that no carrier measures are left out."""
57
+ out = []
58
+ for word in words:
59
+ s = mean_strength(runner, unit, word)
60
+ if s is not None:
61
+ out.append(WordStrength(word=word, strength=round(max(0.0, s), 6)))
62
+ return out
63
+
64
+
65
+ def auroc(category: Sequence[float], siblings: Sequence[float]) -> float:
66
+ """The chance that a category value beats a sibling value, ties counting half."""
67
+ wins = sum((a > b) + 0.5 * (a == b) for a in category for b in siblings)
68
+ return wins / (len(category) * len(siblings))
69
+
70
+
71
+ def specificity(
72
+ runner: NeuronHookRunner,
73
+ unit: Unit,
74
+ lex: wordnet.Lexicon,
75
+ synset: str,
76
+ source: str,
77
+ scale: float,
78
+ exclude: Sequence[str] = (),
79
+ ) -> WordnetSpecificity | None:
80
+ """Return the unit on ``synset``'s words against its siblings' words.
81
+
82
+ Words in ``exclude`` (the scan's peak words) are left out of both lists. ``None``
83
+ when either list has fewer than ``MIN_WORDS`` measured words.
84
+ """
85
+ skip = {w.lower() for w in exclude}
86
+ inside = wordnet.holdouts(lex, synset, skip, MAX_WORDS)
87
+ near, outside = wordnet.sibling_words(
88
+ lex, synset, skip | {w.lower() for w in inside}, MAX_WORDS
89
+ )
90
+ category, siblings = measure(runner, unit, inside), measure(runner, unit, outside)
91
+ if len(category) < MIN_WORDS or len(siblings) < MIN_WORDS:
92
+ return None
93
+ line = ACTIVATION_THRESHOLD * scale
94
+ a, b = [w.strength for w in category], [w.strength for w in siblings]
95
+ return WordnetSpecificity(
96
+ synset=synset,
97
+ lemma=lex.lemma(synset),
98
+ source=source,
99
+ sibling_synsets=near,
100
+ carriers=list(CARRIERS),
101
+ scale=scale,
102
+ category=category,
103
+ siblings=siblings,
104
+ category_active=round(sum(x >= line for x in a) / len(a), 6),
105
+ siblings_active=round(sum(x >= line for x in b) / len(b), 6),
106
+ auroc=round(auroc(a, b), 6),
107
+ )