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,301 @@
|
|
|
1
|
+
"""On-disk cache of a scan's whole-layer activations.
|
|
2
|
+
|
|
3
|
+
One forward pass over the corpus captures every neuron of a layer, so a scan stores
|
|
4
|
+
all of them. Later audits of any neuron in that layer, and per-token analysis
|
|
5
|
+
(``open-interpretability activations``), replay the stored tokens instead of re-running the model.
|
|
6
|
+
|
|
7
|
+
An entry is keyed by everything that sets its numbers. That is the model, its revision,
|
|
8
|
+
the weights' dtype and the device type (``coreml-ane`` for a scan on the ANE). It is also
|
|
9
|
+
the torch and transformers versions and the layer. Last, it is the token budget per
|
|
10
|
+
batch (which sets padding), and the exact pieces scanned (texts and windows). Replaying
|
|
11
|
+
an entry gives the same scan result as running the model, bit for bit.
|
|
12
|
+
|
|
13
|
+
Each entry is a directory ``<root>/<model>/L<layer>/<key>/``. It holds ``meta.json``,
|
|
14
|
+
``pieces.json`` and ``acts.bin`` (``[rows, width]`` in the activation dtype). It also
|
|
15
|
+
holds the per-row ``ids.npy``, ``positions.npy``, ``piece.npy`` and ``counted.npy``.
|
|
16
|
+
The rows are every real (non-padding) token of every piece, in scan order. ``meta.json``
|
|
17
|
+
lists how many pieces each forward pass took. Entries are written to a temporary
|
|
18
|
+
directory and renamed into place, so concurrent scans never see a partial one. Once the
|
|
19
|
+
cache outgrows its size cap, the least recently used entries go.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import secrets
|
|
28
|
+
import shutil
|
|
29
|
+
from collections.abc import Iterator, Sequence
|
|
30
|
+
from contextlib import suppress
|
|
31
|
+
from dataclasses import asdict, dataclass
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
import torch
|
|
37
|
+
|
|
38
|
+
from openinterp.core.cache_dirs import cache_root
|
|
39
|
+
from openinterp.harness.hooks import NeuronHookRunner
|
|
40
|
+
from openinterp.harness.scan import Chunk, Piece
|
|
41
|
+
from openinterp.harness.scan_backend import scan_device
|
|
42
|
+
|
|
43
|
+
FORMAT = 2 # 2: batches by token budget, recorded in meta.json
|
|
44
|
+
ENV = "OPENINTERP_SCAN_CACHE" # cache directory, or "off"
|
|
45
|
+
SIZE_ENV = "OPENINTERP_SCAN_CACHE_GB"
|
|
46
|
+
DEFAULT_GB = 40.0
|
|
47
|
+
|
|
48
|
+
# numpy has no bfloat16: store its bits as int16 and view them back.
|
|
49
|
+
_BITS = {torch.bfloat16: torch.int16}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_root() -> Path | None:
|
|
53
|
+
"""``$OPENINTERP_SCAN_CACHE``, else ``$XDG_CACHE_HOME/openinterp/scans``; None if off."""
|
|
54
|
+
return cache_root(ENV, "scans")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Entry:
|
|
59
|
+
"""One cached layer scan."""
|
|
60
|
+
|
|
61
|
+
path: Path
|
|
62
|
+
meta: dict
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def size(self) -> int:
|
|
66
|
+
"""The entry's size on disk, in bytes."""
|
|
67
|
+
return sum(f.stat().st_size for f in self.path.iterdir())
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def last_used(self) -> float:
|
|
71
|
+
"""When the entry was last read or written, as a timestamp."""
|
|
72
|
+
return (self.path / "meta.json").stat().st_mtime
|
|
73
|
+
|
|
74
|
+
def pieces(self) -> list[Piece]:
|
|
75
|
+
"""The pieces that the entry scanned, in scan order."""
|
|
76
|
+
return [Piece(**p) for p in json.loads((self.path / "pieces.json").read_text())]
|
|
77
|
+
|
|
78
|
+
def rows(self, name: str) -> np.ndarray:
|
|
79
|
+
"""A per-row array: ``ids``, ``positions``, ``piece`` or ``counted``."""
|
|
80
|
+
return np.load(self.path / f"{name}.npy", mmap_mode="r")
|
|
81
|
+
|
|
82
|
+
def acts(self) -> np.memmap:
|
|
83
|
+
"""The whole layer's activations, ``[rows, width]``, memory-mapped."""
|
|
84
|
+
shape = (self.meta["rows"], self.meta["width"])
|
|
85
|
+
return np.memmap(self.path / "acts.bin", dtype=self.meta["storage"], mode="r", shape=shape)
|
|
86
|
+
|
|
87
|
+
def column(self, neuron_idx: int) -> torch.Tensor:
|
|
88
|
+
"""One neuron's activation on every row, as float32 (as the scan reads it)."""
|
|
89
|
+
return self._tensor(np.ascontiguousarray(self.acts()[:, neuron_idx])).float()
|
|
90
|
+
|
|
91
|
+
def _tensor(self, array: np.ndarray) -> torch.Tensor:
|
|
92
|
+
tensor = torch.from_numpy(np.array(array))
|
|
93
|
+
dtype = getattr(torch, self.meta["dtype"])
|
|
94
|
+
return tensor.view(dtype) if dtype in _BITS else tensor
|
|
95
|
+
|
|
96
|
+
def chunks(self) -> Iterator[Chunk]:
|
|
97
|
+
"""The scan's batches again, as the model produced them."""
|
|
98
|
+
pieces = self.pieces()
|
|
99
|
+
acts = self.acts()
|
|
100
|
+
ids, positions = self.rows("ids"), self.rows("positions")
|
|
101
|
+
counted, piece = self.rows("counted"), self.rows("piece")
|
|
102
|
+
lengths = np.bincount(piece, minlength=len(pieces)).tolist()
|
|
103
|
+
start = row = 0
|
|
104
|
+
for size in self.meta["batches"]:
|
|
105
|
+
batch_lengths = lengths[start : start + size]
|
|
106
|
+
end = row + sum(batch_lengths)
|
|
107
|
+
yield Chunk(
|
|
108
|
+
pieces=pieces[start : start + size],
|
|
109
|
+
lengths=batch_lengths,
|
|
110
|
+
ids=torch.from_numpy(np.array(ids[row:end])).long(),
|
|
111
|
+
positions=torch.from_numpy(np.array(positions[row:end])).long(),
|
|
112
|
+
counted=torch.from_numpy(np.array(counted[row:end])),
|
|
113
|
+
acts=self._tensor(acts[row:end]),
|
|
114
|
+
)
|
|
115
|
+
start, row = start + size, end
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class LayerCache:
|
|
119
|
+
"""The cache of whole-layer scans under ``root``, capped at ``max_bytes``."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, root: Path, max_bytes: int | None = None) -> None:
|
|
122
|
+
self.root = Path(root)
|
|
123
|
+
if max_bytes is None:
|
|
124
|
+
max_bytes = int(float(os.environ.get(SIZE_ENV, DEFAULT_GB)) * 1e9)
|
|
125
|
+
self.max_bytes = max_bytes
|
|
126
|
+
self.last: Path | None = None # the entry the last scan replayed or stored
|
|
127
|
+
self.replayed = False
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def default(cls) -> LayerCache | None:
|
|
131
|
+
"""The cache at the default location, or ``None`` when caching is off."""
|
|
132
|
+
root = default_root()
|
|
133
|
+
return None if root is None else cls(root)
|
|
134
|
+
|
|
135
|
+
# -- keys ----------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def fields(
|
|
139
|
+
runner: NeuronHookRunner, layer: int, pieces: Sequence[Piece], batch_tokens: int
|
|
140
|
+
) -> dict | None:
|
|
141
|
+
"""What an entry is keyed by; None for a model with no name to key it by."""
|
|
142
|
+
config = getattr(runner.model, "config", None)
|
|
143
|
+
model = getattr(runner, "model_name", None) or getattr(config, "_name_or_path", None)
|
|
144
|
+
if not model:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
import transformers
|
|
148
|
+
|
|
149
|
+
transformers_version = transformers.__version__
|
|
150
|
+
except ImportError:
|
|
151
|
+
transformers_version = None
|
|
152
|
+
digest = hashlib.sha256()
|
|
153
|
+
for piece in pieces:
|
|
154
|
+
digest.update(json.dumps(asdict(piece), ensure_ascii=False).encode() + b"\n")
|
|
155
|
+
return {
|
|
156
|
+
"format": FORMAT,
|
|
157
|
+
"model": model,
|
|
158
|
+
"revision": getattr(runner, "model_revision", None),
|
|
159
|
+
"dtype": str(next(runner.model.parameters()).dtype).removeprefix("torch."),
|
|
160
|
+
"device": scan_device(
|
|
161
|
+
runner, pieces, layer, batch_tokens
|
|
162
|
+
), # the ANE's activations differ from torch's
|
|
163
|
+
"torch": torch.__version__,
|
|
164
|
+
"transformers": transformers_version,
|
|
165
|
+
"layer": layer,
|
|
166
|
+
"batch_tokens": batch_tokens,
|
|
167
|
+
"pieces_sha256": digest.hexdigest(),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def path(self, fields: dict) -> Path:
|
|
171
|
+
"""The entry directory for ``fields``."""
|
|
172
|
+
key = hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest()[:20]
|
|
173
|
+
return self.root / fields["model"].replace("/", "--") / f"L{fields['layer']}" / key
|
|
174
|
+
|
|
175
|
+
# -- reading -------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def lookup(
|
|
178
|
+
self, runner: NeuronHookRunner, layer: int, pieces: Sequence[Piece], batch_tokens: int
|
|
179
|
+
) -> Entry | None:
|
|
180
|
+
"""The entry for this scan, or ``None`` when there is none."""
|
|
181
|
+
fields = self.fields(runner, layer, pieces, batch_tokens)
|
|
182
|
+
if fields is None:
|
|
183
|
+
return None
|
|
184
|
+
path = self.path(fields)
|
|
185
|
+
try:
|
|
186
|
+
meta = json.loads((path / "meta.json").read_text())
|
|
187
|
+
except (OSError, ValueError):
|
|
188
|
+
return None
|
|
189
|
+
os.utime(path / "meta.json") # least recently used goes first
|
|
190
|
+
return Entry(path, meta)
|
|
191
|
+
|
|
192
|
+
def entries(self) -> list[Entry]:
|
|
193
|
+
"""Every complete entry in the cache."""
|
|
194
|
+
found = []
|
|
195
|
+
for meta in self.root.glob("*/L*/*/meta.json"):
|
|
196
|
+
if meta.parent.name.startswith("."):
|
|
197
|
+
continue # still being written
|
|
198
|
+
try:
|
|
199
|
+
found.append(Entry(meta.parent, json.loads(meta.read_text())))
|
|
200
|
+
except (OSError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
return sorted(found, key=lambda e: e.last_used)
|
|
203
|
+
|
|
204
|
+
def cached_layers(self, model: str) -> set[int]:
|
|
205
|
+
"""The layers of ``model`` that the cache holds."""
|
|
206
|
+
entries = self.entries()
|
|
207
|
+
return {e.meta["fields"]["layer"] for e in entries if e.meta["fields"]["model"] == model}
|
|
208
|
+
|
|
209
|
+
# -- writing -------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def through(
|
|
212
|
+
self,
|
|
213
|
+
runner: NeuronHookRunner,
|
|
214
|
+
layer: int,
|
|
215
|
+
pieces: Sequence[Piece],
|
|
216
|
+
batch_tokens: int,
|
|
217
|
+
chunks: Iterator[Chunk],
|
|
218
|
+
) -> Iterator[Chunk]:
|
|
219
|
+
"""The cached chunks if this scan is cached, else ``chunks``, stored as they pass."""
|
|
220
|
+
entry = self.lookup(runner, layer, pieces, batch_tokens)
|
|
221
|
+
self.replayed, self.last = entry is not None, entry and entry.path
|
|
222
|
+
if entry is not None:
|
|
223
|
+
return entry.chunks()
|
|
224
|
+
fields = self.fields(runner, layer, pieces, batch_tokens)
|
|
225
|
+
if fields is None:
|
|
226
|
+
return chunks
|
|
227
|
+
self.last = self.path(fields)
|
|
228
|
+
return self._record(fields, pieces, chunks)
|
|
229
|
+
|
|
230
|
+
def _record(self, fields: dict, pieces: Sequence[Piece], chunks: Iterator[Chunk]):
|
|
231
|
+
final = self.path(fields)
|
|
232
|
+
tmp = final.with_name(f".{final.name}.{os.getpid()}.{secrets.token_hex(4)}")
|
|
233
|
+
tmp.mkdir(parents=True)
|
|
234
|
+
rows: dict[str, list[np.ndarray]] = {"ids": [], "positions": [], "piece": [], "counted": []}
|
|
235
|
+
sizes: list[int] = []
|
|
236
|
+
index = 0
|
|
237
|
+
width = storage = None
|
|
238
|
+
try:
|
|
239
|
+
with open(tmp / "acts.bin", "wb") as fh:
|
|
240
|
+
for chunk in chunks:
|
|
241
|
+
acts = chunk.acts.contiguous()
|
|
242
|
+
bits = acts.view(_BITS[acts.dtype]) if acts.dtype in _BITS else acts
|
|
243
|
+
fh.write(bits.numpy().tobytes())
|
|
244
|
+
width, storage = acts.shape[1], str(bits.numpy().dtype)
|
|
245
|
+
rows["ids"].append(chunk.ids.numpy().astype(np.int32))
|
|
246
|
+
rows["positions"].append(chunk.positions.numpy().astype(np.int32))
|
|
247
|
+
rows["counted"].append(chunk.counted.numpy())
|
|
248
|
+
sizes.append(len(chunk.pieces))
|
|
249
|
+
for n in chunk.lengths:
|
|
250
|
+
rows["piece"].append(np.full(n, index, dtype=np.int32))
|
|
251
|
+
index += 1
|
|
252
|
+
yield chunk
|
|
253
|
+
_write_index(tmp, fields, pieces, rows, width, storage, sizes)
|
|
254
|
+
with suppress(OSError): # another scan stored it first
|
|
255
|
+
tmp.rename(final)
|
|
256
|
+
self.evict(keep=final)
|
|
257
|
+
finally:
|
|
258
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
259
|
+
|
|
260
|
+
def evict(self, keep: Path | None = None) -> list[Entry]:
|
|
261
|
+
"""Delete least recently used entries until the cache fits its cap."""
|
|
262
|
+
entries = self.entries()
|
|
263
|
+
total = sum(e.size for e in entries)
|
|
264
|
+
removed = []
|
|
265
|
+
for entry in entries:
|
|
266
|
+
if total <= self.max_bytes:
|
|
267
|
+
break
|
|
268
|
+
if entry.path == keep:
|
|
269
|
+
continue
|
|
270
|
+
total -= entry.size
|
|
271
|
+
shutil.rmtree(entry.path, ignore_errors=True)
|
|
272
|
+
removed.append(entry)
|
|
273
|
+
return removed
|
|
274
|
+
|
|
275
|
+
def clear(self) -> int:
|
|
276
|
+
"""Delete every entry; returns how many there were."""
|
|
277
|
+
entries = self.entries()
|
|
278
|
+
for entry in entries:
|
|
279
|
+
shutil.rmtree(entry.path, ignore_errors=True)
|
|
280
|
+
return len(entries)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _write_index(tmp: Path, fields: dict, pieces, rows: dict, width, storage, sizes) -> None:
|
|
284
|
+
"""Write everything that an entry holds besides ``acts.bin``.
|
|
285
|
+
|
|
286
|
+
That is the per-row arrays, the pieces, and ``meta.json``.
|
|
287
|
+
"""
|
|
288
|
+
for name, parts in rows.items():
|
|
289
|
+
np.save(tmp / f"{name}.npy", np.concatenate(parts) if parts else np.zeros(0))
|
|
290
|
+
(tmp / "pieces.json").write_text(json.dumps([asdict(p) for p in pieces]))
|
|
291
|
+
total = int(sum(len(p) for p in rows["ids"]))
|
|
292
|
+
meta = {
|
|
293
|
+
"fields": fields,
|
|
294
|
+
"rows": total,
|
|
295
|
+
"width": width or 0,
|
|
296
|
+
"dtype": fields["dtype"],
|
|
297
|
+
"storage": storage or "float32",
|
|
298
|
+
"batches": sizes,
|
|
299
|
+
"created": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
300
|
+
}
|
|
301
|
+
(tmp / "meta.json").write_text(json.dumps(meta, indent=2))
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Run independent forward passes layer by layer over a streamed model.
|
|
2
|
+
|
|
3
|
+
A streamed model (``--memory stream``, :mod:`openinterp.harness.memory`) reads each
|
|
4
|
+
decoder layer from disk on every pass. The review makes hundreds of passes, one after
|
|
5
|
+
another, so it reads every layer hundreds of times. :func:`map_layer_major` runs the
|
|
6
|
+
items of a loop at once, each in its own thread. It runs each layer once for all the
|
|
7
|
+
passes that wait for it:
|
|
8
|
+
|
|
9
|
+
1. Each thread runs the model's own forward, so each pass computes what it computes
|
|
10
|
+
alone. Only the order of the passes changes.
|
|
11
|
+
2. Every thread waits at a layer or has finished. Then the scheduler loads the next
|
|
12
|
+
layer that a thread waits at, in the order of the layers, and lets those threads run
|
|
13
|
+
it. It then frees the layer and loads the next one. So the layers are read in sweeps
|
|
14
|
+
from the first to the last, and a sweep serves every waiting pass.
|
|
15
|
+
3. While the threads run, a hook is applied only in the thread that registered it.
|
|
16
|
+
The harness's hooks stay where they are, on modules that every thread shares.
|
|
17
|
+
|
|
18
|
+
A model that isn't streamed runs the loop as before, one item after another.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from typing import Any, TypeVar
|
|
27
|
+
|
|
28
|
+
import torch
|
|
29
|
+
from torch import nn
|
|
30
|
+
|
|
31
|
+
from openinterp.harness.memory import is_streamed, shared_by, stream_rows
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
R = TypeVar("R")
|
|
35
|
+
|
|
36
|
+
# The most passes that run at once. Each holds its own activations.
|
|
37
|
+
MAX_WORKERS = 32
|
|
38
|
+
|
|
39
|
+
# The prompt length that :func:`workers_for` plans each pass's memory with.
|
|
40
|
+
PLANNED_TOKENS = 256
|
|
41
|
+
|
|
42
|
+
_active = False # a layer-major loop is running: a loop inside it runs in order
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LayerScheduler:
|
|
46
|
+
"""Lets threads through the streamed layers in sweeps, one loaded layer at a time."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, layers: Sequence[nn.Module]) -> None:
|
|
49
|
+
self.layers = list(layers)
|
|
50
|
+
self.streamed = [
|
|
51
|
+
[m for m in layer.modules() if getattr(getattr(m, "_hf_hook", None), "offload", False)]
|
|
52
|
+
for layer in self.layers
|
|
53
|
+
]
|
|
54
|
+
self.cond = threading.Condition()
|
|
55
|
+
self.running = 0 # threads outside the scheduler's waits
|
|
56
|
+
self.inside = 0 # threads inside the loaded layer
|
|
57
|
+
self.waiting: dict[int, int] = {} # thread id -> the layer it waits at
|
|
58
|
+
self.loaded: int | None = None
|
|
59
|
+
self.loads = 0 # layers read from disk, for tests and reports
|
|
60
|
+
|
|
61
|
+
def start(self) -> None:
|
|
62
|
+
"""A worker thread starts."""
|
|
63
|
+
with self.cond:
|
|
64
|
+
self.running += 1
|
|
65
|
+
|
|
66
|
+
def finish(self) -> None:
|
|
67
|
+
"""A worker thread ends, normally or with an exception."""
|
|
68
|
+
with self.cond:
|
|
69
|
+
self.running -= 1
|
|
70
|
+
self._advance_if_idle()
|
|
71
|
+
self.cond.notify_all()
|
|
72
|
+
|
|
73
|
+
def enter(self, layer: int) -> None:
|
|
74
|
+
"""Block until ``layer`` is loaded for this thread's pass."""
|
|
75
|
+
me = threading.get_ident()
|
|
76
|
+
with self.cond:
|
|
77
|
+
self.running -= 1
|
|
78
|
+
self.waiting[me] = layer
|
|
79
|
+
self._advance_if_idle()
|
|
80
|
+
self.cond.notify_all()
|
|
81
|
+
while self.loaded != layer or me not in self.waiting:
|
|
82
|
+
self.cond.wait()
|
|
83
|
+
del self.waiting[me]
|
|
84
|
+
self.running += 1
|
|
85
|
+
self.inside += 1
|
|
86
|
+
|
|
87
|
+
def leave(self, layer: int) -> None:
|
|
88
|
+
"""This thread's pass has run ``layer``, or stopped in it."""
|
|
89
|
+
with self.cond:
|
|
90
|
+
self.inside -= 1
|
|
91
|
+
|
|
92
|
+
def _advance_if_idle(self) -> None:
|
|
93
|
+
# Called with the lock held. Only when no thread can still reach the loaded layer.
|
|
94
|
+
if self.running or self.inside or not self.waiting:
|
|
95
|
+
return
|
|
96
|
+
targets = set(self.waiting.values())
|
|
97
|
+
if self.loaded in targets:
|
|
98
|
+
return # its waiters are being let through
|
|
99
|
+
after = [layer for layer in targets if self.loaded is None or layer > self.loaded]
|
|
100
|
+
self._load(min(after) if after else min(targets))
|
|
101
|
+
|
|
102
|
+
def _load(self, layer: int) -> None:
|
|
103
|
+
if self.loaded is not None:
|
|
104
|
+
self._release(self.loaded)
|
|
105
|
+
for module in self.streamed[layer]:
|
|
106
|
+
module._hf_hook.pre_forward(module) # reads the weights, as each pass did
|
|
107
|
+
module._hf_hook.offload = False # so a pass doesn't read or free them again
|
|
108
|
+
self.loaded = layer
|
|
109
|
+
self.loads += 1
|
|
110
|
+
|
|
111
|
+
def _release(self, layer: int) -> None:
|
|
112
|
+
for module in self.streamed[layer]:
|
|
113
|
+
module._hf_hook.offload = True
|
|
114
|
+
module._hf_hook.post_forward(module, None) # frees the weights
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
"""Free the layer that is still loaded."""
|
|
118
|
+
if self.loaded is not None:
|
|
119
|
+
self._release(self.loaded)
|
|
120
|
+
self.loaded = None
|
|
121
|
+
|
|
122
|
+
@contextmanager
|
|
123
|
+
def installed(self) -> Iterator[None]:
|
|
124
|
+
"""Route every pass through :meth:`enter` and :meth:`leave` at each layer."""
|
|
125
|
+
originals = [layer.forward for layer in self.layers]
|
|
126
|
+
|
|
127
|
+
def through(index: int, forward: Callable) -> Callable:
|
|
128
|
+
def run(*args, **kwargs):
|
|
129
|
+
self.enter(index)
|
|
130
|
+
try:
|
|
131
|
+
return forward(*args, **kwargs)
|
|
132
|
+
finally:
|
|
133
|
+
self.leave(index)
|
|
134
|
+
|
|
135
|
+
return run
|
|
136
|
+
|
|
137
|
+
for index, (layer, forward) in enumerate(zip(self.layers, originals, strict=True)):
|
|
138
|
+
layer.forward = through(index, forward)
|
|
139
|
+
try:
|
|
140
|
+
with _hooks_per_thread():
|
|
141
|
+
yield
|
|
142
|
+
finally:
|
|
143
|
+
for layer, forward in zip(self.layers, originals, strict=True):
|
|
144
|
+
layer.forward = forward
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def _hooks_per_thread() -> Iterator[None]:
|
|
150
|
+
"""While it is open, a forward hook registered in a thread is applied only in it."""
|
|
151
|
+
register_hook, register_pre_hook = (
|
|
152
|
+
nn.Module.register_forward_hook,
|
|
153
|
+
nn.Module.register_forward_pre_hook,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def owned(fn: Callable) -> Callable:
|
|
157
|
+
owner = threading.get_ident()
|
|
158
|
+
|
|
159
|
+
def hook(*args, **kwargs):
|
|
160
|
+
return fn(*args, **kwargs) if threading.get_ident() == owner else None
|
|
161
|
+
|
|
162
|
+
return hook
|
|
163
|
+
|
|
164
|
+
def forward_hook(self, hook, *args, **kwargs):
|
|
165
|
+
return register_hook(self, owned(hook), *args, **kwargs)
|
|
166
|
+
|
|
167
|
+
def forward_pre_hook(self, hook, *args, **kwargs):
|
|
168
|
+
return register_pre_hook(self, owned(hook), *args, **kwargs)
|
|
169
|
+
|
|
170
|
+
nn.Module.register_forward_hook = forward_hook
|
|
171
|
+
nn.Module.register_forward_pre_hook = forward_pre_hook
|
|
172
|
+
try:
|
|
173
|
+
yield
|
|
174
|
+
finally:
|
|
175
|
+
nn.Module.register_forward_hook = register_hook
|
|
176
|
+
nn.Module.register_forward_pre_hook = register_pre_hook
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@contextmanager
|
|
180
|
+
def _locked(obj: Any, name: str) -> Iterator[None]:
|
|
181
|
+
"""Let one thread at a time call ``obj.name``: a fast tokenizer isn't thread-safe."""
|
|
182
|
+
own = vars(obj).get(name) # an attribute set on the instance, to put back after
|
|
183
|
+
method, lock = getattr(obj, name), threading.Lock()
|
|
184
|
+
|
|
185
|
+
def call(*args, **kwargs):
|
|
186
|
+
with lock:
|
|
187
|
+
return method(*args, **kwargs)
|
|
188
|
+
|
|
189
|
+
setattr(obj, name, call)
|
|
190
|
+
try:
|
|
191
|
+
yield
|
|
192
|
+
finally:
|
|
193
|
+
if own is None:
|
|
194
|
+
delattr(obj, name)
|
|
195
|
+
else:
|
|
196
|
+
setattr(obj, name, own)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def workers_for(runner, n_items: int, tokens: int, logits: bool) -> int:
|
|
200
|
+
"""How many passes run at once: as many as a quarter of the planned memory holds.
|
|
201
|
+
|
|
202
|
+
Each pass is planned at ``tokens`` tokens, with its logits if ``logits``.
|
|
203
|
+
"""
|
|
204
|
+
rows = stream_rows(runner, tokens, 1, logits=logits)
|
|
205
|
+
return max(1, min(n_items, MAX_WORKERS, rows))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def prompt_tokens(runner, texts: Sequence[str]) -> int:
|
|
209
|
+
"""The token count of the longest of ``texts``: what a pass over one of them holds."""
|
|
210
|
+
return max((runner.encode([t])["input_ids"].shape[1] for t in texts), default=1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def map_layer_major(
|
|
214
|
+
runner,
|
|
215
|
+
fn: Callable[[T], R],
|
|
216
|
+
items: Sequence[T],
|
|
217
|
+
*,
|
|
218
|
+
tokens: int = PLANNED_TOKENS,
|
|
219
|
+
logits: bool = True,
|
|
220
|
+
) -> list[R]:
|
|
221
|
+
"""``[fn(item) for item in items]``, run layer-major when ``runner``'s model streams.
|
|
222
|
+
|
|
223
|
+
``tokens`` and ``logits`` describe the largest pass an item makes, so that the
|
|
224
|
+
passes that run at once fit in the planned memory (:func:`workers_for`). ``fn`` must
|
|
225
|
+
not change state that another item reads. The results are in the order of
|
|
226
|
+
``items``. The first exception raised by an item is raised again.
|
|
227
|
+
"""
|
|
228
|
+
global _active
|
|
229
|
+
items = list(items)
|
|
230
|
+
if len(items) < 2 or _active or not is_streamed(runner.model):
|
|
231
|
+
return [fn(item) for item in items] # a loop inside a running one runs in order
|
|
232
|
+
n_workers = workers_for(runner, len(items), tokens, logits)
|
|
233
|
+
scheduler = LayerScheduler(runner.layers)
|
|
234
|
+
_active = True
|
|
235
|
+
try:
|
|
236
|
+
with scheduler.installed(), _locked(runner, "encode"), shared_by(n_workers):
|
|
237
|
+
results = _run_workers(scheduler, fn, items, n_workers)
|
|
238
|
+
finally:
|
|
239
|
+
_active = False
|
|
240
|
+
runner.layer_loads = getattr(runner, "layer_loads", 0) + scheduler.loads
|
|
241
|
+
return results
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_workers(scheduler: LayerScheduler, fn: Callable, items: list, n_workers: int) -> list:
|
|
245
|
+
"""Run ``fn`` over ``items`` in ``n_workers`` threads; raise the first exception again."""
|
|
246
|
+
results: list[Any] = [None] * len(items)
|
|
247
|
+
errors: list[BaseException] = []
|
|
248
|
+
queue = iter(range(len(items)))
|
|
249
|
+
lock = threading.Lock()
|
|
250
|
+
grad = torch.is_grad_enabled() # grad mode is per thread: carry the caller's over
|
|
251
|
+
|
|
252
|
+
def work() -> None:
|
|
253
|
+
torch.set_grad_enabled(grad)
|
|
254
|
+
try:
|
|
255
|
+
while not errors:
|
|
256
|
+
with lock:
|
|
257
|
+
index = next(queue, None)
|
|
258
|
+
if index is None:
|
|
259
|
+
return
|
|
260
|
+
results[index] = fn(items[index])
|
|
261
|
+
except BaseException as exc: # raised again in the calling thread
|
|
262
|
+
errors.append(exc)
|
|
263
|
+
finally:
|
|
264
|
+
scheduler.finish()
|
|
265
|
+
|
|
266
|
+
threads = [threading.Thread(target=work) for _ in range(n_workers)]
|
|
267
|
+
for thread in threads:
|
|
268
|
+
scheduler.start()
|
|
269
|
+
thread.start()
|
|
270
|
+
for thread in threads:
|
|
271
|
+
thread.join()
|
|
272
|
+
if errors:
|
|
273
|
+
raise errors[0]
|
|
274
|
+
return results
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Distinguish retrospective neurons from lookahead (next-token preparing) neurons.
|
|
2
|
+
|
|
3
|
+
Two complementary measurements on native weights:
|
|
4
|
+
|
|
5
|
+
* :func:`output_token_projection` reads the neuron's output direction through the
|
|
6
|
+
unembedding, to list the tokens that the pole directly promotes. This is a logit-lens
|
|
7
|
+
approximation that skips the final norm.
|
|
8
|
+
* :func:`measure_lookahead` zero-ablates the pole on real prompts and compares the
|
|
9
|
+
next-token distributions. A lookahead neuron's ablation moves probability mass
|
|
10
|
+
in a concentrated way onto/off a small set of upcoming tokens. :func:`ablation_profile`
|
|
11
|
+
does the same for any unit's ablation (an attention head is mean-ablated).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
import torch
|
|
19
|
+
import torch.nn.functional as F
|
|
20
|
+
|
|
21
|
+
from openinterp.core.schemas import LookaheadProfile, Pole
|
|
22
|
+
from openinterp.harness.hooks import Inputs, Intervention, NeuronHookRunner, ablate_pole
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def output_token_projection(
|
|
26
|
+
runner: NeuronHookRunner, layer: int, neuron_idx: int, pole: Pole, k: int = 10
|
|
27
|
+
) -> list[tuple[int, float]]:
|
|
28
|
+
"""Top-``k`` ``(token_id, score)`` pairs promoted by the pole through the direct path."""
|
|
29
|
+
unembed = runner.model.get_output_embeddings().weight.detach() # [vocab, d_model]
|
|
30
|
+
direction = runner.neuron_output_direction(layer, neuron_idx).to(unembed.dtype)
|
|
31
|
+
scores = unembed @ direction
|
|
32
|
+
if pole == "negative":
|
|
33
|
+
scores = -scores
|
|
34
|
+
top = scores.topk(min(k, scores.numel()))
|
|
35
|
+
return list(zip(top.indices.tolist(), top.values.tolist(), strict=True))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def measure_lookahead(
|
|
39
|
+
runner: NeuronHookRunner,
|
|
40
|
+
prompts: Inputs,
|
|
41
|
+
layer: int,
|
|
42
|
+
neuron_idx: int,
|
|
43
|
+
pole: Pole,
|
|
44
|
+
**kwargs,
|
|
45
|
+
) -> LookaheadProfile:
|
|
46
|
+
"""Zero-ablate the pole on ``prompts``; see :func:`ablation_profile`."""
|
|
47
|
+
return ablation_profile(
|
|
48
|
+
runner, prompts, {"interventions": {layer: ablate_pole(neuron_idx, pole)}}, **kwargs
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@torch.no_grad()
|
|
53
|
+
def ablation_profile(
|
|
54
|
+
runner: NeuronHookRunner,
|
|
55
|
+
prompts: Inputs,
|
|
56
|
+
ablation: Mapping[str, Mapping[int, Intervention]],
|
|
57
|
+
*,
|
|
58
|
+
k: int = 10,
|
|
59
|
+
kl_threshold: float = 1e-3,
|
|
60
|
+
concentration_threshold: float = 0.5,
|
|
61
|
+
) -> LookaheadProfile:
|
|
62
|
+
"""Ablate a unit on ``prompts`` and profile the effect on next-token predictions.
|
|
63
|
+
|
|
64
|
+
``ablation`` holds :meth:`NeuronHookRunner.run_with_interventions` keyword arguments
|
|
65
|
+
(a unit's ``ablation()``). ``kl_divergence`` is KL(clean || ablated) averaged over
|
|
66
|
+
non-padding positions. The unit is flagged as lookahead when two things are true.
|
|
67
|
+
That KL exceeds ``kl_threshold``. And the ``k`` tokens that lose the most probability
|
|
68
|
+
hold at least ``concentration_threshold`` of the total probability mass moved.
|
|
69
|
+
"""
|
|
70
|
+
encoded = runner.encode(prompts)
|
|
71
|
+
mask = encoded.get("attention_mask", torch.ones_like(encoded["input_ids"])).bool()
|
|
72
|
+
clean = F.log_softmax(runner.run_with_interventions(encoded).float(), dim=-1)[mask]
|
|
73
|
+
ablated = F.log_softmax(runner.run_with_interventions(encoded, **ablation).float(), dim=-1)[
|
|
74
|
+
mask
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
kl = F.kl_div(ablated, clean, log_target=True, reduction="batchmean").clamp(min=0.0)
|
|
78
|
+
prob_shift = (clean.exp() - ablated.exp()).mean(dim=0) # positive = unit supports token
|
|
79
|
+
total_moved = prob_shift.abs().sum()
|
|
80
|
+
top = prob_shift.topk(min(k, prob_shift.numel()))
|
|
81
|
+
concentration = float(top.values.clamp(min=0).sum() / total_moved) if total_moved > 0 else 0.0
|
|
82
|
+
|
|
83
|
+
tokenizer = runner.tokenizer
|
|
84
|
+
tokens = [
|
|
85
|
+
tokenizer.decode([i]) if tokenizer is not None else str(i)
|
|
86
|
+
for i, v in zip(top.indices.tolist(), top.values.tolist(), strict=True)
|
|
87
|
+
if v > 0
|
|
88
|
+
]
|
|
89
|
+
return LookaheadProfile(
|
|
90
|
+
is_lookahead=float(kl) >= kl_threshold and concentration >= concentration_threshold,
|
|
91
|
+
target_upcoming_tokens=tokens,
|
|
92
|
+
kl_divergence=float(kl),
|
|
93
|
+
)
|