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.
- open_interpretability-0.0.20.dist-info/METADATA +157 -0
- open_interpretability-0.0.20.dist-info/RECORD +256 -0
- open_interpretability-0.0.20.dist-info/WHEEL +4 -0
- open_interpretability-0.0.20.dist-info/entry_points.txt +2 -0
- open_interpretability-0.0.20.dist-info/licenses/LICENSE +21 -0
- openinterp/__init__.py +6 -0
- openinterp/agents/__init__.py +25 -0
- openinterp/agents/base.py +229 -0
- openinterp/agents/judge.py +149 -0
- openinterp/agents/proposer.py +471 -0
- openinterp/agents/proposer_profiles.py +79 -0
- openinterp/agents/review_measurements.py +173 -0
- openinterp/agents/reviewer.py +498 -0
- openinterp/agreement.py +63 -0
- openinterp/audit_options.py +79 -0
- openinterp/baseline.py +254 -0
- openinterp/benchmark.py +450 -0
- openinterp/blends.py +239 -0
- openinterp/builder/__init__.py +6 -0
- openinterp/builder/agents.py +260 -0
- openinterp/builder/aligned.py +41 -0
- openinterp/builder/charts.py +411 -0
- openinterp/builder/circuit_pages.py +37 -0
- openinterp/builder/circuits.py +264 -0
- openinterp/builder/compiler.py +495 -0
- openinterp/builder/corpora.py +98 -0
- openinterp/builder/coverage.py +46 -0
- openinterp/builder/dashboard.py +255 -0
- openinterp/builder/disk_memo.py +65 -0
- openinterp/builder/experiment.py +105 -0
- openinterp/builder/heat.py +121 -0
- openinterp/builder/hrefs.py +71 -0
- openinterp/builder/learned_baseline.py +204 -0
- openinterp/builder/model_facts.py +138 -0
- openinterp/builder/neuronpedia_export.py +107 -0
- openinterp/builder/parallel.py +100 -0
- openinterp/builder/parquet_export.py +458 -0
- openinterp/builder/pieces.py +127 -0
- openinterp/builder/pii.py +211 -0
- openinterp/builder/registry_stats.py +144 -0
- openinterp/builder/research.py +109 -0
- openinterp/builder/sections.py +28 -0
- openinterp/builder/significance.py +103 -0
- openinterp/builder/sister_graph.py +180 -0
- openinterp/builder/standing.py +185 -0
- openinterp/builder/statuses.py +34 -0
- openinterp/builder/stopped.py +91 -0
- openinterp/builder/stops.py +30 -0
- openinterp/builder/superpositions.py +102 -0
- openinterp/builder/templates/_agent_role_cells.html +9 -0
- openinterp/builder/templates/_causal_chart.html +67 -0
- openinterp/builder/templates/_circuit.html +8 -0
- openinterp/builder/templates/_dashboard.html +133 -0
- openinterp/builder/templates/_experiment.html +500 -0
- openinterp/builder/templates/_macros.html +82 -0
- openinterp/builder/templates/_patchscope.html +33 -0
- openinterp/builder/templates/_prior_art.html +24 -0
- openinterp/builder/templates/_specificity.html +21 -0
- openinterp/builder/templates/_steering.html +34 -0
- openinterp/builder/templates/_superposition.html +62 -0
- openinterp/builder/templates/_vocab_chart.html +88 -0
- openinterp/builder/templates/_wiring.html +44 -0
- openinterp/builder/templates/about.html +95 -0
- openinterp/builder/templates/agent.html +69 -0
- openinterp/builder/templates/agents.html +80 -0
- openinterp/builder/templates/base.html +103 -0
- openinterp/builder/templates/circuit.html +64 -0
- openinterp/builder/templates/circuits.html +46 -0
- openinterp/builder/templates/experiment.html +21 -0
- openinterp/builder/templates/hub.html +16 -0
- openinterp/builder/templates/index.html +387 -0
- openinterp/builder/templates/layer.html +69 -0
- openinterp/builder/templates/learned-baseline.html +44 -0
- openinterp/builder/templates/model.html +151 -0
- openinterp/builder/templates/models.html +47 -0
- openinterp/builder/templates/neuron.html +234 -0
- openinterp/builder/templates/paper.html +39 -0
- openinterp/builder/templates/redirect.html +1 -0
- openinterp/builder/templates/research.html +31 -0
- openinterp/builder/templates/user.html +54 -0
- openinterp/builder/templates/users.html +39 -0
- openinterp/builder/templates/wiki/experiment.md +271 -0
- openinterp/builder/templates/wiki/index.md +32 -0
- openinterp/builder/templates/wiki/neuron.md +30 -0
- openinterp/builder/templates/wiki/stop.md +36 -0
- openinterp/builder/tree.py +470 -0
- openinterp/builder/users.py +142 -0
- openinterp/builder/wiki.py +427 -0
- openinterp/checks.py +429 -0
- openinterp/cli.py +494 -0
- openinterp/cli_baseline.py +332 -0
- openinterp/cli_benchmark.py +300 -0
- openinterp/cli_build.py +128 -0
- openinterp/cli_check.py +51 -0
- openinterp/cli_concepts.py +76 -0
- openinterp/cli_corpus.py +252 -0
- openinterp/cli_costs.py +257 -0
- openinterp/cli_coverage.py +76 -0
- openinterp/cli_crossref.py +86 -0
- openinterp/cli_errata.py +142 -0
- openinterp/cli_evidence.py +495 -0
- openinterp/cli_evidence_store.py +93 -0
- openinterp/cli_explain.py +101 -0
- openinterp/cli_loop.py +125 -0
- openinterp/cli_memory.py +28 -0
- openinterp/cli_neuronpedia.py +175 -0
- openinterp/cli_pause.py +45 -0
- openinterp/cli_pole_effects.py +133 -0
- openinterp/cli_probing.py +119 -0
- openinterp/cli_registry.py +348 -0
- openinterp/cli_reproduce.py +446 -0
- openinterp/cli_review.py +302 -0
- openinterp/cli_scan.py +223 -0
- openinterp/cli_score.py +446 -0
- openinterp/cli_sisters.py +86 -0
- openinterp/cli_submission.py +217 -0
- openinterp/cli_superpositions.py +118 -0
- openinterp/cli_wiring.py +54 -0
- openinterp/codeid.py +73 -0
- openinterp/concepts.py +400 -0
- openinterp/core/__init__.py +59 -0
- openinterp/core/agent_names.py +76 -0
- openinterp/core/attribution.py +101 -0
- openinterp/core/cache_dirs.py +15 -0
- openinterp/core/causal.py +82 -0
- openinterp/core/confounds.py +103 -0
- openinterp/core/contributors.py +26 -0
- openinterp/core/corpora.py +126 -0
- openinterp/core/costs.py +430 -0
- openinterp/core/draws.py +109 -0
- openinterp/core/errata.py +244 -0
- openinterp/core/evidence.py +160 -0
- openinterp/core/examples.py +93 -0
- openinterp/core/exceptions.py +29 -0
- openinterp/core/explanation.py +130 -0
- openinterp/core/fence.py +42 -0
- openinterp/core/heavy_lock.py +114 -0
- openinterp/core/identifiers.py +198 -0
- openinterp/core/isolation.py +149 -0
- openinterp/core/ledger.py +130 -0
- openinterp/core/licenses.py +28 -0
- openinterp/core/manifold.py +117 -0
- openinterp/core/partners.py +32 -0
- openinterp/core/patchscope.py +59 -0
- openinterp/core/paths.py +40 -0
- openinterp/core/pause.py +96 -0
- openinterp/core/pricing.py +178 -0
- openinterp/core/prior_art.py +44 -0
- openinterp/core/probing.py +146 -0
- openinterp/core/profiles.py +221 -0
- openinterp/core/project.py +28 -0
- openinterp/core/run_environment.py +38 -0
- openinterp/core/schemas.py +484 -0
- openinterp/core/specificity.py +49 -0
- openinterp/core/stats.py +172 -0
- openinterp/core/steering.py +49 -0
- openinterp/core/strict.py +19 -0
- openinterp/core/takedowns.py +136 -0
- openinterp/core/transcripts.py +130 -0
- openinterp/core/wiring.py +80 -0
- openinterp/corpus.py +476 -0
- openinterp/corpus_cache.py +145 -0
- openinterp/corpus_layouts.py +108 -0
- openinterp/corpus_presets.py +168 -0
- openinterp/corpus_rebuild.py +93 -0
- openinterp/coverage.py +155 -0
- openinterp/crossref.py +442 -0
- openinterp/data/__init__.py +9 -0
- openinterp/data/offensive_words.txt +58 -0
- openinterp/data/public_suffix_list.dat +15771 -0
- openinterp/data/sample_corpus.txt +40 -0
- openinterp/draw_steps.py +359 -0
- openinterp/errata_checks.py +129 -0
- openinterp/evidence.py +462 -0
- openinterp/evidence_branch.py +119 -0
- openinterp/evidence_lenses.py +75 -0
- openinterp/experiment_loop.py +357 -0
- openinterp/explain.py +252 -0
- openinterp/half_precision.py +79 -0
- openinterp/harness/__init__.py +37 -0
- openinterp/harness/ane/__init__.py +9 -0
- openinterp/harness/ane/backend.py +324 -0
- openinterp/harness/ane/cache.py +276 -0
- openinterp/harness/ane/chunks.py +163 -0
- openinterp/harness/ane/convert.py +116 -0
- openinterp/harness/ane/forward.py +258 -0
- openinterp/harness/ane/qualified.py +40 -0
- openinterp/harness/annotations.py +329 -0
- openinterp/harness/attribution.py +96 -0
- openinterp/harness/causal.py +264 -0
- openinterp/harness/confounds.py +184 -0
- openinterp/harness/covariance.py +301 -0
- openinterp/harness/crossref.py +195 -0
- openinterp/harness/cuda_devices.py +88 -0
- openinterp/harness/dead.py +124 -0
- openinterp/harness/devices.py +401 -0
- openinterp/harness/dictionaries.py +496 -0
- openinterp/harness/dreaming.py +116 -0
- openinterp/harness/environment.py +64 -0
- openinterp/harness/explanation_scores.py +371 -0
- openinterp/harness/features.py +276 -0
- openinterp/harness/first_position.py +137 -0
- openinterp/harness/heads.py +226 -0
- openinterp/harness/hooks.py +499 -0
- openinterp/harness/input_attribution.py +367 -0
- openinterp/harness/jlens.py +183 -0
- openinterp/harness/layer_cache.py +301 -0
- openinterp/harness/layer_major.py +274 -0
- openinterp/harness/lookahead.py +93 -0
- openinterp/harness/manifold.py +200 -0
- openinterp/harness/memory.py +471 -0
- openinterp/harness/moe.py +134 -0
- openinterp/harness/patcher.py +322 -0
- openinterp/harness/patchscope.py +177 -0
- openinterp/harness/probing.py +383 -0
- openinterp/harness/scan.py +462 -0
- openinterp/harness/scan_backend.py +201 -0
- openinterp/harness/sister_passes.py +463 -0
- openinterp/harness/sisters.py +363 -0
- openinterp/harness/sites.py +101 -0
- openinterp/harness/specificity.py +107 -0
- openinterp/harness/steering.py +159 -0
- openinterp/harness/superpositions.py +116 -0
- openinterp/harness/units.py +203 -0
- openinterp/harness/wiring.py +432 -0
- openinterp/harness/wiring_activations.py +285 -0
- openinterp/langid.py +73 -0
- openinterp/names.py +129 -0
- openinterp/neuronpedia_baseline.py +88 -0
- openinterp/neuronpedia_review.py +148 -0
- openinterp/planner.py +466 -0
- openinterp/planner_effects.py +89 -0
- openinterp/planner_heads.py +92 -0
- openinterp/planner_models.py +174 -0
- openinterp/planner_sisters.py +122 -0
- openinterp/planner_states.py +57 -0
- openinterp/planner_superpositions.py +114 -0
- openinterp/pole_effects.py +102 -0
- openinterp/prior_art.py +143 -0
- openinterp/reproduce.py +450 -0
- openinterp/revision.py +45 -0
- openinterp/runner_cache.py +35 -0
- openinterp/score_controls.py +169 -0
- openinterp/sensitive.py +243 -0
- openinterp/show.py +236 -0
- openinterp/signing.py +209 -0
- openinterp/sisters.py +476 -0
- openinterp/superposition_finders.py +225 -0
- openinterp/sync/__init__.py +5 -0
- openinterp/sync/r2.py +280 -0
- openinterp/syntax.py +399 -0
- openinterp/takedown_checks.py +76 -0
- openinterp/toxicity.py +158 -0
- openinterp/verdict_regression.py +79 -0
- openinterp/wiring_backfill.py +170 -0
- 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
|
+
)
|