From aac55b1474274db48f086a912dddef5867a9646b Mon Sep 17 00:00:00 2001 From: jlarson4 Date: Thu, 15 Oct 2026 10:48:57 -0500 Subject: [PATCH] Demoted some analysis tools to demos or sub-methods of other features --- demos/Backward_Lens_Demo.ipynb | 1191 ++++------- ...Lens_Coordinate_Patch_Benchmark_Demo.ipynb | 75 +- demos/Jacobian_Lens_Demo.ipynb | 8 +- demos/SVD_Circuits_Demo.ipynb | 931 +++------ demos/direct_path_patching_ioi.ipynb | 591 +++++- docs/make_docs.py | 1 + docs/source/content/analysis_tools.md | 68 +- docs/source/content/backward_lens.md | 235 --- docs/source/content/jacobian_lens_fitting.md | 256 +-- docs/source/content/svd_circuits.md | 161 -- docs/source/index.md | 3 +- .../jacobian_lens_causal_swap_benchmark.py | 671 ++++++ .../model_bridge/test_jais2_adapter.py | 18 - tests/integration/test_backward_lens.py | 779 ------- ...est_jacobian_lens_causal_swap_benchmark.py | 4 +- tests/integration/test_relevance_lens.py | 389 ---- tests/integration/test_svd_circuits.py | 189 -- tests/integration/test_svd_circuits_mpt.py | 62 - .../test_svd_circuits_oracle_parity.py | 190 -- .../test_gated_mlp_relevance_rule.py | 450 ---- .../test_joint_gate_up_relevance_rule.py | 164 -- .../test_layernorm_weight_offset.py | 25 - .../test_normalization_relevance_rule.py | 418 ---- .../test_qwen3_5_adapter.py | 19 - .../test_relevance_rule_mount_placeholders.py | 129 -- .../unit/model_bridge/test_relevance_rules.py | 400 ---- ...est_jacobian_lens_causal_swap_benchmark.py | 124 +- ...jacobian_lens_causal_swap_benchmark_cli.py | 32 +- ...obian_lens_causal_swap_benchmark_trials.py | 6 +- tests/unit/test_direct_path_patching.py | 814 ++++---- tests/unit/test_make_docs.py | 1 + tests/unit/tools/conftest.py | 8 + tests/unit/tools/test_attribution_patching.py | 28 + tests/unit/tools/test_backward_lens.py | 765 ------- .../tools/test_direct_logit_attribution.py | 1 - tests/unit/tools/test_jacobian_lens.py | 287 ++- ...st_jacobian_lens_coordinate_patch_hooks.py | 10 +- tests/unit/tools/test_logit_lens.py | 19 +- tests/unit/tools/test_model_state.py | 85 + tests/unit/tools/test_relevance_lens.py | 1184 ----------- tests/unit/tools/test_relevance_rules.py | 444 ---- tests/unit/tools/test_svd_circuits.py | 1846 ----------------- tests/unit/utilities/test_statistics.py | 66 + .../model_bridge/_relevance_rules.py | 357 ---- .../generalized_components/gated_mlp.py | 244 +-- .../joint_gate_up_mlp.py | 36 +- .../generalized_components/normalization.py | 163 +- transformer_lens/patching.py | 217 +- transformer_lens/tools/analysis/__init__.py | 169 +- .../tools/analysis/_model_state.py | 81 +- .../tools/analysis/attribution_patching.py | 85 +- .../tools/analysis/backward_lens.py | 1005 --------- .../analysis/direct_logit_attribution.py | 22 +- .../tools/analysis/direct_path_patching.py | 375 +--- .../tools/analysis/jacobian_lens.py | 425 ++-- .../jacobian_lens_causal_swap_benchmark.py | 831 -------- ...jacobian_lens_causal_swap_benchmark_cli.py | 194 -- .../jacobian_lens_coordinate_patch.py | 17 +- .../analysis/jacobian_lens_decomposition.py | 21 +- .../analysis/jacobian_lens_registry.json | 3 +- transformer_lens/tools/analysis/logit_lens.py | 33 +- .../tools/analysis/projection_kernel.py | 68 +- .../tools/analysis/relevance_lens.py | 407 ---- .../tools/analysis/sparse_probing.py | 40 +- .../tools/analysis/svd_circuits.py | 1070 ---------- transformer_lens/utilities/__init__.py | 2 + transformer_lens/utilities/statistics.py | 122 +- 67 files changed, 3639 insertions(+), 15495 deletions(-) delete mode 100644 docs/source/content/backward_lens.md delete mode 100644 docs/source/content/svd_circuits.md create mode 100644 scripts/jacobian_lens_causal_swap_benchmark.py delete mode 100644 tests/integration/test_backward_lens.py delete mode 100644 tests/integration/test_relevance_lens.py delete mode 100644 tests/integration/test_svd_circuits.py delete mode 100644 tests/integration/test_svd_circuits_mpt.py delete mode 100644 tests/integration/test_svd_circuits_oracle_parity.py delete mode 100644 tests/unit/model_bridge/generalized_components/test_gated_mlp_relevance_rule.py delete mode 100644 tests/unit/model_bridge/generalized_components/test_joint_gate_up_relevance_rule.py delete mode 100644 tests/unit/model_bridge/generalized_components/test_normalization_relevance_rule.py delete mode 100644 tests/unit/model_bridge/supported_architectures/test_relevance_rule_mount_placeholders.py delete mode 100644 tests/unit/model_bridge/test_relevance_rules.py rename tests/unit/{tools => scripts}/test_jacobian_lens_causal_swap_benchmark.py (80%) rename tests/unit/{tools => scripts}/test_jacobian_lens_causal_swap_benchmark_cli.py (51%) rename tests/unit/{tools => scripts}/test_jacobian_lens_causal_swap_benchmark_trials.py (99%) delete mode 100644 tests/unit/tools/test_backward_lens.py create mode 100644 tests/unit/tools/test_model_state.py delete mode 100644 tests/unit/tools/test_relevance_lens.py delete mode 100644 tests/unit/tools/test_relevance_rules.py delete mode 100644 tests/unit/tools/test_svd_circuits.py delete mode 100644 transformer_lens/model_bridge/_relevance_rules.py delete mode 100644 transformer_lens/tools/analysis/backward_lens.py delete mode 100644 transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark.py delete mode 100644 transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark_cli.py delete mode 100644 transformer_lens/tools/analysis/relevance_lens.py delete mode 100644 transformer_lens/tools/analysis/svd_circuits.py diff --git a/demos/Backward_Lens_Demo.ipynb b/demos/Backward_Lens_Demo.ipynb index 483b69dba1..070e7f9846 100644 --- a/demos/Backward_Lens_Demo.ipynb +++ b/demos/Backward_Lens_Demo.ipynb @@ -7,9 +7,9 @@ "source": [ "# Backward Lens: gradient factors in vocabulary space\n", "\n", - "This notebook demonstrates `BackwardLens` on a small, fixed set of GPT-2 next-token targets, then repeats one reconstruction against a raw Pythia-70m Bridge to show the same dense-MLP contract generalizes beyond `Conv1D` weights. It reproduces the *method* of projecting forward inputs and backward signals into vocabulary space; it is not a paper-scale replication and makes no causal claim from token rankings alone. All examples and layers are declared before analysis, and all outcomes are shown without filtering.\n", + "This notebook implements Backward Lens in a few short cells on top of `TransformerBridge` hooks and `logit_readout`, applies it to a small, fixed set of GPT-2 next-token targets, then repeats one reconstruction on Pythia-70m to show the same code handles both MLP weight layouts. It reproduces the *method* of projecting the forward inputs and backward signals of an MLP weight gradient into vocabulary space; it is not a paper-scale replication and makes no causal claim from token rankings alone. All examples and layers are declared before analysis, and all outcomes are shown without filtering.\n", "\n", - "The implementation follows [Katz et al. (2024)](https://aclanthology.org/2024.emnlp-main.142/). Gradient signs below are raw `d(loss) / d(tensor)` signs; gradient descent subtracts them." + "The method follows [Katz et al. (2024)](https://aclanthology.org/2024.emnlp-main.142/). Gradient signs below are raw `d(loss) / d(tensor)` signs; gradient descent subtracts them." ] }, { @@ -19,11 +19,39 @@ "source": [ "## Geometry\n", "\n", - "Each MLP linear projection stores its weight as `[in, out]` (`Conv1D`, e.g. GPT-2) or `[out, in]` (`torch.nn.Linear`, e.g. Pythia/GPT-NeoX). `BackwardLens` reads which layout applies from the Bridge projection component, not the model class, and token-position factors reconstruct the gradient as\n", + "For one linear projection and one prompt, let $x_i$ be the projection's input at token position $i$ and $\\delta_i = \\partial L / \\partial y_i$ the loss gradient at its output. The weight gradient is a sum of one outer product per position:\n", "\n", "$$\\nabla_W L = \\sum_i x_i \\delta_i^\\mathsf{T} = X^\\mathsf{T}\\Delta.$$\n", "\n", - "For FF1 (`c_fc`), the residual-width factor is the forward input $x_i$. For FF2 (`c_proj`), it is the backward signal $\\delta_i$. `BackwardLens` applies a fresh `ln_final` and unembedding to those residual-width rows." + "That is the gradient for `[in, out]` storage (`Conv1D`, as in GPT-2); `torch.nn.Linear` (Pythia/GPT-NeoX) stores `[out, in]`, so its gradient is the transpose. A dense MLP has two projections, and each has exactly one residual-width factor that can be read in vocabulary space:\n", + "\n", + "| Projection | Bridge hook points | Weight shape (`[in, out]`) | Residual-width factor |\n", + "|---|---|---|---|\n", + "| FF1 (`mlp.in`, GPT-2 `c_fc`) | `blocks.{l}.mlp.in.hook_in`, `.hook_out` | `[d_model, d_mlp]` | forward input $x_i$: the post-`ln_2` residual, gain and bias included |\n", + "| FF2 (`mlp.out`, GPT-2 `c_proj`) | `blocks.{l}.mlp.out.hook_in`, `.hook_out` | `[d_mlp, d_model]` | backward signal $\\delta_i$ at the MLP output |\n", + "\n", + "FF1 rows describe what entered the MLP; FF2 rows are raw loss gradients at its output. They are different quantities and should not be read as one signal." + ] + }, + { + "cell_type": "markdown", + "id": "5c0e1a77", + "metadata": {}, + "source": [ + "## Reading a factor in vocabulary space\n", + "\n", + "Each residual-width row $v$ is read as $P(v) = \\operatorname{Unembed}(\\operatorname{LN}_{final}(v))$ with `logit_readout`, which recomputes the final norm's statistics for every row instead of reusing the scales cached during the forward pass. The values are signed, pre-softmax logits, not probabilities. The Normalized Logit Lens reads $P(v / \\lVert v\\rVert_2)$ instead. The final LayerNorm has an epsilon and a bias, so raw and normalized readouts can differ, mostly for rows with very small norms such as backward signals; exact-zero rows stay zero.\n", + "\n", + "Gradient descent applies $W \\leftarrow W - \\eta \\nabla_W L$, so for FF2 the *smallest* raw-gradient logits and ascending target ranks are the ordering relevant to an update. Negating projected logits is not equivalent: the final norm's bias and epsilon make the readout not exactly sign-symmetric.\n", + "\n", + "## Restrictions\n", + "\n", + "The implementation below is deliberately narrow:\n", + "\n", + "- **Dense, non-gated MLP.** It factors the two projections of a dense MLP (GPT-2, Pythia/GPT-NeoX). A gated MLP (Llama, Mistral, Qwen, Gemma) has a third, gate projection that is not handled here. Models whose MLP runs on flattened `[batch * position, width]` tensors (OPT) or that have no MLP (Mamba, RWKV) do not fit either.\n", + "- **Raw Bridge.** Gradients are taken for the original weights, the ones training would update. Compatibility mode folds norms into the weights, which changes both the factors and the weights they belong to, so it stays off.\n", + "- **Evaluation mode.** Dropout in training mode would perturb the gradients. `model.eval()` disables it without disabling autograd, so do not wrap the analysis in `torch.no_grad()`.\n", + "- **One prompt, one target token.** The loss is next-token cross-entropy at the final position against a single token. With GPT-2's tokenizer a leading space usually matters, and the prompt's own tokenization (including any BOS) defines the positions." ] }, { @@ -32,10 +60,10 @@ "id": "4955da53", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:04:37.335213Z", - "iopub.status.busy": "2026-09-02T13:04:37.335044Z", - "iopub.status.idle": "2026-09-02T13:05:32.078419Z", - "shell.execute_reply": "2026-09-02T13:05:32.077709Z" + "iopub.execute_input": "2026-10-14T19:03:26.763115Z", + "iopub.status.busy": "2026-10-14T19:03:26.762909Z", + "iopub.status.idle": "2026-10-14T19:03:28.707818Z", + "shell.execute_reply": "2026-10-14T19:03:28.707515Z" } }, "outputs": [ @@ -100,7 +128,6 @@ "# NBVAL_IGNORE_OUTPUT\n", "import gc\n", "import os\n", - "import warnings\n", "\n", "os.environ[\"HF_HUB_DISABLE_PROGRESS_BARS\"] = \"1\"\n", "os.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\n", @@ -109,24 +136,18 @@ "import numpy as np\n", "import pandas as pd\n", "import torch\n", + "import torch.nn.functional as F\n", "from huggingface_hub.utils import logging as hf_hub_logging\n", "from IPython.display import display\n", "from transformers.utils import logging as hf_logging\n", - "from typeguard import InstrumentationWarning\n", "\n", - "with warnings.catch_warnings():\n", - " warnings.filterwarnings(\n", - " \"ignore\",\n", - " message=\"@typechecked only supports instrumenting functions wrapped.*\",\n", - " category=InstrumentationWarning,\n", - " )\n", - " from transformer_lens.model_bridge import TransformerBridge\n", - " from transformer_lens.tools.analysis import BackwardLens\n", + "from transformer_lens.model_bridge import TransformerBridge\n", + "from transformer_lens.tools.analysis import logit_readout\n", "\n", + "# Loading messages and progress bars would otherwise land in the checked cell outputs.\n", "hf_hub_logging.set_verbosity_error()\n", "hf_logging.set_verbosity_error()\n", "hf_logging.disable_progress_bar()\n", - "torch.manual_seed(0)\n", "\n", "DEVICE = \"cpu\" # Stable saved outputs and portable notebook validation.\n", "DETAILED_LAYERS = (0, 2, 4, 6, 8, 10, 11)\n", @@ -139,102 +160,133 @@ "pd.DataFrame(EXAMPLES, columns=[\"prompt\", \"one-token target\"])" ] }, + { + "cell_type": "markdown", + "id": "ea451106", + "metadata": {}, + "source": [ + "## Load a raw GPT-2 Bridge\n", + "\n", + "Boot the Bridge without compatibility mode or weight processing, then switch it to evaluation mode. CPU float32 keeps the saved outputs portable; change `DEVICE` for interactive exploration." + ] + }, { "cell_type": "code", "execution_count": 2, - "id": "8b1857ae", + "id": "a5007e02", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:32.080857Z", - "iopub.status.busy": "2026-09-02T13:05:32.080627Z", - "iopub.status.idle": "2026-09-02T13:05:32.517362Z", - "shell.execute_reply": "2026-09-02T13:05:32.516603Z" + "iopub.execute_input": "2026-10-14T19:03:28.709162Z", + "iopub.status.busy": "2026-10-14T19:03:28.709040Z", + "iopub.status.idle": "2026-10-14T19:03:29.044535Z", + "shell.execute_reply": "2026-10-14T19:03:29.044243Z" } }, "outputs": [ { - "data": { - "image/png": "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OxyiZ9LWyx1eHk0xd/VWXLmdqO1wSIuORl7B8u0H0aubL+wb1NN2OPQfJZFI8NbQl3E+6hJW/b2J90q1xMUr0Vi+7k907tAKTRu5aDscegqJKNMPY//hU1i0fD2aNGkCn44d0aZNG5ibm2szPnoKmUyG69euITIyEufOnUP7Nh74bNo4GBkaajs0omdWUFCIz7/5GTdvxWNod18EtHGHk62NtsOiF0AIgZt3U3E08gq2HTuLbgEdMe39t6Cvx9ePkXbJ75W8W7VCjy7+8GrRAgYGfJ93TSKEQGJyMsIiIrDrYAi823jg84/egSHPo85TSlwA4OTZC/h331Fcio6BRCKBg4M9TE3NYMgbYZ1SUlKC/Lw83H9wH3l5+XCwt0W3Lh3w+mt9mLRQrVJQUIglv2/EibBIFBYVobFjAzRybADzOiYw5CDtWqdIKkV2XgGu3rmLlLQMWJqboVd3X4wZ/iqTFtIZh0PPYt0/e5CUcg9mpqZo2bw5LMzNYWJszDESOqxYKkVuXh5ux8fjbkoKTOuYoJu/DyaMGcKkpYZQSVzkMjKzcfJMJG7HJyEnNw9FxXyfgi7R09ODuakp6terC1+f1mjc0JkXS6rVCgqLEB4ZjRNnInHvwUPk5ORBxq4atY6hgT7MzUzh4mQP/87t0KZlCxgYMEEl3SOEwO07d3E8LAJXb9xCTk6+4l0gpJsM9EuvLw729RHQ2RvtWrvzy94aptzEhYiIiIiISFfwuTsREREREek8Ji5ERERERKTzmLgQEREREZHOY+JCREREREQ6j4kLERERERHpPCYuRERERESk85i4EBERERGRzmPiQkREREREOo+JCxERERER6TwmLkREREREpPOYuBARERERkc5j4kJERERERDqPiQsREREREek8Ji5ERERERKTzmLgQEREREZHOY+JCREREREQ6j4kLERERERHpPCYuRERERESk85i4EBERERGRzmPiQkREREREOo+JCxERERER6TwmLkREREREpPOYuBARERERkc5j4kJERERERDqPiQsREREREek8Ji5ERERERKTzmLgQEREREZHOY+JCREREREQ6j4kLERERERHpPCYuRERERESk85i4EBERERGRzmPiQkREREREOo+JCxERERER6TwmLkREREREpPOYuBARERERkc5j4kJERERERDqPiQsRERHRf0RG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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded a raw GPT-2 TransformerBridge: 12 layers, d_model=768, d_mlp=3072.\n" + ] } ], "source": [ - "# Original schematic: each position contributes one outer product.\n", - "fig, ax = plt.subplots(figsize=(10, 3.2))\n", - "ax.axis(\"off\")\n", - "boxes = [\n", - " (0.04, 0.55, \"forward row\\n$x_i$\", \"#dbeafe\"),\n", - " (0.28, 0.55, r\"$\\otimes$\", \"white\"),\n", - " (0.43, 0.55, \"backward row\\n\" r\"$\\delta_i$\", \"#fee2e2\"),\n", - " (0.67, 0.55, \"$=$\", \"white\"),\n", - " (0.80, 0.55, \"rank-one\\ncontribution\", \"#ede9fe\"),\n", - "]\n", - "for x, y, label, color in boxes:\n", - " ax.text(\n", - " x,\n", - " y,\n", - " label,\n", - " ha=\"center\",\n", - " va=\"center\",\n", - " fontsize=12,\n", - " bbox=dict(boxstyle=\"round,pad=0.5\", facecolor=color, edgecolor=\"#475569\"),\n", - " )\n", - "ax.annotate(\n", - " \"sum over prompt positions\",\n", - " xy=(0.80, 0.27),\n", - " xytext=(0.43, 0.12),\n", - " arrowprops=dict(arrowstyle=\"->\", color=\"#475569\"),\n", - " color=\"#334155\",\n", - ")\n", - "ax.set_title(\"Outer-product decomposition of one MLP weight gradient\", fontsize=14)\n", - "plt.show()" + "model = TransformerBridge.boot_transformers(\"gpt2\", device=DEVICE, dtype=torch.float32)\n", + "model.eval()\n", + "print(\n", + " f\"Loaded a raw GPT-2 TransformerBridge: {model.cfg.n_layers} layers, \"\n", + " f\"d_model={model.cfg.d_model}, d_mlp={model.cfg.d_mlp}.\"\n", + ")" ] }, { "cell_type": "markdown", - "id": "ea451106", + "id": "3f6b2d10", "metadata": {}, "source": [ - "## Load a raw GPT-2 Bridge\n", + "## Capture the factors\n", "\n", - "Backward Lens requires original GPT-2 weights. Do not enable compatibility mode or process the weights. CPU float32 is used deliberately so the saved notebook is portable; change `DEVICE` for interactive exploration." + "`mlp_gradient_factors` captures both sides of every requested projection with forward hooks, runs one forward pass, and calls `torch.autograd.grad` once for the loss's gradient with respect to the captured projection outputs (the $\\delta_i$) and the projection weights. The weight gradients serve only as a check: the outer-product sum must reproduce what autograd computes for the weight itself. Nothing is written to any parameter's `.grad`, and `run_with_hooks` removes the hooks when the forward pass returns.\n", + "\n", + "`vocab_logits` is the readout: `logit_readout` with no token selector returns the full-vocabulary logits of each row." ] }, { "cell_type": "code", "execution_count": 3, - "id": "a5007e02", + "id": "8c1d7e42", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:32.519185Z", - "iopub.status.busy": "2026-09-02T13:05:32.518979Z", - "iopub.status.idle": "2026-09-02T13:05:34.263223Z", - "shell.execute_reply": "2026-09-02T13:05:34.262420Z" + "iopub.execute_input": "2026-10-14T19:03:29.045752Z", + "iopub.status.busy": "2026-10-14T19:03:29.045684Z", + "iopub.status.idle": "2026-10-14T19:03:29.049662Z", + "shell.execute_reply": "2026-10-14T19:03:29.049297Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loaded a raw GPT-2 TransformerBridge.\n" - ] - } - ], + "outputs": [], "source": [ - "with warnings.catch_warnings():\n", - " warnings.simplefilter(\"ignore\")\n", - " model = TransformerBridge.boot_transformers(\n", - " \"gpt2\", device=DEVICE, dtype=torch.float32\n", - " )\n", - "model.eval()\n", - "lens = BackwardLens(model)\n", - "print(\"Loaded a raw GPT-2 TransformerBridge.\")" + "PROJECTIONS = (\"in\", \"out\")\n", + "SIDES = (\"hook_in\", \"hook_out\")\n", + "\n", + "\n", + "def mlp_gradient_factors(model, prompt, target, layers):\n", + " \"\"\"Rank-1 factors of each requested MLP weight gradient for one next-token loss.\"\"\"\n", + " if model.compatibility_mode:\n", + " raise ValueError(\"Backward Lens reads the original weights; boot a raw Bridge\")\n", + " if any(module.training for module in model.modules()):\n", + " raise ValueError(\"call model.eval() first: dropout would perturb the gradients\")\n", + " if any(getattr(model.blocks[layer].mlp, \"gate\", None) is not None for layer in layers):\n", + " raise ValueError(\"a gated MLP has a third projection that this code does not factor\")\n", + " target_ids = model.to_tokens(target, prepend_bos=False)[0]\n", + " if target_ids.numel() != 1:\n", + " raise ValueError(f\"{target!r} is {target_ids.numel()} tokens; the loss takes one\")\n", + " sites = [(layer, proj) for layer in layers for proj in PROJECTIONS]\n", + " captured = {}\n", + "\n", + " def capture(tensor, hook):\n", + " captured[hook.name] = tensor\n", + "\n", + " names = [f\"blocks.{layer}.mlp.{proj}.{side}\" for layer, proj in sites for side in SIDES]\n", + " tokens = model.to_tokens(prompt)\n", + " with torch.enable_grad():\n", + " logits = model.run_with_hooks(tokens, fwd_hooks=[(name, capture) for name in names])\n", + " loss = F.cross_entropy(logits[0, -1:], target_ids.to(logits.device))\n", + " outputs = [captured[f\"blocks.{layer}.mlp.{proj}.hook_out\"] for layer, proj in sites]\n", + " projections = [getattr(model.blocks[layer].mlp, proj) for layer, proj in sites]\n", + " weights = [projection.original_component.weight for projection in projections]\n", + " grads = torch.autograd.grad(loss, outputs + weights)\n", + "\n", + " factors = {}\n", + " for (layer, proj), delta, weight_grad in zip(sites, grads[: len(sites)], grads[len(sites) :]):\n", + " x, delta = captured[f\"blocks.{layer}.mlp.{proj}.hook_in\"][0].detach(), delta[0].detach()\n", + " outer = x.T @ delta\n", + " # d_model != d_mlp, so the gradient's shape tells Conv1D [in, out] from Linear [out, in].\n", + " layout = \"in_out\" if weight_grad.shape == outer.shape else \"out_in\"\n", + " rebuilt = outer if layout == \"in_out\" else outer.T\n", + " factors[layer, proj] = {\n", + " \"residual\": x if proj == \"in\" else delta,\n", + " \"layout\": layout,\n", + " \"relative_error\": float((rebuilt - weight_grad).norm() / weight_grad.norm()),\n", + " }\n", + " return {\"tokens\": tokens[0].cpu(), \"target_id\": int(target_ids), \"factors\": factors}\n", + "\n", + "\n", + "def vocab_logits(model, rows, normalized=False):\n", + " \"\"\"Signed logits of residual-width rows read through the final norm and unembedding.\"\"\"\n", + " if normalized:\n", + " norms = rows.norm(dim=-1, keepdim=True)\n", + " rows = rows / torch.where(norms == 0, torch.ones_like(norms), norms)\n", + " return logit_readout(model, rows).values\n", + "\n", + "\n", + "def ascending_rank(logits, token_id):\n", + " \"\"\"Zero-based rank counted from the smallest logit; tied logits share a rank.\"\"\"\n", + " return (logits < logits[..., token_id, None]).sum(dim=-1)\n", + "\n", + "\n", + "def decode(model, ids):\n", + " return [model.tokenizer.decode([int(i)]).replace(\"\\n\", \"\\\\n\") for i in ids]" ] }, { @@ -243,10 +295,10 @@ "id": "60ea683d", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:34.265510Z", - "iopub.status.busy": "2026-09-02T13:05:34.265279Z", - "iopub.status.idle": "2026-09-02T13:05:34.682954Z", - "shell.execute_reply": "2026-09-02T13:05:34.682165Z" + "iopub.execute_input": "2026-10-14T19:03:29.050626Z", + "iopub.status.busy": "2026-10-14T19:03:29.050570Z", + "iopub.status.idle": "2026-10-14T19:03:29.137301Z", + "shell.execute_reply": "2026-10-14T19:03:29.136926Z" } }, "outputs": [ @@ -254,39 +306,25 @@ "name": "stdout", "output_type": "stream", "text": [ - "Analyzed 6 positions across 7 layers.\n" + "Prompt positions: ['<|endoftext|>', 'The', ' capital', ' of', ' France', ' is']\n", + "Target ' Paris' is token id 6342.\n", + "Factored 14 weight gradients (layouts: ['in_out']).\n", + "Outer-product sums match autograd's weight gradients to 1e-5: True\n" ] } ], "source": [ - "def final_position_summary(result):\n", - " rows = []\n", - " for layer_result in result.layers:\n", - " ff2 = layer_result.output_projection\n", - " rows.append(\n", - " {\n", - " \"layer\": layer_result.layer,\n", - " \"raw target rank\": int(\n", - " ff2.gradient_descent_target_ranks(result.target_token_id)[-1]\n", - " ),\n", - " \"normalized target rank\": int(\n", - " ff2.gradient_descent_target_ranks(\n", - " result.target_token_id, normalized=True\n", - " )[-1]\n", - " ),\n", - " \"final-position VJP norm\": float(ff2.factor_norms[-1]),\n", - " }\n", - " )\n", - " return pd.DataFrame(rows)\n", - "\n", - "\n", "prompt, target = EXAMPLES[0]\n", - "primary_result = lens.analyze(prompt, target, DETAILED_LAYERS, normalized=True)\n", - "primary_layer_summary = final_position_summary(primary_result)\n", + "primary = mlp_gradient_factors(model, prompt, target, DETAILED_LAYERS)\n", + "position_tokens = decode(model, primary[\"tokens\"])\n", + "worst_error = max(f[\"relative_error\"] for f in primary[\"factors\"].values())\n", + "print(f\"Prompt positions: {position_tokens}\")\n", + "print(f\"Target {target!r} is token id {primary['target_id']}.\")\n", "print(\n", - " f\"Analyzed {len(primary_result.prompt_token_ids)} positions across \"\n", - " f\"{len(primary_result.layers)} layers.\"\n", - ")" + " f\"Factored {len(primary['factors'])} weight gradients \"\n", + " f\"(layouts: {sorted({f['layout'] for f in primary['factors'].values()})}).\"\n", + ")\n", + "print(f\"Outer-product sums match autograd's weight gradients to 1e-5: {worst_error < 1e-5}\")" ] }, { @@ -296,9 +334,7 @@ "source": [ "## Vocabulary-facing factor tables\n", "\n", - "FF1 top tokens describe forward residual directions. FF2 bottom tokens use the ascending raw-gradient ordering relevant to a subtracted gradient-descent update. The layer-by-position table keeps those two factors separate. The final-position comparison shows how unit normalization changes FF2 token ordering while retaining and coloring by the original VJP norm.\n", - "\n", - "The sign panel compares the largest raw-gradient logits with the ordering obtained by subtracting those same projected logits. It is deliberately labeled as an ordering diagnostic, not as $P(-v)$: final LayerNorm makes direct factor negation a different operation. None of these tables are direct model predictions." + "FF1 top tokens describe the forward residual directions entering each MLP. FF2 bottom tokens use the ascending raw-gradient ordering relevant to a subtracted gradient-descent update. The layer-by-position table keeps the two factors separate. The final-position comparison shows how unit normalization changes the FF2 ordering, next to the original VJP (vector-Jacobian product) norm. None of these tables are model predictions." ] }, { @@ -307,10 +343,10 @@ "id": "663bd83f", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:34.685339Z", - "iopub.status.busy": "2026-09-02T13:05:34.685077Z", - "iopub.status.idle": "2026-09-02T13:05:35.033441Z", - "shell.execute_reply": "2026-09-02T13:05:35.032697Z" + "iopub.execute_input": "2026-10-14T19:03:29.138382Z", + "iopub.status.busy": "2026-10-14T19:03:29.138321Z", + "iopub.status.idle": "2026-10-14T19:03:29.197991Z", + "shell.execute_reply": "2026-10-14T19:03:29.197659Z" } }, "outputs": [ @@ -356,7 +392,7 @@ " 0\n", " 1\n", " The\n", - " The, the,  The\n", + " The, the, The\n", " ixel, Medium, iop\n", " \n", " \n", @@ -372,23 +408,23 @@ " 0\n", " 3\n", " of\n", - " of, of,  Of\n", - " Archdemon, ortium,  Difference\n", + " of, of, Of\n", + " Archdemon, ortium, Difference\n", " \n", " \n", " 4\n", " 0\n", " 4\n", " France\n", - " France, France,  French\n", - " anton,  Seasons, figure\n", + " France, France, French\n", + " anton, Seasons, figure\n", " \n", " \n", " 5\n", " 0\n", " 5\n", " is\n", - " is,  also,  was\n", + " is, also, was\n", " adr, adian, umn\n", " \n", " \n", @@ -396,15 +432,15 @@ " 2\n", " 0\n", " <|endoftext|>\n", - " ,,  the, \\n\n", - " ometric,  Associates, 76561\n", + " ,, the, \\n\n", + " ometric, Associates, 76561\n", " \n", " \n", " 7\n", " 2\n", " 1\n", " The\n", - " latest,  following,  Official\n", + " latest, following, Official\n", " orescent, oro, ulhu\n", " \n", " \n", @@ -412,15 +448,15 @@ " 2\n", " 2\n", " capital\n", - " capital,  punishment,  penalty\n", - " illary, fig,  Variant\n", + " capital, punishment, penalty\n", + " illary, fig, Variant\n", " \n", " \n", " 9\n", " 2\n", " 3\n", " of\n", - " the,  course,  interest\n", + " the, course, interest\n", " uably, iqueness, uments\n", " \n", " \n", @@ -428,7 +464,7 @@ " 2\n", " 4\n", " France\n", - " Connection,  France, 's\n", + " Connection, France, 's\n", " Emerald, baugh, DIT\n", " \n", " \n", @@ -436,31 +472,31 @@ " 2\n", " 5\n", " is\n", - " now,  not,  also\n", - " EStreamFrame, ORPG,  Dise\n", + " now, not, also\n", + " EStreamFrame, ORPG, Dise\n", " \n", " \n", " 12\n", " 4\n", " 0\n", " <|endoftext|>\n", - " ,,  the,  and\n", - " OTOS, ë,  istg\n", + " ,, the, and\n", + " OTOS, ë, istg\n", " \n", " \n", " 13\n", " 4\n", " 1\n", " The\n", - " resa,  latest,  Bottom\n", - " ��,  Eth, �\n", + " resa, latest, Bottom\n", + " ��, Eth, �\n", " \n", " \n", " 14\n", " 4\n", " 2\n", " capital\n", - " capital,  punishment,  city\n", + " capital, punishment, city\n", " nings, BILITIES, cles\n", " \n", " \n", @@ -468,31 +504,31 @@ " 4\n", " 3\n", " of\n", - " the,  Reach,  course\n", - " motivating,  Appearance,  Cups\n", + " the, Reach, course\n", + " motivating, Appearance, Cups\n", " \n", " \n", " 16\n", " 4\n", " 4\n", " France\n", - " 's, ,,  Telecom\n", - " ��,  Antar,  Explosive\n", + " 's, ,, Telecom\n", + " ��, Antar, Explosive\n", " \n", " \n", " 17\n", " 4\n", " 5\n", " is\n", - " now,  not,  also\n", - " Diseases,  Firstly,  Feet\n", + " now, not, also\n", + " Diseases, Firstly, Feet\n", " \n", " \n", " 18\n", " 6\n", " 0\n", " <|endoftext|>\n", - " ,, .,  and\n", + " ,, ., and\n", " SON, leys, VAL\n", " \n", " \n", @@ -500,7 +536,7 @@ " 6\n", " 1\n", " The\n", - " resa, oret,  Latest\n", + " resa, oret, Latest\n", " �, ��, esters\n", " \n", " \n", @@ -508,7 +544,7 @@ " 6\n", " 2\n", " capital\n", - " city,  capital,  cities\n", + " city, capital, cities\n", " appings, UES, =-=-\n", " \n", " \n", @@ -516,15 +552,15 @@ " 6\n", " 3\n", " of\n", - " the,  State,  British\n", - " ヘラ, TABLE,  �\n", + " the, State, British\n", + " ヘラ, TABLE, �\n", " \n", " \n", " 22\n", " 6\n", " 4\n", " France\n", - " 's, ,,  and\n", + " 's, ,, and\n", " , /_, ciation\n", " \n", " \n", @@ -532,8 +568,8 @@ " 6\n", " 5\n", " is\n", - " now,  officially,  not\n", - " Tycoon, 76561,  Osiris\n", + " now, officially, not\n", + " Tycoon, 76561, Osiris\n", " \n", " \n", " 24\n", @@ -548,7 +584,7 @@ " 8\n", " 1\n", " The\n", - " resa, oret,  following\n", + " resa, oret, following\n", " Notting, ournals, EngineDebug\n", " \n", " \n", @@ -556,7 +592,7 @@ " 8\n", " 2\n", " capital\n", - " city,  capital,  cities\n", + " city, capital, cities\n", " Paris, Denver, Philadelphia\n", " \n", " \n", @@ -564,7 +600,7 @@ " 8\n", " 3\n", " of\n", - " South,  British,  China\n", + " South, British, China\n", " Frames, Reviewer, selage\n", " \n", " \n", @@ -572,31 +608,31 @@ " 8\n", " 4\n", " France\n", - " 's, ,,  has\n", - " ciation, ㅋㅋ,  Spac\n", + " 's, ,, has\n", + " ciation, ㅋㅋ, Spac\n", " \n", " \n", " 29\n", " 8\n", " 5\n", " is\n", - " now,  officially,  still\n", - " Paris, Paris,  Cologne\n", + " now, officially, still\n", + " Paris, Paris, Cologne\n", " \n", " \n", " 30\n", " 10\n", " 0\n", " <|endoftext|>\n", - " \\n,  the, ,\n", - " Paris, ゼウス,  Hollande\n", + " \\n, the, ,\n", + " Paris, ゼウス, Hollande\n", " \n", " \n", " 31\n", " 10\n", " 1\n", " The\n", - " resa, oret,  latest\n", + " resa, oret, latest\n", " �醒, ��, ���\n", " \n", " \n", @@ -604,7 +640,7 @@ " 10\n", " 2\n", " capital\n", - " city,  cities,  capital\n", + " city, cities, capital\n", " Paris, ��, �\n", " \n", " \n", @@ -612,7 +648,7 @@ " 10\n", " 3\n", " of\n", - " China,  Kazakhstan,  Ukraine\n", + " China, Kazakhstan, Ukraine\n", " �醒, ���, Paris\n", " \n", " \n", @@ -620,16 +656,16 @@ " 10\n", " 4\n", " France\n", - " has,  is, 's\n", - " cffff, eteenth,  mathemat\n", + " has, is, 's\n", + " cffff, eteenth, mathemat\n", " \n", " \n", " 35\n", " 10\n", " 5\n", " is\n", - " now,  officially,  rapidly\n", - " Paris, Paris,  Copenhagen\n", + " now, officially, rapidly\n", + " Paris, Paris, Copenhagen\n", " \n", " \n", " 36\n", @@ -644,7 +680,7 @@ " 11\n", " 1\n", " The\n", - " resa, odore,  latest\n", + " resa, odore, latest\n", " �, �, \u0010\n", " \n", " \n", @@ -652,7 +688,7 @@ " 11\n", " 2\n", " capital\n", - " city,  cities,  Manila\n", + " city, cities, Manila\n", " �, �, \u0010\n", " \n", " \n", @@ -660,7 +696,7 @@ " 11\n", " 3\n", " of\n", - " Romania,  Kazakhstan,  Armenia\n", + " Romania, Kazakhstan, Armenia\n", " �, �, \u0010\n", " \n", " \n", @@ -668,7 +704,7 @@ " 11\n", " 4\n", " France\n", - " has,  is, 's\n", + " has, is, 's\n", " �, �, \u0010\n", " \n", " \n", @@ -676,8 +712,8 @@ " 11\n", " 5\n", " is\n", - " undergoing,  reeling,  hosting\n", - " Paris, Paris,  Copenhagen\n", + " undergoing, reeling, hosting\n", + " Paris, Paris, Copenhagen\n", " \n", " \n", "\n", @@ -776,238 +812,6 @@ "metadata": {}, "output_type": "display_data" }, - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 layerVJP normraw FF2 bottom tokensnormalized FF2 bottom tokens
006.557e-01adr, adian, umn, itia, igmaticadr, adian, umn, itia, igmatic
121.442e+00EStreamFrame, ORPG, Dise, misconception, senalEStreamFrame, ORPG, Dise, misconception, senal
241.329e+00 Diseases, Firstly, Feet, heses, Differences Diseases, Firstly, Feet, heses, Differences
361.042e+00 Tycoon, 76561, Osiris, Modes, Memories Tycoon, 76561, Osiris, Modes, Memories
484.872e-01 Paris, Paris, Cologne, Copenhagen, Rome Paris, Paris, Cologne, Copenhagen, Rome
5102.749e-01 Paris, Paris, Copenhagen, Cologne, Kyoto Paris, Paris, Copenhagen, Cologne, Kyoto
6112.609e-01 Paris, Paris, Copenhagen, Marse, France Paris, Paris, Copenhagen, Marse, France
\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABKYAAAGNCAYAAAArTXm0AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAmDlJREFUeJzs3Xd4FNX79/HPpoeQhE4oIfQmTelILwLSMTRFUTqiX1FRQFEsIKigIiqKqKCooCJdFKWGDhKaNCEQeoAESAIkkOQ8f/hkfyxJYAMJk4T367q4LvbMmTP3nJnsnr135ozNGGMEAAAAAAAA3GUuVgcAAAAAAACAexOJKQAAAAAAAFiCxBQAAAAAAAAsQWIKAAAAAAAAliAxBQAAAAAAAEuQmAIAAAAAAIAlSEwBAAAAAADAEiSmAAAAAAAAYAkSUwAAAAAAALAEiSkAuIddvHhRTzzxhAoWLCibzabhw4dr+/btstlsmj17dqZue9++fbLZbJo1a1ambudOpDfGrL5PWT2+OzF79mzZbDZt3749Q9udOHGibDabLly4kKHtptfUqVNVpEgRXb582dI47obUjmVWOQ6ZoWHDhho6dGiGtbd8+XJ5e3vryJEjGdZmZhs9erRsNpsSEhIsi+Gpp55Sq1atLNs+ANzLSEwBwF1y+vRp2Wy2NP/FxcWlq96VK1f07bffqlWrVipcuLDy5s2rOnXq6JtvvlFSUpJTMY0ZM0bLly/X5s2bZYzRxIkTM23/c4q7lbgDkl28eFFjxozRqFGjlCtXrkzd1tixY2Wz2RQbG5up28lOKlasqM6dOztdv2nTpg7v2V5eXqpUqZLeeOMN+/t3sp9++kl///23Xn311QyLt0WLFqpXr55GjBiRYW3mFD179lTJkiVTXfb6669r9erVWrRo0d0NCgBAYgoA7rahQ4fKGJPin5eXV7rqffHFFxo4cKAaNmyo0NBQhYWF6amnntLAgQM1cOBAp2L566+/1LBhQ5UqVcpeVqNGDRlj1LNnz4zb6WyqYsWKMsaod+/emVL/bsvq8WVFw4cPlzFGefLksSyGL7/8UpcvX1bfvn0ti8FqWeE4pEf+/Pnt79knTpxQ//799eabb6b42xs/fry6du2qokWLZuj2hw4dqp9//lkHDx7M0HZzslKlSunhhx/WO++8Y3UoAHDPITEFANlU4cKFtXbtWo0ZM0ZFixZV3rx5NWTIEPXv319fffWVU7dxnDlzRt7e3pkfLIDb9sUXX6hz587KnTu31aHgNuTPn18vvvii2rZtq7lz5+rYsWOSpI0bN2r79u16/PHHM3ybHTp0kJ+fn7744osMbzsn6927t/24AADuHhJTAJBN9erVS7Vq1UpRXrZsWUnS4cOH01x3xowZstlsOnv2rGbOnJnqLYPX36q2ceNG2Ww2zZ8/X998843Kli0rLy8v1apVS+vWrXNo+8KFCw7teHp6qnz58hozZoyuXr16W/t6/fY//fRTlSxZUl5eXqpXr55Wr16dov6ZM2c0cOBAFS1aVB4eHipVqpRefvllh/l5kpKS9M4776hSpUry8fFRYGCgevbsqX379tnr3Dgn0/z583X//fdL+q//k/fxjTfeSLV+euJJTx+n5nb2J9nhw4fVpUsX+fr6Kn/+/BoyZIjOnj0rm82msWPHZliMt9MX06ZNU7ly5eTm5qaNGzdKkpYtW6ZatWrJy8tLpUuX1pdffpnm9g4dOqTHH39cAQEB8vDwUNmyZfXOO+8oMTHRXid5fpvo6Gg9/fTTKlSokAoWLCgp9bmNkutfunRJ//vf/5Q/f375+vrqkUce0dmzZ1PE8Pvvv6tmzZoO8To7J9bevXt18OBBNW/ePMWy9MaxceNGtWnTRv7+/vL29tYDDzygmTNn2pf3799fr732miTJ19fXfn5v3bo1zfhmzZrl8PeeO3du1a9fXz///PNN9yuZs8fyZschteMmSfPmzVPDhg2VO3du5cqVS40aNdLKlStTtD1v3jw1adJE/v7+ypMnj1q3bq1NmzZJkry8vLR//34tWLDAvo8VK1Z0at9ulLxecmJq0aJFcnV1VePGjW+57s6dO2Wz2fTRRx+lWBYWFiYXFxeNHz/eXubp6akGDRpo4cKFt2z7l19+kc1m05YtWzR+/HgFBQXJxcVFx48flyRt2rRJ7du3V758+eTl5aVq1ao5nDeSNHLkSHv/uLi4KG/evHrooYdSfV/46quvVLFiRXl5eal69er6/fff04xt/vz5ql+/vnLlyqXcuXOrSZMm+uuvvxzq1KpVSy1btkyxbnJMyerVq6c5c+YoPDw81dvjJdn/zridDwDuLhJTAJDDLF68WDabTeXKlUuzzpNPPiljjPLnz68+ffo43CoYGhqa5nqzZ89WWFiY1q5dqwMHDih37tzq1KmTw3w0efLkcWgvIiJC48eP15QpU/TKK6/c0b7NnDlTJ06c0KZNm7Rv3z6VKFFCDz30kP1LpCRFR0frwQcf1PLlyzVnzhydO3dOH3/8sb7++mu1adPGnpAYP3683nnnHU2cOFFnzpzR1q1bFRwcrA8++CDN7Xfu3NnePz/++KN9H5MTU6lxNp5kzvRxam5nfyTp/PnzatKkiQ4dOqRVq1YpLCxMTZs21YsvvpjmOrcbY3r74ttvv9Xhw4e1Zs0arV69Wj4+Plq5cqXatWun++67T/v379e6deu0d+9ezZkzJ8X29u3bp9q1a+vkyZP6448/FBUVpY8//lgfffSRBg0alKL+//73PzVp0kT79+93+JKfluHDh6tx48Y6fPiwli5dqpCQEA0ZMsShzvLly9W+fXtVr15dBw4c0Pr163Xw4EGn5yhbu3atJKWahE5PHGvXrlWTJk3k7e2tbdu26ejRo3rkkUf01FNP2W9dmj59ut5++21JUkxMjP38vtm2e/fuba+XlJSk/fv3q1WrVurZs6dWrFhx031Lz7G8mdSO26RJkxQcHKzWrVtr//79Cg8PV7NmzdSqVSuHxMa7776r4OBg1a9fX6GhoQoPD9fw4cPtCaC4uDhVqFBBnTp1su/n9cne9Ni/f78kqXjx4pL+OyaVK1d2at6watWqqVatWvrqq69SLPv666/l4uKiPn36OJTXqVNHBw4cUEREhFPxvffee3J3d9fWrVs1f/58ubq66vfff1ejRo1UoEABbdmyRRERERo2bJgGDRrkkCSbMGGCvX+uXr2qLVu2qEiRImrTpo3CwsLs9aZOnar+/furZ8+eOnbsmObNm6evvvoq1QTt119/rS5duqhBgwY6ePCg9uzZo3Llyql169aaN2+eU/t0vY0bN6pHjx4KCgpK8zb6fPnyqUyZMlqzZk262wcA3AEDALgrTp06ZSSl+m/MmDHprpeaGTNmGEmmd+/eTsWUP39+06dPH4ey0NBQI8n8+OOP9rINGzYYSebhhx92qPv3338bSWbmzJm33NYbb7xhfH197a/37t1rJJnvvvvulusmb79FixYO5fHx8aZYsWKmefPm9rJ33nnHSDLr1q1zqPvdd9857FfLli1No0aNbrrd1GJMrX9uVt/ZeO60j293f95++21js9nM7t27HepO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8OHDio6O1l9//aVevXrZ26tSpYq2b9/ucOVSRujQoYMSExPT/QS4vn37as2aNfrggw/04IMPqmLFiinqxMfHa/369emeN+16rq6umjZtmtauXavHH39c//zzj65cuaIjR45o9uzZatKkiaT/EuGVK1fWxIkTtWvXLl28eFE//PCDFixY4HCbqYeHh9566y0tWbJEb7/9ts6dO6fDhw+rT58+ql27tsO2PTw8NH78eK1atUovv/yyTp06pePHj2vw4MHauXOn/dZwSRowYIDOnz+vkSNH6vz58zp48KD69u2rOnXqpNinKlWq6MyZM9q4cWOKhKgk+99Zhw4dbrvfAADpR2IKALKpb775RqdOnVJ4eLhKlizpMFmuzWZz+nH0Ge3JJ5/U+++/r08++USFChVSz549NWjQIDVr1ixD2i5QoIBq166tChUq6NChQ/rjjz9Uv359ex1/f3+tW7dOTZo0UXBwsPLly6enn35aTzzxhP744w/7LS/vvPOOtm3bpocfflj+/v5q1qyZAgICtHbtWvttganx9PTU9OnTtXXrVvvtSDeblN7ZeO7U7e5P3rx5tXr1apUsWVKNGzdWyZIltXz5co0bN86+vxklI/qiRYsWWrx4sXbu3KmyZcuqfv36Kl26tHr06JGibqVKlRQaGqqyZcuqR48eyps3r+rWraupU6dq6NChKlWqVIbt283iXbRokbZt26YyZcqofv36KlmypH2Cb2f6d/DgwZo/f75TidC0NG7cWKtWrVJ0dLT9Spg5c+boyy+/1GuvvWav16pVKz3//PMaMWKEfRLrm12tNW3aNHXs2FHdunVTQECAJk2apO+++07+/v63jCk9xzK9XnzxRS1evFibNm3SAw88oHz58qlLly7av3+/3nrrLXu9kSNH6qefflJISIiqVq2qkiVLauLEiRo2bJi9zltvvaXKlSurSpUqstlsqSaCbkfdunV1//3367vvvkvXen369JGbm1uak55L/11dGh0drYEDB95RjG3bttXGjRsVHx+v5s2bK0+ePPbjlpwccnFx0cKFC1WmTBk1atRIpUqV0tKlS/Xjjz+mmPR/6NCh+uKLL/Tdd9+pWLFi6tixo5544gnVqFEjxbYHDBigX375RWvWrFGZMmVUoUIF7d27V0uXLrU/MEGSateurenTp2v+/PkKCAhQjx499PTTT6d6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+61T693s+HTo0EFXrlzRwoULnY4jvUqUKKE+ffpo8uTJiouLsx+joKAgubq6avfu3Q71U3sKXbIlS5akuJpq4cKFKl++vP1qvzsZWyVfkZPMGJPiKc/t27eXMUY//vhjmu1k5li6WbNmio+P1/Llyx3KnR3P3en613Omv5o3b674+HgtW7bModzZxHZmnKN347tOTkNiCsimOnbsqNOnT+vtt99WTEyMDhw4oG7duqlWrVpOt/H2228rIiJCTz75pMLDw3X27Fm9/fbbqV72OnHiRCUlJaldu3Zau3atYmJidPr0aS1btkxdunTRrl27FBERoVatWmnhwoU6efKk4uPjtXHjRq1YsUJNmza1t5U8F9KSJUvu6PLwjNy/m+nYsaNmz56tP//8U5cuXdLq1as1ZMgQNW7c2KHeW2+9pTNnzuiJJ57QkSNHdObMGb3++uv2D6dbcaaP0ysj+nrcuHGKjY1V9+7d9e+//yoqKkofffSRvv76a7344ov2gdqd9ndasTp7XgEA0taxY0cdPHhQH3zwgWJiYrR371517dr1lkma640bN05hYWEaOHCgjh8/roiICL3yyivKly9firqTJ09WdHS02rdvr40bN+rSpUs6efKkli5dqvbt2yssLExhYWFq166dfvvtN506dUpxcXEKCQnR2rVr1axZM3tbyZ8PixcvduqzbMKECTpz5oweffRRhYWF6dy5c5owYYLmzJmjV199NcUVUzcKCAjQsGHDNG3aNH3++ec6f/689uzZo/79+6tBgwYOdfPly6ePPvpIM2fO1IsvvqiDBw8qLi5O//77r6ZPn66uXbs607V3JCAgQM8995ymTp2qL7/8UhcuXNDevXs1YMAA1a9f/5br161bV4UKFdIbb7yhsLAwRUZGasqUKTp+/LhT2/f29lbp0qW1atWqFLczdenSRV27dtWAAQP09ddf69SpU4qNjdX27ds1evRovfPOO7ezy+rfv78++ugj7du3T3FxcTp27Ji+/vpreXt7q27dupL+S5A+9thj+vLLL7Vy5UrFxMRo4cKFWrFiRZpjs7Jly2rIkCE6fvy4Tp06peeee06hoaGaMGGCU/t7Mx07dtTly5c1cuRIXbhwQWFhYerdu7cqVarkUK9Lly7q0KGDhg0bpq+++kpnzpxRRESEZs2apVdfffWOYnBGr169VK1aNQ0cOFAhISGKiYnRsmXLtGjRIvtVaJm5fjJn+6tnz56qXr26Bg0apNWrVysmJkZLly7VkiVLnNpeZpyjmXl8cqy7OdM6AOckPzEirX/JT36ZMmWKKVOmjPHy8jL33Xef+fHHH+1Pwkh+4kvyU/lufNJEsuXLl5vatWsbT09PU6JECTN58mT700A2b97sUPfkyZPm6aefNiVLljTu7u6maNGipm3btmb+/Pn2J/T89ddfpkuXLqZo0aLG29vblCtXzowcOdL+5Ixkr7zyiilSpIhxcXExkszKlSvT1UeDBg0yNWvWvGU9Z/cv+Ul358+fT9HGxYsXzYABA0zhwoWNj4+PadGihdm9e7d55JFHHJ62Y4wxK1asMHXq1DGenp4mMDDQfPDBB2bRokVOPZXOGOf6OK1Y03r6THr6Oq24tmzZYh5++GHj5+dnPDw8TJUqVcxnn32WYn1n+zut7aQVq7PnFQDci5KfypfWv379+hljjHn//fdNqVKljJeXl6lWrZqZN2+emTJlisP78c2eZGaMMb/99pu5//77jYeHhylVqpT54osvHJ60d73w8HAzYMAAU6JECePu7m6KFy9uOnToYH777TeTlJRkkpKSzNKlS02HDh1MkSJFTK5cuUyFChXMa6+9ZmJjYx3aev75503hwoWNzWZzeFJtWtatW2datWplfH19jaenp6lRo4b56quvHOokP2Eu+UnG10tMTDTjxo0zJUqUMJ6enqZOnTpmw4YN5rHHHkv1KXcrVqwwDz/8sMmXL5/x9PQ0FSpUMIMGDXJ4Al/16tVN69atU403rafypVb/ueeeM56eniniHTt2rAkMDDSenp6mXr16ZvPmzSY4ONiULl067Y76/7Zu3WoaN25sfHx8TKFChcyzzz5rH4+++uqr9nqpPZXPmP8+/6tVq2Y8PDwczrnk2D755BNTq1YtkytXLuPn52dq1qxpxo8fb6Kioowx//fEs+ufrnwz4eHhZvjw4aZSpUrGy8vLFClSxHTt2tVs3brVoV5UVJTp2bOn8fX1Nf7+/qZv374mNjY2zafyTZkyxcyaNcuUK1fOPt755ZdfUmw/rf291X58/fXXpkKFCsbLy8tUqFDBfPXVV2bevHlGktmwYYO93rVr18y7775rKleubH8SZJ8+fczRo0dvGcPNnsqXWlxlypQxjzzyiENZRESEeeyxx4y/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27bp3Xff1YsvvqgPPvhAGzZssDqkm/rmm2+0d+9eq8MAAAD3uGydmEqvKVOmqGLFilaHAQAAcpC4uDj16tVLzZo105EjR1S0aFGdOnVKI0aM0COPPGJ1eAAAAFmam9UBfPLJJypRooTy5Mmj33//XQEBAfrf//4nSfr777+1aNEiXb16VQ888IAeeeQR2Wy2VNuZNGmSypQpo86dOzuUv/baa2rWrJmaN2+uffv26dKlS5Kkq1ev6oUXXtDzzz+vLVu2aOfOnSpUqJCeeuop+fv729dPSkrSnDlztG3bNgUFBal79+5699131bdvX913332Z0ykAACDbGDZsmJYtW6YdO3aoZMmSDst27drl8Hrz5s1atGiR4uLi9OCDD6pTp04OY5vXXntN7du3V0REhDZv3iwfHx899dRTCggIcGjn4MGD+vnnn3XhwgVVqlRJvXr1kqenZ4p2oqKitGHDBuXKlUu9e/dW8eLFU8S/dOnSNOtMnjxZ//77r2w2mwICAtSiRQvVq1cvRRt//PGHli9froCAAHXs2FHz5s3TAw88oBYtWmRKzAAAIOew/Iqp+fPn69lnn9WLL74oPz8/BQYGSpImTpyotm3b6sqVK/L19dXbb7+t9u3byxiTajvXrl3TyJEjHcq2bdumsWPH2gc019/Kd/XqVX366adq1aqVlixZIn9/f/3www9q2LChEhIS7G0MGDBA//vf/+Tl5aV///1XdevW1ccff6zDhw9nRncAAIBs5MyZM5o+fbpGjhyZIiklSVWrVrX/f9q0aWrUqJEuXLggb29vDR48WL1793ao/80336h79+6aOHGicuXKpWXLlqlGjRo6efKkvc68efNUu3ZtHT9+XPnz59e3336rWrVq2X98S26nZ8+e+uSTT+Tj46PVq1erRo0aioyMdNjem2++edM6JUqUUMWKFVW+fHlFRUWpbdu2+uyzzxzaeP/999W5c2clJCQoMjJSLVq00IQJExQaGpopMQMAgJzFZtLK9NwlLVu21KFDh7R//355eHhIkg4cOKAqVapo586d9lvvYmJiVLZsWX3xxRf2q6JsNpv+/PNPtWzZUqdPn1ZgYKBWrVqlBx98UNJ/k53v2LFDISEhKerHxsbK19dXL7/8st59911J0rlz5xQQEKDly5erSZMm2rlzp2rUqKH169fbfx38/PPPNWTIEC1atEjt27dPsT/x8fGKj4+3v46OjlZgYKAW5a0nH5vlF6gBAJDhmkWGWB2C06Kjo+Xv76+LFy/Kz8/vjttbvHixOnTooI0bN6pu3bpp1rtw4YJKliyp8ePHa8iQIZJkH2esWLFCTZs2lSQVL15cBQoU0NatW+Xm5qbExETVrVtXderU0WeffabY2FgFBgbq22+/VYcOHST9d3V3nTp1FBwcbP+Rrnjx4qpZs6YWLFggSUpISFBQUJBef/11DRo0yOk6N5o/f76eeuopRUVFyWazKSoqSkFBQfrkk0/Up08fSdKqVavUrFkzvf/++xo+fHiGxnyjtMZdpR8Pk6uH780OHQBY6sD0AlaHAGSq9Iy5skSm5KGHHrInpaT/Bnne3t76/PPP7VdIGWPk7u6ubdu2pbhdT5ICAgLUvn17ff3113rwwQcVHx+vH374QZMmTbrptlu3bm3/f4ECBVSwYEH7VVWrV69WUFCQwyXrvXr1sg8oUzN+/Hi9+eabTu03AADI3i5evChJKly48E3r/f3337p48aLDFVLVqlVLkZiSpG7dusnN7b8hmqurq3r27KmvvvpKkrRmzRpduHBBy5Yt0/Lly2WMkTFG8fHx2rZtm8M2rx/juLm5qXz58ikeAnOrOgkJCZo3b5527dql8+fPKzo6WhcuXNCpU6dUtGhR/f3337p06ZJ69OhhX6dp06YOtx5mdMzXY9wFAED2lyUSU3nz5nV4ffbsWfn6+qps2bIO5SNHjlSNGjXSbKd///7q2bOnPv74Yy1atEjXrl1T9+7db7rtXLlyObx2dXW138p35swZFSjgmMn29/eXu7t7mu2NGjVKL7zwgv118i93AAAg5ylYsKAk6fjx46neypfs9OnT8vLykq+v41U8hQsX1unTpx3Kbhx7FCxY0F7n7NmzcnNzU4UKFRzqlC9fXkFBQQ5lNxvjOFMnISFBzZo1U1RUlLp06aKyZcsqOjpa0n/jm6JFi+rMmTPy8fGRl5dXmvuQ0TFfj3EXAADZX5ZITN2oSJEiunjxogYNGnTTJNCN2rRpozx58uinn37Sjz/+qO7du8vHx+e24yhWrJhOnDjhUBYREaFr166luY6np6fDRJ4AACDnqlevnnLlyqUlS5aoYcOGadYLDAxUXFyczp0755C0OXr0qBo0aOBQ9/jx4w6vjx07Zk+2FClSRAkJCWrXrp1KlSqVgXuS0t9//63169crMjJSefLkkSStX79er7/+ur1OsWLFFBsbq+joaPtl+sYYhzmxMjNmxl0AAGR/lk9+npquXbsqISFBY8eOdSj/559/FBYWluZ6rq6ueuqpp/T+++9r+fLl6tev3x3F0aZNG507d84+14EkTZ069Y7aBAAAOYefn59eeuklTZ48WevWrXNYdu3aNfsYonbt2ipWrJg+/vhj+/I///xT+/bts8+7lGzWrFn2K5NiYmI0c+ZMdezYUZLUuHFjFStWTK+++qoSExPt65w4ccJhsvGMkNx+8hxOSUlJ+uCDDxzq1KlTR4ULF3YYH82ZM0dRUVH213czZgAAkP1kySumihcvrh9++EF9+vTR77//rsqVKyssLEzR0dH69ddfb7puv379NG7cOJUrVy7FL5DpVbJkSb3++uvq0aOHOnbsqJiYGJ05c0bu7u5yccmSOT0AAHCXvf7660pMTFSrVq1Ut25dlStXTqdPn9bu3bv1yCOPqFOnTvL29tYXX3yh7t27a+PGjcqbN68WL16s0aNHp5imoFChQnrggQfUoEEDrV+/Xt7e3ho+fLgkycvLS7/++qu6dOmiqlWrqm7dujpx4oTCw8P1zTffZOh+1a1bVw0aNFC9evXUrFkzbdu2TTabzaFOrly59NFHH+mJJ57QmjVr5O3trR07diggIMA+VrqbMQMAgOzH8sTUs88+q0KFCqUo79Kli5o1a6YVK1bo/Pnz6tevnxo0aOCQEJoyZYr9qX3JSpQooUKFCqlv374p2ry+vqenp6ZMmZLikvKxY8c6THY+evRotW3bVqGhoQoKClKNGjVUqFAh5c+f/472GwAA5AwuLi56++23NWzYMIWEhOjs2bMqWrSoatas6TAJeLt27RQWFqa//vpLcXFxGjNmjCpXrpyivSFDhqhu3br6+++/1blzZz388MMOczjVqVNHhw4d0ooVK3TixAmVKlVKDRs2dKhz43hGSjnmulUdV1dXrVy5Ur///rtOnjyp3r17q3bt2po5c6bDfvXs2VM1a9bUmjVrFBAQoEaNGqlixYoOY6WMihkAAOQ8NpP82LscYunSperatavCw8MzZCCzbds23X///fZfCCdMmKB3331Xp06dSjHRZ2qSH5G4KG89+dgszwMCAJDhmkWGWB2C09Lz6GIrFC9eXGPHjtWTTz5pdShO27Vrl8qXL2+f62n+/Pnq2rWrDh06lOnzYN0o+fiWfjxMrh6+t14BACxyYHqBW1cCsrH0jLlyTKbk6NGjeueddzR//nw9//zzGfbr2ubNm/XEE0+oVq1aCg8P19atW/X11187lZQCAADI6U6dOqVHHnlEdevW1fnz5/XXX3/p3XffvetJKQAAkD3lmMSUt7e3qlatqo4dO6pt27YZ1u7gwYPVsmVLbdy4Ublz51a9evUcLl8HAADIKKndzpbVPfTQQ1q1apVCQkJks9n08ccfq3Tp0laHBQAAsokcdytfVsOtfACAnI5b+ZBVcCsfgOyCW/mQ06VnzMWj5QAAAAAAAGAJElMAAAAAAACwBIkpAAAAAAAAWILEFAAAAAAAACxBYgoAAAAAAACWIDEFAAAAAAAAS5CYAgAAAAAAgCVITAEAAAAAAMASJKYAAAAAAABgCRJTAAAAAAAAsASJKQAAAAAAAFjCzeoA7hWNj/whPz8/q8MAAADI8UI/yc+4CwCAbIIrpgAAAAAAAGAJElMAAAAAAACwBIkpAAAAAAAAWILEFAAAAAAAACxBYgoAAAAAAACWIDEFAAAAAAAAS5CYAgAAAAAAgCVITAEAAAAAAMASJKYAAAAAAABgCTerA7hXrCnZWj42uhsAkD00iwyxOgTgtt3/TKRcPa5aHQaAbO7A9AJWhwDcE7hiCgAAAAAAAJYgMQUAAAAAAABLkJgCAAAAAACAJUhMAQAAAAAAwBIkpgAAAAAAAGAJElMAAAAAAACwBIkpAAAAAAAAWILEFAAAAAAAACxBYgoAAAAAAACWIDEFAAAAAAAAS5CYAgAAAAAAgCVITAEAAAAAAMASJKYAAAAAAABgCRJTAAAAAAAAsMRdT0xFRUVpwIABqlGjhpo2baoLFy6oYsWK2rdvX7raqVixojZs2HDTOjVr1tSqVavStQ4AAMC95OLFizp37pyuXbuWYtnly5d17tw5Xbp0yYLIAADAveCuJ6befPNN7d27VzNmzNBXX32lhIQE7d+/X3FxcelqZ//+/bccJO3fv1+xsbHpWgcAAOBe0rp1axUsWFDjxo1Lsaxz584qWLCgXn31VQsiAwAA94K7npjavn27HnroIdWoUUNlypRR3rx5tXfvXlWqVCnTt7137141aNAg07cDAACQnZQrV07ffPONkpKS7GXh4eEKCQlRUFCQQ93kK6wiIyMd6l/v/Pnzio+PlyRdunQpxQ+Dly9fTjOWpKSkmy4HAAA5y11NTFWpUkWbN2/WlClTVLFiRVWsWFH33XefOnfurMOHD9vrXbt2Te+9954aNmyoOnXqaPDgwTp16tRN2z537pwGDBig+++/X507d9by5ctT1OncubN27Ngh6b8BUcWKFbVkyRL1799fderUUfv27bVp0yaHdSIiItSvXz+Hdm+8RRAAACA7a926tRISEhzGT1999ZXat2+vAgUKONR94oknVLFiRZUvX14+Pj5q27atjh496lCnatWqGjFihGrUqKGAgACNGTNGkjRx4kQVKFBAhQoVUtGiRTV58mQZYyRJsbGx6tu3r3x9fVWwYEGVLFlSs2bNyuQ9BwAAVruriam5c+eqQoUKeuyxxzR//nzNnz9fM2bMSHErX7du3bRkyRKNGTNGn376qSSpXr16Drfl3ahjx47as2ePPvroI/Xr108DBgxI8Wvb9bfyJSUlaf/+/erbt68aNWqkqVOnKigoSG3atNH58+clScYYtW/fXvv377e3O3DgQIWGht40FgAAgOzE1dVVTz75pL766itJ/42TZsyYoX79+qWou2DBAvsVU6dPn1aePHnUt2/fFPVmzpypTz/9VDExMZo4caK2bdumUaNG6bffflNsbKx27Niho0eP6sqVK5Kknj176vz58zpy5IguXbqkGTNmaPDgwQoJCcncnQcAAJZyu5sbq1Chgry8vFSgQAFVrFhR0n9XOl1v9erVWrZsmSIiIuTr6ytJqlWrlipWrKg5c+akOkD6888/tWXLFh05ckTFihWTJLm7u6tt27a3jOn1119Xnz59JEnVqlXTV199pY0bN6pt27ZatmyZduzYoSNHjqho0aKSJC8vLz300ENpthcfH2+/dF2SoqOjbxkDAACA1fr27asqVaooKipKmzZtkjFGDz30kEaPHp1q/YSEBCUkJOiZZ55Ro0aNdOXKFXl7e9uXDxgwQA8++KD9dUREhHLlyqXKlStLkgoWLKhJkyZJkv755x8tWbJE+/btk7u7u86fP69q1aqpXbt2+vHHH9WoUaNUY2DcBQBA9ndXE1POCAkJUVJSkurXr2+/tNsYoxMnTujAgQOprhMaGqpy5crZk1KS1LRpU6e2V716dfv/3d3dVaBAAZ05c0aStGPHDpUtW9ae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LFhQknT69Gnlzp1buXPndqhz5swZeXp6yt/f36G+MUYnTpxQoUKF5OHhoYsXL8rX11cuLinvbjTGKCoqSvny5ZPNZtPJkyeVL18+BnAAACDbadasmaZPn56ifOHChapatao8PDy0detWFStWTLVq1dKKFSt05swZ2Ww2BQQEqEaNGvL39091XR8fH23fvl25cuVSvXr15ObmONRMSEjQ5s2bFRkZqVq1aqlIkSIOy69du6aNGzfqwoULqlixosqVK5fxHQAAALKUO5pjqmfPnhowYIAaNmyooKAgDRkyRJIUHh6uhx56SPnz51dgYKDKli2r33//3WHdI0eOqE2bNvLz81NgYKAaNmyof//9177cmTYCAwMVEhIiSXrmmWc0YMAAh+WXLl1S6dKl7etdX//SpUsKDAzUW2+9pVKlSqlkyZLy9fXVxx9/7NDGmjVrVKpUKQUGBip//vwaPXq0ypUrp7/++utOug4AACBLefrppzVw4EA9+OCD+uabb7Rjxw5J0tq1azV//nz9+uuvevnll1WmTBmtWLEixbqDBg1S/fr1NWXKFPXs2VMNGzbU1atX7XV27dqlihUr6rHHHtPnn3+uBg0a6KuvvrIv/+eff1SxYkU9++yz+vLLL/Xggw/qqaeekjHm7nQAAACwxB3fyjd37lwtWLBA7dq1kyTFx8erVatW6ty5sxYtWiRPT0/99NNPCg4O1s6dO1W6dGnFxcWpRYsWqlKlik6fPq38+fNr/fr12rBhg8qVK+dUGzd67LHH1Lt3b8XGxtqvmpo/f75cXFzUsWPHNONftGiRQkJCVLx4cf3000969NFH1aFDB5UqVUqXL19W9+7d1aNHD02aNElXrlxRcHCwLl++nGZ78fHxDhOsR0dH327XAgAAZLiwsDDNnj3b/jooKEj169eXJB07dkw7duxwuCLq9ddfd1j/gw8+0KBBgxx+UJSks2fPaufOnfLx8VFUVJRKlSqluXPnqlevXkpISFDnzp1Vt25dzZw5U+7u7rp69ao9wZWYmKiuXbtqwIABGjlypCQpKipK999/v2bOnKknn3wy1X1h3AUAQPZ3x0/la9++vT0pJUkLFizQ2bNnNWzYMEVFRenUqVNq2LChypUrp0WLFkmSfv31V504cUJff/218ufPL0lq0KCBnnjiCafbuFG7du3k6empefPm2cu+//57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kqlUqZLx8/NL8bfOuZm6L774wlStWtXkyZPHFCtWLNU627ZtM0FBQaZYsWImICDAlClTxuzatSvdde5lJKYyyejRo03RokXN8ePHjTHGLFq0yEgy69atsziyjPHWW2+ZY8eO2V9/8803xsXFxWzbts3CqDLemjVrTOnSpU3Pnj1zVGIqNjbWlClTxnTt2tVcvnzZGGPMqVOnzC+//GJxZBnjhx9+MK6urvY3+8TERPPEE0+YoKAgawO7TbNmzTI//vijOXHiRJqJqTfeeMMEBASYo0ePGmOM+e2334zNZjOrV6++2+Gm2+LFi8306dPNpUuXjKenZ6qJqbFjx5rw8HD76++++87YbDazZcuWuxnqbVmzZo35+OOPzYULF0xQUNBNE1Pr1683JUuWNI8++mi2SUzt3r3bjB8/3kRERJgHH3ww1cTU5cuXTbly5UynTp3s7zkRERHm559/vtvhIgeaNWuW8fb2Nn///bcxxpgdO3aYXLly5dgfzDLSO++8Yw4fPmx//dNPPxmbzWbWrl1rL6N/0+fatWumQYMGZsSIESkSU9euXTPlypUzjz32mElKSjJJSUmmZ8+eply5ciYhIcHCqLOew4cPG19fXzNixAiTmJhojDHmn3/+cfhBY8aMGSZXrlz25Mm2bduMt7e3mTlzpiUxZ1Xh4eEpfvj7/PPPjc1ms4+tODfT9tJLL5kdO3aY8ePHp5qYunr1qildurTp06ePMcaYpKQk061bN1OxYkX7uetMnXsdialMUrRoUTN69GiHsho1aph+/fpZFFHm8/b2Np9++qnVYWSYyMhIExQUZFavXm0GDRqUoxJTn376qfHw8DBnz561OpRMMXnyZJM3b16HsqlTpxofH59s/Qvv2bNn00xMlShRwowcOdKhrFatWvYPwOwircRUanLnzm0mT56cyRFlrJslps6fP29KlSplVqxYYYYOHZptElPXSysx9cUXXxh3d3cTERFhQVTI6Zo3b26Cg4Mdynr27GkefPBBiyLK3goUKGDeffdd+2v6N31GjRplgoODzd69e1MkplasWGEkmX379tnLdu/ebSRl26u6M8uQIUNMqVKlbpoUady4senZs6dDWXBwsGnSpEkmR5e9/P3330aS2b17t71s165dRpL9ogLOzVtLKzG1bNkyI8kcPHjQXrZ9+3YjyYSEhDhd517HHFOZ4MyZMzp58qRq1qzpUF6nTh2FhoZaFFXm2r17t65cuaKyZctaHUqG6du3rx599FE1btzY6lAy3PLly1W/fn3ly5dPO3fu1IEDB3LEHEzJHnvsMRUvXlwDBgzQn3/+qVmzZum9997T+PHjZbPZrA4vw0VFReno0aP31HvO3r17FRsbm6Pec/r376/g4GA1a9bM6lAy3PLly1WnTh0VLFhQu3btynHvObBWaGjoPfX+l5nCwsIUFRXl8N5K/zpv+fLl+v777/XFF1+kujw0NFQ+Pj6qUKGCvey+++5Trly56M8bLF++XA8//LASEhK0bds2hYeHp5hjinPTOQ888IB69OihoUOHavHixVq0aJGefvppPfroo7r//vslcW7eidDQUPn7+6tMmTL2surVq8vDw8Ped87Uude5WR1AThQVFSVJyp8/v0N5/vz57ctykitXruipp55S/fr11bJlS6vDyRBTpkzRiRMn9PPPP1sdSqY4efKkfHx8VLNmTSUkJOjChQtycXHRzJkz1bRpU6vDu2P58+fXM888o1GjRmnLli06e/asKlasqPbt21sdWqa4195z4uLi9NRTT6lOnTpq3bq11eFkiKlTpyosLEw//PCD1aFkipMnT8rDw0O1atXS1atX7Q8EmTFjhpo3b25xdMjOjDG6cOFCqu9/ly9fVnx8vDw9PS2KLnu5du2annzySVWrVk0dOnSQRP+mx9mzZ/XEE0/ou+++U758+VKdiDsqKipFX0o59/P6Tpw8eVKRkZGqVKmS/P39dfz4cQUGBmr27NkqX768EhISFBMTk+q5GR0drcTERLm6uloUfdYzePBgDRgwQMOHD5f03yTcb7/9tn055+btc6bv6N9b44qpTODu7i5JKZ5scuXKFXl4eFgRUqa5evWqgoODdeHCBc2dO1cuLtn/lDp27JhefvllDRkyRJs2bdLatWt1+vRpxcTEaO3atYqOjrY6xDvm7u6u5cuXa9y4cdq1a5fCw8P18MMPq3v37jd9Ik928dVXX+mFF17Q6tWrtX37dh07dkwVKlRQs2bNcsT+3ehees+5du2aunfvrrNnz+rXX3/NEYPOEydO6MUXX9SQIUO0efNmrV27VqdOnVJsbKzWrl2bI57q6u7urpUrV2rMmDH295xOnTqpe/fuaT4ZCHCGzWaTm5tbqu9/0v+9P+LmEhMT9eijjyo8PFzz58+39xv967znnntO1apVk4eHh9auXatt27ZJ+u9JXvv27ZP0X3+lNg7JiZ/Xd8rd3V2LFy/WkiVLFBoaqqNHjypfvnzq06ePJMnV1VUuLi6pnpsuLi45YnyQUf755x+1atVKY8aM0b59+7Rv3z69/vrratWqlfbs2SOJc/NOONN39O+tZf8sQhZUrFgxubq66sSJEw7lJ06cUIkSJSyKKuMlJ6X279+vlStXqkiRIlaHlCEuX76smjVr6uuvv9bIkSM1cuRIbdq0SceOHdPIkSMVHh5udYh3rGTJkipRooQefvhhSZKLi4sGDhyos2fP2gdP2dnixYvVpEkTValSRdJ/+zdkyBCFh4dr586dFkeX8YoWLSp3d/cc/55z7do19ejRQ7t379bKlStVrFgxq0PKEFeuXNEDDzygmTNn2t9zNmzYoBMnTmjkyJEKCwuzOsQ7VrJkSRUtWlQdO3aU9N+X3YEDByoyMtI+KAZuV4kSJVJ9/ytevHiO+MEssyUmJuqxxx7Tpk2btHLlSgUFBTksp3+dkydPHsXExNjfxydNmiRJ+vTTT/X9999LkoKCghQZGenwBfXKlSs6f/58jvq8zgglS5ZU48aNValSJUmSt7e3+vTpo82bNys+Pl42m02BgYE5fuyTEX7//XflypVLvXv3tpc9/vjj8vb21u+//y6Jc/NOBAUF6dy5c7p69aq97NKlS7p48aK975ypc6/j0yQTeHl5qVGjRlq4cKG97MqVK/rzzz/VqlUrCyPLONeuXVO3bt20Z88erVq1SsWLF7c6pAxToUIFrV271uFfp06dVLlyZa1du1ZVq1a1OsQ71rp1a50/f97+i6ckHT9+XJJUsGBBq8LKMAULFkwxUDl27Jh9WU7j7u6uJk2aOLznxMXF6Y8//sgx7zkJCQnq2bOntm/frlWrVuWoD/GyZcumeM/p2rWr/b0oef6H7Kx169a6cOGCLl26ZC/LSe85sFarVq20ePFi+/wzxhgtXLgwx7z/ZabExET17t1b69ev16pVq1S6dOkUdehf53z22WcO7+PJyagvvvjCfstUixYtZIzRb7/9Zl8vuW+5rdlR69atU4zljh8/Lj8/P/vto6mdm4sWLeLcvEHBggV16dIlnT9/3l4WFRWlS5cu2T+DOTdvX4sWLXTt2jV7kk+SFi5cKBcXF3vfOVPnXsccU5lk7NixatasmV5++WU1bNhQn376qfLkyaPBgwdbHVqGeOyxx7R8+XJNnz5dR44c0ZEjRyT996taTvrCmFP16NFDH3/8sR555BENHTpUUVFReu211/TYY4/liKtQBg8erBkzZqhv377q3r27IiIi9Prrr6tdu3YqVaqU1eGl27lz57Rv3z77LV3//POPcufOrcDAQPsv22+//baaNGmi4cOHq3Hjxvr888+VO3duPf3001aG7pTo6Gj7lWzGGB08eFBr165VoUKFVL58eUnSE088oaVLl2r69Ok6evSojh49KkkOfZBVxcXFaevWrZKk+Ph4HT16VGvXrlWePHnsV/VlZ8YYrVu3TtJ/x/L06dNau3atcuXKpQceeECSFBwcrMmTJ6tr16763//+pwsXLmj06NHq0aNHlj9+yPpGjBihOXPm6PHHH1ePHj00d+5cHT58WL/88ovVoWV5AwYM0Lx58zRt2jSdPHlSJ0+elPTf1f/Jn5f0b8YJDAzU0KFDNWTIEMXGxkqShg8frmeeeUaBgYEWR5e1vPDCC/ruu+80cOBAPfLII/r33381YcIEjRo1yl5n1KhRqlmzpp588kkFBwfr559/1vHjxzVy5EgLI896OnXqpOLFi6tjx44aPny4jDGaNGmSvUzi3LyZ3bt368KFCwoPD9fVq1e1du1aSVKtWrXk5eWlUqVKafDgwRo0aJAuXryoxMREDR8+XM8995z9jiJn6tzrbObGxxsgw2zYsEGffPKJTp8+rSpVqmjkyJE55sR76KGHUp0XpE+fPhowYIAFEWWuiRMn6t9//03zKSvZUUxMjCZOnKj169fL399fDz30kPr165dj7sn/559/9Omnn+rQoUPy9fVVkyZNNHDgwGw5Sevy5cs1ZsyYFOW9e/d2SHZv2rRJU6ZM0alTp3TfffdpxIgR2SLRuGvXLg0ZMiRFeevWrfXaa69Jktq2bauYmJgUdW7sg6zoxIkT6tGjR4ry2rVr68MPP0x1nQ8//FD//POPpk+fntnh3bGrV6+m+mtfUFCQ/YoBSYqNjdWkSZO0bt06+fn5qWXLlurfv7/c3PiNDHfuwIEDev/993Xo0CGVKlVKw4cPt98ChLR16tRJkZGRKcq7d++u//3vf/bX9G/6HT16VI8++qimTZumypUr28uTkpL0+eefa/HixZKk9u3ba/Dg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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "def clean_token(token):\n", - " return token.replace(\"\\n\", \"\\\\n\")\n", - "\n", - "\n", - "token_ids = primary_result.prompt_token_ids.tolist()\n", - "position_tokens = [\n", - " clean_token(model.tokenizer.decode([token_id])) for token_id in token_ids\n", - "]\n", - "\n", - "layer_position_rows = []\n", - "for layer_result in primary_result.layers:\n", - " ff1_top = layer_result.input_projection.top_tokens(model.tokenizer, k=3)\n", - " ff2_bottom = layer_result.output_projection.bottom_tokens(model.tokenizer, k=3)\n", - " for position, prompt_token in enumerate(position_tokens):\n", - " layer_position_rows.append(\n", - " {\n", - " \"layer\": layer_result.layer,\n", - " \"position\": position,\n", - " \"prompt token\": prompt_token,\n", - " \"FF1 top raw directions\": \", \".join(\n", - " map(clean_token, ff1_top[position])\n", - " ),\n", - " \"FF2 bottom raw (update ordering)\": \", \".join(\n", - " map(clean_token, ff2_bottom[position])\n", - " ),\n", - " }\n", - " )\n", - "display(pd.DataFrame(layer_position_rows))\n", - "\n", - "final_position = -1\n", - "norm_comparison_rows = []\n", - "for layer_result in primary_result.layers:\n", - " ff2 = layer_result.output_projection\n", - " raw_bottom = ff2.bottom_tokens(model.tokenizer, k=5)[final_position]\n", - " normalized_bottom = ff2.bottom_tokens(\n", - " model.tokenizer, k=5, normalized=True\n", - " )[final_position]\n", - " norm_comparison_rows.append(\n", - " {\n", - " \"layer\": layer_result.layer,\n", - " \"VJP norm\": float(ff2.factor_norms[final_position]),\n", - " \"raw FF2 bottom tokens\": \", \".join(map(clean_token, raw_bottom)),\n", - " \"normalized FF2 bottom tokens\": \", \".join(\n", - " map(clean_token, normalized_bottom)\n", - " ),\n", - " }\n", - " )\n", - "norm_comparison = pd.DataFrame(norm_comparison_rows)\n", - "display(\n", - " norm_comparison.style.set_uuid(\"ff2-norm-comparison\")\n", - " .background_gradient(cmap=\"viridis\", subset=[\"VJP norm\"])\n", - " .format({\"VJP norm\": \"{:.3e}\"})\n", - ")\n", - "\n", - "last = primary_result.layer(DETAILED_LAYERS[-1]).output_projection\n", - "raw_top = last.top(k=5)\n", - "raw_bottom = last.bottom(k=5)\n", - "top_ids = raw_top.indices[final_position].tolist()\n", - "bottom_ids = raw_bottom.indices[final_position].tolist()\n", - "top_tokens = [clean_token(model.tokenizer.decode([token_id])) for token_id in top_ids]\n", - "bottom_tokens = [\n", - " clean_token(model.tokenizer.decode([token_id])) for token_id in bottom_ids\n", - "]\n", - "\n", - "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n", - "axes[0].barh(top_tokens, raw_top.values[final_position].numpy(), color=\"#be123c\")\n", - "axes[0].invert_yaxis()\n", - "axes[0].set(\n", - " xlabel=\"projected raw-gradient logit\",\n", - " title=\"Largest +gradient logits\",\n", - ")\n", - "axes[1].barh(\n", - " bottom_tokens, -raw_bottom.values[final_position].numpy(), color=\"#2563eb\"\n", - ")\n", - "axes[1].invert_yaxis()\n", - "axes[1].set(\n", - " xlabel=\"negative projected raw-gradient logit\",\n", - " title=\"Largest ordering after subtracting gradient\",\n", - ")\n", - "fig.suptitle(\"FF2 final-position sign ordering (not a direct P(-v) readout)\")\n", - "fig.tight_layout()\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "id": "2bc454cb", - "metadata": {}, - "source": [ - "## Reconstruction error and compact gradient rank\n", - "\n", - "The gradient rank is estimated from the small factor core rather than by taking an expensive SVD of the full MLP matrix. The estimate uses a relative singular-value threshold of $10^{-5}$ and is bounded by the number of prompt positions." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "323e645c", - "metadata": { - "execution": { - "iopub.execute_input": "2026-09-02T13:05:35.035652Z", - "iopub.status.busy": "2026-09-02T13:05:35.035444Z", - "iopub.status.idle": "2026-09-02T13:05:35.408876Z", - "shell.execute_reply": "2026-09-02T13:05:35.408222Z" - } - }, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABKYAAAGGCAYAAABBiol3AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAfvNJREFUeJzt3Xd8FHX+x/H3biqBdEoSCCR0QhBQQLoVEaUoooKiIGANonIWrIjl0LMfl9OzIYrKqahgbyACgoCAAqFDaElIQjohdef3B7/ssSZAEnYyKa/n45GH2e/Mzrz3Q8xOPjvzHZthGIYAAAAAAACAGma3OgAAAAAAAAAaJhpTAAAAAAAAsASNKQAAAAAAAFiCxhQAAAAAAAAsQWMKAAAAAAAAlqAxBQAAAAAAAEvQmAIAAAAAAIAlaEwBAAAAAADAEjSmAAAAAAAAYAkaUwDc6t5775Wnp6fL2NixY9W+fXuLEqG2Kikpkc1m0+OPP251FAAAUEusXr1aNptNn3/+uXPsk08+kc1m08aNGy3LVRu8+eabstls2rVrl9VRALeiMQXUE6tWrdL48eMVFRUlX19fhYSE6KyzztJNN92kpUuXyjAMqyOa7oorrlDnzp2tjlEr5OXlyWaz6amnniIHAKDBOHTokO69917FxsaqSZMmCggIUGxsrKZMmaK1a9daHa9GzZ8/XzabTZs3b7Y6Sp1BzQBr0JgC6oFHH31UAwYMkL+/vz777DNlZGQoMTFRr7zyijIzM3XhhRfqjz/+sCzfggUL+GQH5Xh6esowDM6YAgC4xU8//aSuXbvq119/1QsvvKCkpCQlJyfr1VdfVUpKivr06aOSkhKrY6IaxowZI8Mw1KNHD6ujADCB5+lXAVCbvfPOO3rqqaf04osv6p577nFZdsEFF+iCCy7Q22+/LS8vL4sSAgAAmOvgwYO66qqrdNZZZ+nHH3+Ut7e3c9mgQYM0aNAgPfnkk7LZbBamBABUhDOmgDru8ccfV3R0tO6+++6TrjNp0iR17drV+fjSSy9Vjx49dPDgQV1xxRUKDAzUpZdeKkm6++67ZbPZZLPZZLfbFRISomHDhum3334rt93XXntNHTp0kK+vr3r27Kmffvqpwv2fbI6plStXatiwYQoODpavr6969OihDz74wGWdsqwpKSm68sor5e/vr2bNmumuu+5ScXGxc70ePXpo0aJF2r59uzP/6Q4+T1UHh8Ohl19+WWeddZZ8fX0VFBSkK6+8Utu3b3fZhsPh0D//+U/17NlTfn5+Cg8P1/jx45WYmOhc5+jRo7r//vsVHR0tb29vRURE6JZbblFqaqpznfT0dNlsNj3//PP65ptvnPvt0qWLPv3003LZ33jjDfXs2VP+/v5q0aKFLrvsMq1atUqStHnzZvn7+0s6fjZdWS3Gjx9fbl+LFi1S9+7d5e3trfnz52vjxo2y2WxasGBBuX02bdpUU6ZMqfTrP12Ok80xZUa9AAD128svv6zs7Gw9//zzLk2pEz366KPy8PBwPt62bZvGjBmj0NBQ+fj4KCYmRi+88IIcDodznbI5fbZv364HH3xQLVq0UHBwsG677TYVFxerpKREM2bMUHh4uBo3bqxrr71WOTk5Lvst28a2bdv0wAMPqHnz5mrcuLGGDRumbdu2uay7YsUKl+MYPz8/9ezZU6+++mq513P48GFNnTpV0dHR8vX1VadOnfTkk0/q2LFjeuqpp3TDDTdIkrp16+bc3ieffHLKOq5Zs0aDBw9Wo0aN1LJlSz311FNatWpVuTmfTqzLo48+qpYtW8putysvL69Kr2HdunU6//zzXfZX0fQTJ5tjKjU1VXfccYciIyPl7e2tyMhI3XvvvTp27Fi5rDt37tQTTzyh8PBwNWrUSJdcconLGf3VqZk76lDZfCdTdjZg69at9eeff552faBWMgDUWVu3bjUkGbfeemuVnjd06FCjc+fOxuWXX2788ssvRnp6uvHee++VW6+4uNjYvn27MXbsWCMoKMjYv3+/c9mLL75o2Gw24+mnnzbS0tKMnTt3GldccYUxbNgww8PDw2U71157rdGuXTuXsc8//9zw9PQ0br75ZmP37t1GZmam8dprrxleXl7Gq6++6pK1S5cuxpgxY4zly5cbOTk5xvvvv294eHgYs2fPdtnmqFGjjE6dOrmlDmPHjjUCAgKMd955xzhy5Iixd+9eY8yYMUZISIixb98+5zbGjBljNGnSxHj11VeNpKQk4/Dhw8YHH3xg3HfffYZhGEZJSYkxaNAgIzQ01Fi8eLGRnZ1tLF++3Gjbtq3Rvn17IysryzAMw0hLSzMkGVdddZUxZcoUIzEx0Th8+LBx/fXXG56ensaePXuc+1ywYIFhs9mMt99+28jKyjIyMjKM7777zhg9erRzndzcXEOS8eSTT5Z73WX7GjVqlHHjjTcae/bsMbZu3WosWbLE2LBhgyHJ+PDDD8s9LzQ01Jg8ebLL2Ole/6lyFBcXG5KMmTNnOsfMqBcAoP7r2rWrERISUun1d+zYYQQGBhoDBgwwNm3aZGRkZBivv/664ePjY9x8883O9d544w1DknHdddcZc+fONbKysowlS5YYAQEBxgMPPGDcc889xptvvmlkZmYay5YtMwIDA43bbrvNZV9l27jmmmuMV1991cjIyDA2bdpknHvuuUazZs2MQ4cOnTRnamqqMWfOHMPT09OYO3euczwpKcmIjIw0unTpYvz0009GTk6OsWPHDuOxxx4zFi5caBiGYbz33nuGJGPTpk2VqsmWLVsMPz8/Y9iwYcaOHTuM9PR044UXXjBGjx5tSDI+++yzcq/p2muvNf79738b6enpxvz58428vLxKv4atW7cajRs3Ni655BJj+/btRnp6uvHss89WuL+PP/7YkGRs2LDBOXb48GGjTZs2Ro8ePYyVK1caubm5xooVK4z27dsbF198seFwOFyyTpgwwXjttdeMjIwM448//jDat29vnHPOOS5Zq1ozd9ShKvnK1t25c6dhGIbx559/Gq1btzZ69eplJCUlVSozUBvRmALqsO+++86QZDz11FNVet7QoUMNScayZcsqtX5hYaHh4+NjPP/884ZhGEZ+fr4RGBhoXHXVVS7r5eTkGMHBwadtTBUVFRkRERHGeeedV25fU6dONUJCQozCwkKXrGvWrHFZb8SIEUZ0dLTLWHUaUxXVoayub7zxhsv4sWPHjPDwcGcj8MsvvzQkGf/+979Puo+PPvrIkGS8++67LuOrVq1yadiUNVo6d+5slJaWOtdLT083PD09jUcffdQ5dttttxnh4eGnfG2VaUxFRUUZJSUlLsuq0piqzOuvamPKjHoBAOq/Jk2aGD169Kj0+tddd53RqFEjIyUlxWX8gQcecGlMlDUCZsyY4bLeHXfcYfj5+Rn333+/y/idd95p+Pr6ury/lm3jrrvucll3//79hpeXlzFt2rTT5h0zZozRt29f5+MpU6YYPj4+p/wgpqpNlmuvvdYIDAw0srOzXcbHjx9/0sbUnXfeWaltV/Qaxo0bZwQEBDg/dCozduzYSjWmbr/9dqNRo0YuH5wahmH8/PPPhiTjm2++ccn6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lqYPP/xQ999/vyRp9OjR6t+/v+655x599dVXys3N1a+//qrx48erbdu2mjp1apXz3X777Xr++eeVkJCggoICHTp0SG+++aZ8fHzUr18/SZKnp6c6deqkX375RRkZGZXetpeXlyZOnKh33nlHP/zwg3Jzc/X1119r0aJFLj8DlX39Vc1hRr0AAPVfq1attHDhQm3cuFEXXHCB8z0sPz9fK1as0PDhw/Xoo486P2SZOXOmPDw8dNVVV2nLli3KysrSW2+9pRdffFGTJk1St27d3J4xKSlJb7zxhrKysrRlyxZdc8018vf3d75nSsfnKvrqq6/02Wef6ejRo/rtt9903XXXlWtezZo1S82bN9fll1+uJUuWKDc3V7t27dLjjz/u/GCta9eustls+vrrryvVaHn00UdVVFSkcePGadeuXcrIyNDLL79c4VnMp1LZ1/Doo4+quLhY1157rXbu3KmMjAw9//zzlW4KPfHEEwoNDdWwYcP0008/KTs7W2lpafr55591/fXXl5tjqjKqWrNTqWwdqiswMFDff/+9+vXrp6FDh+qbb75xy3YBK9CYAuqBZ555RsuWLVNGRoZGjRqloKAgtWzZUmPGjNGuXbv0wQcf6MEHHzztdry8vPTll1+qZcuW6tevn9q3b6+ff/5Z77//frnG1t/+9jfNmTNHb7zxhlq1aqXRo0fr1ltvVUxMTKUyl82JlJWVpfPOO09BQUEaMmSIvv/+ez333HPVqsPUqVM1atQoXXjhhfLw8DijU7bff/99Pfvss3rrrbfUsWNHhYeH66abbpK3t7emTZvmXG/BggWaNWuW/v3vfysqKkrdu3fX119/rTvuuEPS8cbMd999pxtvvFF33HGHQkJCdNVVV2nw4MFauXKlgoKCqpzt4YcfVnp6uq655hoFBwerV69eysrKcn5yVua1115Tbm6uwsPDZbPZNH78+Ept/7nnntPIkSM1ZswYtWrVSgsWLFB8fHyF9Tzd669qDjPqBQBoGC666CJt3rxZ5557ru6++26FhYWpRYsWuu222xQeHq41a9bI09NTktSpUyetXr1aTZs21aBBg9S8eXM9//zzevLJJ/XGG2+Ykm/WrFnavn27OnTooN69e8vf31+//PKLy/xETz75pOLi4jR16lQ1a9ZM9957r1555RVFRUW5bKvs9QwaNEgTJkxQ06ZNNXz4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SDr+CV5BQYH8/f318ssv67///a9bAwIAADR0+/bt04QJEzRhwgT16tVLPXr0cPmqbGOqsLBQr7zyijp27KjAwEAFBARo/PjxzF8FAEAd4OXto2SPCElS2t5NFqdxn2o1po4ePSp/f39JUosWLZSYmCjp+PwG2dnZbgsHAAAAqX379kpOTj7j7WzatElpaWn67rvvdOzYMf3xxx/avHmzJk2a5IaUAADAbBmN2kiSjh7aanES96n25OdlhgwZojvvvFOTJ0/Whx9+qN69e7sjFwAAAP7foEGD9MADD+jZZ5/VqFGjyk1+HhAQoICAgNNup1evXurVq5fzcXR0tEaPHq0333zT7ZkBAID7FQS2k46ukO3ITqujuE21zph64403nN//4x//UJMmTXT33XcrOztbr7/+utvCAQAA4PjNV3bt2qUZM2aoS5cuioyMdPl68cUXq7S9vLw8HTlyRCtWrNB7772nm266yaTkAADAnTybd5IkNc7dY3ES96nWGVNTpkxxft+8eXN99tlnbgsEAAAAV1OnTtXYsWNPurxp06ZV2t6VV16pX3/9Vfn5+Ro1apRmzJhx0nULCwtVWFjofJyTkyNJcjgccjgcVdpvZTgcDhmGYcq2Gzpqaw7qah5qaw7qap6aqG1Aqy7SRql54f5a/W9YlWxnfCkfAAAAzNW0adMqN59O5YcffpAk7dy5U9ddd51GjhzpHPur2bNna9asWeXGMzMzVVJS4rZMZRwOh3Jzc2UYhuz2ap3cj5Ogtuagruahtuagruapidr6hkZKkporQ/v27VFj/yBT9nOmcnNzK71upRtTffv2rfRGV69eXel1AQAAUF56errS09OdDalT3Tmvuo2rDh066Mknn9SwYcN08OBBtWrVqtw6Dz74oKZPn+58nJOTo8jISAUHB1dqXquqcjgcstlsCg4O5g8mN6O25qCu5qG25qCu5qmJ2oaEhChNwWqmTBVkHFBkm7am7OdMeXpW/jyoSq85fPjwaoUBAABA1f3rX//SrFmzNHPmTEmq8KylMjNnztTjjz9erf2UXZrn5eVV4XIfHx/5+PiUG7fb7aYddNtsNlO335BRW3NQV/NQW3NQV/PURG1TvVurWVGmcg5ulf2cC0zbz5moyuuvdGPqkUceqVaY+iY+Pl7x8fEqLS21OgoAAKjHpk+frilTpjjPSjpxjs+/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AAAAWIJL+arJMAxJUk5Ojtu37XA4lJubK09PT05hdzNqax5qaw7qah5qaw7q6qrsOKHsuKE+MPMYSOJnyEzU1hzU1TzU1hzU1TzU9n+qcgxEY6qK4uPjFR8fr6KiIklSZGSkxYkAAEBtl5ubW2/u5pubmyuJYyAAAHB6lTkGshn16SO8GuRwOJSUlCR/f3+338o4JydHkZGROnDggAICAty67YaO2pqH2pqDupqH2pqDuroyDEO5ubmKiIioN5+cmnkMJPEzZCZqaw7qah5qaw7qah5q+z9VOQbijKlqstvtatWqlan7CAgIaPA/zGahtuahtuagruahtuagrv9TX86UKlMTx0ASP0NmorbmoK7mobbmoK7mobbHVfYYqH58dAcAAAAAAIA6h8YUAAAAAAAALEFjqhby8fHRzJkz5ePjY3WUeofamofamoO6mofamoO64kzxM2QeamsO6moeamsO6moeals9TH4OAAAAAAAAS3DGFAAAAAAAACxBYwoAAAAAAACWoDEFAAAAAAAAS9CYqoUOHjyodevWKSsry+oo9Upubq42bNiglJQUq6PUSw6HQytXrtSff/5pdZR65dixY9qwYYMOHz5sdZR6JSMjQ+vXr9fOnTtVWlpqdZw6y+FwaO3atdq0adNJ1yksLNT69eu1ffv2GkyGuqqoqEgbNmzQtm3brI5SrxiGod27dyshIUGFhYVWx6mXEhMTtWLFCo7f3Wzfvn36888/VVJSYnWUeqO0tFQ7d+7U+vXrlZGRYXWcOi05OVkrVqxQdnb2Sdc5cOCA1q1bp5ycnBpMVvfQmKpFioqKNHbsWHXs2FE33nijwsPD9fzzz1sdq85LTEzUNddco8jISE2aNEmdOnXSJZdcotTUVKuj1StPPfWUBg8erFtuucXqKPXGc889p+bNm+vGG29U//79NWXKFA7MzlBpaakmT56sVq1aacqUKbrgggvUvn17LV++3OpodUpxcbGeeeYZdejQQUOGDNGdd95Z4XrffPONWrZsqauvvlr9+vVTr169lJycXMNpUVf89NNPatWqla666ioNHDhQPXr00IEDB6yOVee9/vrrio6O1tChQzV69GiFh4fr7bfftjpWvZKamqoBAwZo0KBBWr16tdVx6oXdu3erf//+OvvsszV58mTFxMTwXu0Gy5cvV/v27XXhhRdqypQpatWqlSZPnsyHdFW0bt06jRkzRt27d9egQYO0YcOGcusUFBToqquuUqdOnXTDDTcoLCxMc+bMsSBt3UBjqhaZPXu2li1bpu3btyshIUGffvqp7r//fv3yyy9WR6vTdu/erauvvlqZmZnasGGDEhMTlZaWpttuu83qaPXG8uXLNW/ePF1zzTVWR6k34uPj9fjjj+ubb77Rpk2btHv3bg0aNIhPus/QBx98oPfee0/r1q3T+vXrtX//fg0YMEA33XST1dHqlPz8fGVlZemHH37QmDFjKlwnPT1d1157re666y7t3r1bSUlJ8vLy0uTJk2s4LeqCrKwsXX311br55pu1Z88eJSUlKSAgQBMmTLA6Wp13+PBhrVixQrt27dK2bdv04osv6uabb67wDylUnWEYmjBhgm644Qaro9QbeXl5GjJkiFq1aqVDhw5p7dq1WrZsmdLT062OVudNnDhRgwYN0r59+7R+/XqtWbNG7777rj788EOro9Upmzdv1rXXXqvffvvtpOvMmjVLa9as0e7du7V161Z98MEHmjZt2imf05DRmKpF3n77bU2cOFGRkZGSpGHDhumcc87hU60zdNFFF+nqq6+WzWaTJAUHB2vs2LFasWKFxcnqh4yMDN1www2aO3euAgMDrY5TL5SUlOiJJ57Q1KlTNXDgQOf4hAkT1LhxYwuT1X1paWkKDAxUTEyMJMlut6tv375KS0uzOFndEhgYqGeeeUZt27Y96Toff/yxSktL9be//U2S5Ovrq3vvvVfffvstZ02hnM8++0xHjx7VAw88IEny9vbWAw88oKVLlyoxMdHacHXco48+qlatWjkfT5w4UT4+Plq1apWFqeqP5557TqWlpbrrrrusjlJvvP3220pOTtZ//vMf+fr6SpLCw8N15ZVXWpys7ktLS9O5554ru/14GyA2NlZBQUEcB1XRxIkTdfXVV8vLy+uk68ydO1dTpkxReHi4JOmKK65QbGys5s6dW1Mx6xQaU7VERkaG9u/fr3POOcdlvE+fPnyiZYK1a9eqffv2VseoFyZNmqTrrrtOgwcPtjpKvbFp0yalpqZqxIgRSk1N1fr165WZmWl1rHrhxhtvVIsWLXTbbbfphx9+0HvvvacXXnhBzzzzjNXR6p0NGzaoS5cu8vPzc4716dNHhmFo48aN1gVDrbRhwwZ16NBBAQEBzrE+ffo4l8F9Nm/erGPHjnEc5AZr1qzRSy+9pHfeecf5ASjO3E8//aQBAwYoMDBQf/zxB/NButHf//53vfDCC5o/f75++OEH3XLLLQoPD9eNN95odbR6JSkpSYcPH+Zv+yrwtDoAjiubeC40NNRlPDQ0lEnp3Ozjjz/Wp59+qi+//NLqKHXenDlzdOjQIX388cdWR6lXkpKSJEmffPKJFixYoPDwcG3btk3jx4/Xa6+9Jg8PD4sT1l1NmzbV1KlT9fDDD2v16tVKS0tTTEyMLrvsMquj1TsZGRkVvqeVLQNOVNHPS0hIiHMZ3OPYsWO66aab1K9fP1188cVWx6nTcnJyNG7cOP373/9WREQEN9dxo6SkJAUHB+vss8+Ww+FQRkaGvLy89O6772rQoEFWx6vThg8frs8//1wPPPCAmjVrpgMHDujvf/97ud+/ODP8bV91NKZqibLTAAsKClzGjx07Jm9vbysi1Us//fSTbrzxRj377LP8IXqGDhw4oPvvv1/x8fHOa6VTUlKUm5urFStW6KyzznL55BuVV/b7YPPmzdq7d68aNWqkrVu3qk+fPoqNjeVygTPwn//8R/fff79Wr16tmJgYlZaW6vbbb9eFF16orVu38vvWjby8vCp8T5NEnVFORT8vZY/5eXGPoqIijRkzRllZWfrll1+cl/KgembMmKGIiAg1a9ZMK1ascP6xuWXLFrVq1UqxsbEWJ6y7vLy89OOPP+rbb7/VJZdcIofDodtuu01XX3219u/fz++EaiosLNQFF1ygSy65RN999508PDyUkJCgvn37ysPDQ1OmTLE6Yr3B3/ZVxztSLRERESEvLy8dOnTIZfzQoUNq3bq1RanqlyVLlmjkyJGaOXOm7rvvPqvj1Hn5+fnOOdBmzJihGTNm6LffftOBAwc0Y8YM7du3z+qIdVZUVJSk43NKNWrUSJLUpUsXDR48mDvSnKEvv/xSF1xwgXOOKQ8PD91+++3as2ePNm/ebHG6+qVNmzYVvqdJ4n0N5fDzYq6yptT27du1dOlS55wnqD5/f38ZhuE8Bnr66aclHZ9X5q233rI4Xd0WFRWl6OhoXXLJJZKOzwd588036/Dhw9qxY4fF6equzZs3KzExUbfddpvz7PuYmBhdcMEFWrx4scXp6pfIyEjZ7Xb+tq8CGlO1hJeXl8477zyXXwoFBQX67rvvNGTIEAuT1Q8///yzRowYoUceeUQzZsywOk690KlTJ61YscLla9SoUYqJidGKFSvUrVs3qyPWWR07dlR0dHSFb2bNmjWzKFX90KxZMx08eNBlrOx29NTWvYYMGaLdu3crISHBObZo0SKFhITo7LPPtjAZaqMhQ4bo0KFDWr9+vXNs0aJFCggI0LnnnmthsrqvuLhYV199tRISEvTzzz+7TISO6nv22WddjoEWLVokSXr++ef10ksvWZyubhs6dKiOHDnicrZJ2Xs379XVV1a7vx4HHTx4kLq6mZ+fn/r37+/yt/3Ro0f1448/8rf9SXApXy3y5JNP6rzzztO9996rwYMH67XXXlOTJk10xx13WB2tTlu7dq2GDx+uSy65RIMGDXK5G9+JdzwDapPnnntOkydPVpMmTdShQwctXLhQO3fu1AcffGB1tDrt9ttvV//+/XXzzTfrqquuUkpKih577DGNGjXKeUdUVM66detUUFCgw4cPKzs72/m7tez36kUXXaRLLrlEV199tWbNmqWkpCQ9/fTTeumll055Fxs0TAMGDNCIESM0btw4Pfnkk0pPT9fMmTP19NNPO+/Kheq5/vrr9dNPP+nNN99UYmKi8y6HrVu35pN71ErXXXed5syZozFjxuiOO+5Qenq6HnnkEU2YMEEtWrSwOl6d1bp1a40aNUpxcXE6cuSIwsLC9Mknn+jPP//Ua6+9ZnW8OiU1NVU7duxw3s1w06ZN8vT0dPm9+tRTT2nIkCF68MEH1a9fP82ZM0fNmzfXLbfcYmX0WstmGIZhdQj8z2+//aY5c+YoOTlZXbt21QMPPKCWLVtaHatOW7Bggf71r39VuGzZsmVMJO1Gzz//vHbu3Kn//Oc/VkepF3788Ue9+eabysjIUMeOHXX33XdzFyU32LRpk1599VXt3r1bAQEBOu+883TLLbdwzX8VXX311UpOTi43fmLzPz8/Xy+++KJ++eUX+fn56frrr9fVV19dkzFRhxQUFOill17S0qVL1ahRI40dO1bjxo2zOladd8kllyg/P7/c+IQJE3TzzTdbkKh+ysjI0MiRI/XCCy9wlp8bZGdn64UXXtCqVasUFBSkoUOH6qabbuK4/QwVFRXp9ddf17Jly5STk6N27drp9ttv50qHKvrmm2+cl++eaNKkSZo0aZLz8cqVKxUfH6/Dhw+rW7dumjFjhsLCwmoyap1BYwoAAAAAAACWYI4pAAAAAAAAWILGFAAAAAAAACxBYwoAAAAAAACWoDEFAAAAAAAAS9CYAgAAAAAAgCVoTAEAAAAAAMASNKYAAAAAAABgCRpTACDp6NGjWrBggfLy8qyOAgAAUKN+++03rV692uoYABoom2EYhtUhAMBqiYmJio6O1s6dO9W+fXur4wAAANSYiRMnqqSkRPPnz7c6CoAGiDOmAAAAAAAAYAlPqwMAQG3kcDj00UcfSZI8PT0VFRWlHj16yNPzf782Fy5cqLPPPlvR0dEuz/3444/Vu3dvRUVFSZKysrK0evVq2e129ezZU82aNXOum52drW+++UZXXHGFtm/frl27dqlv375q2bKl+S8SAACgAn/++acSEhIkScHBwTrrrLMUHh7uXL59+3bt2LFDI0aMcHne1q1btXv3bg0fPtw5tmHDBu3bt09t2rRR9+7dZbf/79yIlStXysfHRx06dNCqVatks9k0dOhQk18dgNqGxhQAVKC0tFSff/65JKm4uFgbNmxQYGCgvv32W7Vo0UKS9N5772nRokV69913nc9buXKlxo4dqz179kiS5s+frzvvvFM9e/aUl5eXfvvtN7344ouaNGmSJOnAgQMaN26cLr/8ciUmJiomJkZt2rShMQUAACyzZcsWLVq0SJKUnp6uVatW6ZlnntGdd94pSSooKNCoUaO0c+dOtWvXzvm8adOmqXXr1ho+fLgyMzM1evRo7d27V927d9eWLVvUokULLV68WKGhoZKkl156Sfv379eRI0fUsWNHde/encYU0AAxxxQA6PRzTJWWlmr48OFq37695syZI0n66quvdM011yg5OVkBAQGSpMmTJ+vAgQP6/vvvlZCQoD59+mjJkiXq06ePJGn58uW65JJLtG3bNrVp00abN29Wt27dNHHiRL399tuy2Ww196IBAAB0+jmmVq5cqYsuukh79uxRRESEJKl3794aOnSonnrqKUnS/v37FR0dreXLl6t///66/vrrVVRUpA8//FCenp4qKSnRiBEj1KZNG7322muSpDFjxujLL7/Uxo0b1blz55p5sQBqHc6YAoBT2LRpkxITE3X06FGFhYVpzZo1zmWXXnqpQkJCtGDBAt1yyy06evSoPvroI73xxhuSjp8t1bJlS+3fv1/79u1T2ecAfn5++vXXX9WmTRvntu68806aUgAAoNbIysrSxo0blZqaKofDIS8vL/3xxx/OxtSUKVP01FNP6YknnpDdbtfcuXPVoUMH9e/f33lMNGPGDH3++ecyDEOGYah169ZaunSpy34uueQSmlJAA0djCgAqkJeXp0svvVQ7duzQOeeco4CAAO3du1dpaWnOdTw8PJxnOt1yyy366KOP5OXlpSuvvFLS8bOwjh07pk8++cRl20OGDFFQUJDL2InzNgAAAFhp/vz5uuOOO9S5c2e1atVK3t7eKi0tVWpqqnOd6667Tn/729/0/fffa+jQoXrnnXd0++23S5IOHTqkkpIS/f7779q5c6fLtgcOHOjymGMgADSmAKACr7/+utLS0rR//375+vpKkp555hnnqedlJk+erL///e9KSEjQ22+/reuuu04+Pj6SpICAAIWFhWnBggWn3R9nSwEAgNrAMAzFxcXplVde0U033eQca9y4sU6cBcbf31/XXHON3n77bXl5eengwYO68cYbJck5xcHUqVN12WWXnXJ/HAMBsJ9+FQBoeFJSUtSmTRtnU8owDH366afl1ouKitJFF12khx56SCtWrNDkyZOdyy699FL9/vvvWrt2rctzsrKylJ+fb+4LAAAAqIZjx44pJydHnTp1co599dVXOnbsWLl1p0yZosWLF+uFF17QZZddprCwMElSWFiYunfvXu4DPen42VQAcCLOmAKACowcOVIvvvii7r//fnXo0EEfffSRdu7cqcDAwHLr3nzzzbrmmmvUo0cP9ezZ0zk+atQojRs3TkOGDNG0adPUpk0bJSQkaPHixfrll1/k5+dXky8JAADgtPz8/DRkyBDdfvvtmjp1qpKSkhQfH+/8sO5E/fv3V/v27fXNN98472Zc5vXXX9fQoUM1dOhQXXnllcrLy9P333+vc845R7Nnz66hVwOgLuCMKQCQ1LhxY1177bXy9/eXdHz+gyVLlujo0aNatWqVrr76ai1YsEAjRowo99zhw4fLw8NDkyZNchm32WyaP3++5s+fr8zMTK1atUqRkZFas2aNcz6FoKAgXXvttWrUqJH5LxIAAKAC5557rvr16+d8/Omnn+q6667TihUrlJeXpyVLlmjSpEmKjo4u99xRo0apRYsWuvzyy13G+/Tpo4SEBJ1//vlatWqVUlNTNWPGDJem1MCBA9W7d2/zXhiAOsFmnHihMACgyhYvXqxx48bpwIEDCgkJsToOAABAjTnnnHM0ZMgQPfPMM1ZHAVBHcSkfAFTTwYMH9dNPP+mpp57S7bffTlMKAAA0GN98841++OEH7dy5U1988YXVcQDUYTSmAKCaDh06pO+++04TJ07Uvffea3UcAACAGrNkyRLl5OTou+++U0REhNVxANRhXMoHAAAAAAAASzD5OQAAAAAAACxBYwoAAAAAAACWoDEFAAAAAAAAS9CYAgAAAAAAgCVoTAEAAAAAAMASNKYAAAAAAABgCRpTAAAAAAAAsASNKQAAAAAAAFiCxhQAAAAAAAAs8X8Qr1RS5/heoQAAAABJRU5ErkJggg==", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -1030,130 +834,83 @@ " \n", " \n", " layer\n", - " matrix\n", - " relative reconstruction error\n", - " compact numerical rank\n", + " VJP norm\n", + " raw FF2 bottom tokens\n", + " normalized FF2 bottom tokens\n", " \n", " \n", " \n", " \n", " 0\n", " 0\n", - " FF1\n", - " 0.000e+00\n", - " 6\n", + " 6.557e-01\n", + " adr, adian, umn, itia, igmatic\n", + " adr, adian, umn, itia, igmatic\n", " \n", " \n", " 1\n", - " 0\n", - " FF2\n", - " 0.000e+00\n", - " 6\n", + " 2\n", + " 1.442e+00\n", + " EStreamFrame, ORPG, Dise, misconception, senal\n", + " EStreamFrame, ORPG, Dise, misconception, senal\n", " \n", " \n", " 2\n", - " 2\n", - " FF1\n", - " 0.000e+00\n", - " 6\n", + " 4\n", + " 1.329e+00\n", + " Diseases, Firstly, Feet, heses, Differences\n", + " Diseases, Firstly, Feet, heses, Differences\n", " \n", " \n", " 3\n", - " 2\n", - " FF2\n", - " 0.000e+00\n", " 6\n", + " 1.042e+00\n", + " Tycoon, 76561, Osiris, Modes, Memories\n", + " Tycoon, 76561, Osiris, Modes, Memories\n", " \n", " \n", " 4\n", - " 4\n", - " FF1\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 5\n", - " 4\n", - " FF2\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 6\n", - " 6\n", - " FF1\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 7\n", - " 6\n", - " FF2\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 8\n", " 8\n", - " FF1\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 9\n", - " 8\n", - " FF2\n", - " 0.000e+00\n", - " 6\n", + " 4.872e-01\n", + " Paris, Paris, Cologne, Copenhagen, Rome\n", + " Paris, Paris, Cologne, Copenhagen, Rome\n", " \n", " \n", - " 10\n", - " 10\n", - " FF1\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 11\n", + " 5\n", " 10\n", - " FF2\n", - " 0.000e+00\n", - " 6\n", - " \n", - " \n", - " 12\n", - " 11\n", - " FF1\n", - " 0.000e+00\n", - " 1\n", + " 2.749e-01\n", + " Paris, Paris, Copenhagen, Cologne, Kyoto\n", + " Paris, Paris, Copenhagen, Cologne, Kyoto\n", " \n", " \n", - " 13\n", + " 6\n", " 11\n", - " FF2\n", - " 0.000e+00\n", - " 1\n", + " 2.609e-01\n", + " Paris, Paris, Copenhagen, Marse, France\n", + " Paris, Paris, Copenhagen, Marse, France\n", " \n", " \n", "\n", "" ], "text/plain": [ - " layer matrix relative reconstruction error compact numerical rank\n", - "0 0 FF1 0.000e+00 6\n", - "1 0 FF2 0.000e+00 6\n", - "2 2 FF1 0.000e+00 6\n", - "3 2 FF2 0.000e+00 6\n", - "4 4 FF1 0.000e+00 6\n", - "5 4 FF2 0.000e+00 6\n", - "6 6 FF1 0.000e+00 6\n", - "7 6 FF2 0.000e+00 6\n", - "8 8 FF1 0.000e+00 6\n", - "9 8 FF2 0.000e+00 6\n", - "10 10 FF1 0.000e+00 6\n", - "11 10 FF2 0.000e+00 6\n", - "12 11 FF1 0.000e+00 1\n", - "13 11 FF2 0.000e+00 1" + " layer VJP norm raw FF2 bottom tokens \\\n", + "0 0 6.557e-01 adr, adian, umn, itia, igmatic \n", + "1 2 1.442e+00 EStreamFrame, ORPG, Dise, misconception, senal \n", + "2 4 1.329e+00 Diseases, Firstly, Feet, heses, Differences \n", + "3 6 1.042e+00 Tycoon, 76561, Osiris, Modes, Memories \n", + "4 8 4.872e-01 Paris, Paris, Cologne, Copenhagen, Rome \n", + "5 10 2.749e-01 Paris, Paris, Copenhagen, Cologne, Kyoto \n", + "6 11 2.609e-01 Paris, Paris, Copenhagen, Marse, France \n", + "\n", + " normalized FF2 bottom tokens \n", + "0 adr, adian, umn, itia, igmatic \n", + "1 EStreamFrame, ORPG, Dise, misconception, senal \n", + "2 Diseases, Firstly, Feet, heses, Differences \n", + "3 Tycoon, 76561, Osiris, Modes, Memories \n", + "4 Paris, Paris, Cologne, Copenhagen, Rome \n", + "5 Paris, Paris, Copenhagen, Cologne, Kyoto \n", + "6 Paris, Paris, Copenhagen, Marse, France " ] }, "metadata": {}, @@ -1161,65 +918,36 @@ } ], "source": [ - "def compact_gradient_rank(factors, relative_threshold=1e-5):\n", - " x = factors.forward_inputs\n", - " delta = factors.output_gradients\n", - " _, rx = torch.linalg.qr(x.T, mode=\"reduced\")\n", - " _, rd = torch.linalg.qr(delta.T, mode=\"reduced\")\n", - " singular_values = torch.linalg.svdvals(rx @ rd.T)\n", - " threshold = singular_values.max() * relative_threshold\n", - " return int((singular_values > threshold).sum())\n", + "def joined(ids):\n", + " return \", \".join(decode(model, ids))\n", "\n", "\n", - "diagnostic_rows = []\n", - "for layer_result in primary_result.layers:\n", - " for name, matrix in (\n", - " (\"FF1\", layer_result.input_projection),\n", - " (\"FF2\", layer_result.output_projection),\n", - " ):\n", - " diagnostic_rows.append(\n", - " {\n", - " \"layer\": layer_result.layer,\n", - " \"matrix\": name,\n", - " \"relative reconstruction error\": (\n", - " matrix.factors.relative_reconstruction_error\n", - " ),\n", - " \"compact numerical rank\": compact_gradient_rank(matrix.factors),\n", - " }\n", + "layer_position_rows, norm_comparison_rows = [], []\n", + "for layer in DETAILED_LAYERS:\n", + " ff1_top = vocab_logits(model, primary[\"factors\"][layer, \"in\"][\"residual\"]).topk(3).indices\n", + " delta = primary[\"factors\"][layer, \"out\"][\"residual\"]\n", + " ff2 = vocab_logits(model, delta)\n", + " ff2_bottom = ff2.topk(3, largest=False).indices\n", + " for position, token in enumerate(position_tokens):\n", + " row = (layer, position, token, joined(ff1_top[position]), joined(ff2_bottom[position]))\n", + " layer_position_rows.append(row)\n", + " normalized = vocab_logits(model, delta[-1], normalized=True)\n", + " norm_comparison_rows.append(\n", + " (\n", + " layer,\n", + " f\"{float(delta[-1].norm()):.3e}\",\n", + " joined(ff2[-1].topk(5, largest=False).indices),\n", + " joined(normalized.topk(5, largest=False).indices),\n", " )\n", - "diagnostics = pd.DataFrame(diagnostic_rows)\n", - "\n", - "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n", - "for matrix_name, group in diagnostics.groupby(\"matrix\"):\n", - " axes[0].plot(\n", - " group[\"layer\"],\n", - " np.maximum(group[\"relative reconstruction error\"], 1e-12),\n", - " marker=\"o\",\n", - " label=matrix_name,\n", " )\n", - " axes[1].plot(\n", - " group[\"layer\"],\n", - " group[\"compact numerical rank\"],\n", - " marker=\"o\",\n", - " label=matrix_name,\n", + "position_columns = [\"layer\", \"position\", \"prompt token\", \"FF1 top raw directions\"]\n", + "display(\n", + " pd.DataFrame(\n", + " layer_position_rows, columns=[*position_columns, \"FF2 bottom raw (update ordering)\"]\n", " )\n", - "axes[0].set(\n", - " yscale=\"log\",\n", - " xlabel=\"layer\",\n", - " ylabel=\"relative error\",\n", - " title=\"Gradient reconstruction\",\n", ")\n", - "axes[1].set(xlabel=\"layer\", ylabel=\"estimated rank\", title=\"Compact gradient rank\")\n", - "for ax in axes:\n", - " ax.grid(alpha=0.25)\n", - " ax.legend()\n", - "fig.tight_layout()\n", - "plt.show()\n", - "diagnostics_for_display = diagnostics.copy()\n", - "diagnostics_for_display[\"relative reconstruction error\"] = diagnostics_for_display[\n", - " \"relative reconstruction error\"\n", - "].map(lambda value: f\"{value:.3e}\")\n", - "display(diagnostics_for_display)" + "norm_columns = [\"layer\", \"VJP norm\", \"raw FF2 bottom tokens\", \"normalized FF2 bottom tokens\"]\n", + "display(pd.DataFrame(norm_comparison_rows, columns=norm_columns))" ] }, { @@ -1229,19 +957,19 @@ "source": [ "## Target-token ranks: raw versus Normalized Logit Lens\n", "\n", - "Ranks are zero-based and ascending in raw-gradient space: rank 0 is the smallest projected FF2 gradient logit. Lower is therefore the update-relevant ordering, but this remains a directional diagnostic rather than a prediction of an optimizer step." + "Ranks are zero-based and ascending in raw-gradient space: rank 0 is the smallest projected FF2 gradient logit at the final position. Lower is therefore the update-relevant ordering, but this remains a directional diagnostic rather than a prediction of an optimizer step. Ranks are shown as vocabulary percentiles at one decimal." ] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "id": "e87a4974", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:35.410940Z", - "iopub.status.busy": "2026-09-02T13:05:35.410743Z", - "iopub.status.idle": "2026-09-02T13:05:35.592085Z", - "shell.execute_reply": "2026-09-02T13:05:35.591305Z" + "iopub.execute_input": "2026-10-14T19:03:29.199051Z", + "iopub.status.busy": "2026-10-14T19:03:29.198976Z", + "iopub.status.idle": "2026-10-14T19:03:29.266057Z", + "shell.execute_reply": "2026-10-14T19:03:29.265712Z" } }, "outputs": [ @@ -1267,7 +995,6 @@ " \n", " \n", " layer\n", - " final-position VJP norm\n", " raw target rank percentile\n", " normalized target rank percentile\n", " \n", @@ -1276,49 +1003,42 @@ " \n", " 0\n", " 0\n", - " 0.655670\n", " 80.9\n", " 80.9\n", " \n", " \n", " 1\n", " 2\n", - " 1.442085\n", " 41.3\n", " 41.4\n", " \n", " \n", " 2\n", " 4\n", - " 1.329280\n", " 11.2\n", " 11.2\n", " \n", " \n", " 3\n", " 6\n", - " 1.042345\n", " 4.4\n", " 4.4\n", " \n", " \n", " 4\n", " 8\n", - " 0.487238\n", " 0.0\n", " 0.0\n", " \n", " \n", " 5\n", " 10\n", - " 0.274904\n", " 0.0\n", " 0.0\n", " \n", " \n", " 6\n", " 11\n", - " 0.260877\n", " 0.0\n", " 0.0\n", " \n", @@ -1327,23 +1047,14 @@ "" ], "text/plain": [ - " layer final-position VJP norm raw target rank percentile \\\n", - "0 0 0.655670 80.9 \n", - "1 2 1.442085 41.3 \n", - "2 4 1.329280 11.2 \n", - "3 6 1.042345 4.4 \n", - "4 8 0.487238 0.0 \n", - "5 10 0.274904 0.0 \n", - "6 11 0.260877 0.0 \n", - "\n", - " normalized target rank percentile \n", - "0 80.9 \n", - "1 41.4 \n", - "2 11.2 \n", - "3 4.4 \n", - "4 0.0 \n", - "5 0.0 \n", - "6 0.0 " + " layer raw target rank percentile normalized target rank percentile\n", + "0 0 80.9 80.9\n", + "1 2 41.3 41.4\n", + "2 4 11.2 11.2\n", + "3 6 4.4 4.4\n", + "4 8 0.0 0.0\n", + "5 10 0.0 0.0\n", + "6 11 0.0 0.0" ] }, "metadata": {}, @@ -1351,7 +1062,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -1361,39 +1072,28 @@ } ], "source": [ - "# Exact ranks can move by one across numeric backends when vocabulary logits are nearly tied.\n", - "# Keep them in primary_layer_summary, but display stable vocabulary percentiles.\n", - "vocab_size = primary_result.layers[0].output_projection.vocabulary_size\n", - "rank_percentiles = primary_layer_summary[[\"layer\", \"final-position VJP norm\"]].copy()\n", - "rank_percentiles[\"raw target rank percentile\"] = (\n", - " 100 * primary_layer_summary[\"raw target rank\"] / vocab_size\n", - ")\n", - "rank_percentiles[\"normalized target rank percentile\"] = (\n", - " 100 * primary_layer_summary[\"normalized target rank\"] / vocab_size\n", - ")\n", - "display(\n", - " rank_percentiles.round(\n", - " {\n", - " \"final-position VJP norm\": 8,\n", - " \"raw target rank percentile\": 1,\n", - " \"normalized target rank percentile\": 1,\n", - " }\n", - " )\n", - ")\n", + "# Exact ranks can move by one across numeric backends when logits nearly tie; one-decimal\n", + "# vocabulary percentiles do not, so only those are displayed.\n", + "def final_position_ranks(result, layers):\n", + " \"\"\"Ascending target ranks among the final position's FF2 logits, raw and normalized.\"\"\"\n", + " rows = []\n", + " for layer in layers:\n", + " delta = result[\"factors\"][layer, \"out\"][\"residual\"][-1]\n", + " logits = [vocab_logits(model, delta, normalized=n) for n in (False, True)]\n", + " rows.append([layer, *(int(ascending_rank(row, result[\"target_id\"])) for row in logits)])\n", + " ranks = pd.DataFrame(rows, columns=[\"layer\", \"raw rank\", \"normalized rank\"])\n", + " for kind in (\"raw\", \"normalized\"):\n", + " percentile = 100 * ranks[f\"{kind} rank\"] / model.cfg.d_vocab\n", + " ranks[f\"{kind} target rank percentile\"] = percentile.round(1)\n", + " return ranks\n", + "\n", + "\n", + "ranks = final_position_ranks(primary, DETAILED_LAYERS)\n", + "display(ranks[[\"layer\", \"raw target rank percentile\", \"normalized target rank percentile\"]])\n", "\n", "fig, ax = plt.subplots(figsize=(7, 4))\n", - "ax.plot(\n", - " rank_percentiles[\"layer\"],\n", - " rank_percentiles[\"raw target rank percentile\"],\n", - " marker=\"o\",\n", - " label=\"raw factor\",\n", - ")\n", - "ax.plot(\n", - " rank_percentiles[\"layer\"],\n", - " rank_percentiles[\"normalized target rank percentile\"],\n", - " marker=\"s\",\n", - " label=\"unit-normalized factor\",\n", - ")\n", + "for kind, marker in ((\"raw\", \"o\"), (\"normalized\", \"s\")):\n", + " ax.plot(ranks[\"layer\"], ranks[f\"{kind} target rank percentile\"], marker=marker, label=kind)\n", "ax.set(\n", " xlabel=\"layer\",\n", " ylabel=\"ascending target-rank percentile (lower is smaller)\",\n", @@ -1411,25 +1111,25 @@ "source": [ "## VJP norm heatmap\n", "\n", - "FF2 factor norms are norms of the residual-width vector-Jacobian products (VJPs). The logarithmic heatmap exposes both token-position and layer variation without discarding low-norm rows." + "FF2 factor norms are the norms of the backward signals at each MLP output. The logarithmic heatmap shows both position and layer variation without discarding low-norm rows. The final layer's MLP output at an earlier position never reaches the final position's logits, because no attention layer follows it, so those rows are exactly zero and the final-layer FF2 gradient has rank one." ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "id": "942c9dce", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:35.593912Z", - "iopub.status.busy": "2026-09-02T13:05:35.593713Z", - "iopub.status.idle": "2026-09-02T13:05:35.846776Z", - "shell.execute_reply": "2026-09-02T13:05:35.846027Z" + "iopub.execute_input": "2026-10-14T19:03:29.266996Z", + "iopub.status.busy": "2026-10-14T19:03:29.266940Z", + "iopub.status.idle": "2026-10-14T19:03:29.312269Z", + "shell.execute_reply": "2026-10-14T19:03:29.311950Z" } }, "outputs": [ { "data": { - "image/png": "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MD8vFXwlSpRQuZZ29uzZqFChAlq2bAlDQ0McPHgQM2bMQLdu3fJk+j4/w8pCQ0OlDqHQMTAwwN27d6UOg4j0GJNVonzUpk0bpKenSx1GkREQEIA+ffrA0dERs2fPzrdREamelwq3AQMGYMaMGZgyZQoiIiLg5uaG0aNHK251okv8DBMRkT7gmlUiIiIiIiLSO1yzSkRERERERHqHySoRERERERHpnSKxZlUul+P58+ewsrKCTCaTOhwiIiIioiJBCIG4uDiULFkSBgYFa5wsOTkZqampWrU1MTGBmZmZjiMqeopEsvr8+XO4u7tLHQYRERERUZEUGhpaoG5RlJycDE9PV0REvNGqvbOzM548ecKENZeKRLJqZWUFAAj5oR+szUwkjobymqy8i9QhUD6R1yna930sSgyO/St1CJRPUm+8lDoEyicmjTykDoHyQWxiCjx8flH8PF5QpKamIiLiDZ6G/A5rawuN2sbGJqJ0qS+QmprKZDWXikSymjn119rMBNbmTFYLO5kl/1EoKuQa/udBBZeBhanUIVA+STXl/9NFhQm/10VKQV2KZ13MDNbFzDVrJJfnTTBFUJFIVomIiIiIiDQml2uefDJZ1Rkmq0RERERERKowWZUUk1UiIiIiIiJVhHh3aNqGdILJKhERERERkSpyocXIKpNVXWGySkREREREpAqnAUuqYN2Zl4iIiIiIiIoEjqwSERERERGpwpFVSTFZJSIiIiIiUoXJqqSYrBIREREREakitEhWBZNVXWGySkREREREpIJMyCHTMPnUtD5lj8kqERERERGRKpwGLCkmq0RERERERKrIheb3TeV9VnWGt64hIiIiIiIivVOgRlblcjkMDJhfExERERFRPuA0YEkViGTV398fy5YtQ1RUFKpUqYKffvoJrVq1kjosIiIiIiIqzPIhWZXL5di+fTsuXLgAW1tb9OvXD5UqVcp1G2361Td6P0y5atUqzJ07F9u2bUNMTAx69OiBzp0748mTJ1KHRkREREREhZkQ725Fo9Gh2ZrVnj17Yvr06XBxccGzZ89Qq1Yt/Pvvv7luo02/+kbvR1aXLFmCoUOHok2bNgAAPz8/bNy4EatWrcL8+fMljo6IiIiIiAqtPB5ZPXLkCPbs2YNbt26hatWqAID09HSMHz8eV69e1bqNNv3qI70eWX39+jUePHiAFi1aKMpkMhlatGiBc+fOSRgZEREREREVepm7AWt6qCkwMBA1atRQJJQA0LdvXwQFBSE8PFzrNtr0q4/0OlmNjIwEABQvXlypvESJEopzqqSkpCA2NlbpICIiIiIi0kjmyKqmB5AlH0lJScnS/cOHD1G6dGmlMk9PT8U5VdRpo02/+kivk9VM8g+G0uVyOWQyWbb1/f39YWNjozjc3d3zOkQiIiIiIiIFd3d3pZzE398/S52kpCRYWVkplWU+TkpKUtmvOm206Vcf6fWaVRcXFwDAy5cvlcpfvnwJZ2fnbNtNnToVEydOVDyOjY1lwkpERERERJoRWqxZFe/qh4aGwtraWlFsamqapaq1tTWio6OVyt68eaM4p4o6bbTpVx/p9ciqnZ0dqlSpglOnTinK5HI5Tp06hSZNmmTbztTUFNbW1koHERERERGRJmRyuVYHgCz5iKpktVq1arhz545S2e3bt2FgYIDKlSurjEmdNtr0q4/0OlkFgClTpmDDhg3YvXs3nj9/jokTJyI+Ph6jRo2SOjQiIiIiIirMhNDuUFOvXr0QEhKCAwcOAHi3Y+/KlSvRvn172NnZAXg3Quvr64vg4GC126hTpyDQ62nAADBw4EDEx8dj6tSpiIyMRPXq1XHs2DG4ublJHRoRERERERVmeXzrmlq1amHOnDno3bs3WrdujUePHiEhIQEnT55U1Hn9+jXWr1+P/v37o2LFimq1UadOQSATQsO71hZAsbGxsLGxQfQiH1ibm0gdDuUxWUVXqUOgfCKvV1vqECifGBz6W+oQKJ+kBmW/2z8VLiZNS0kdAuWD2MQU2PZahJiYmAK1NC8zf3h7bhGsi5lr1jY+CbaNvtbomoODg3Hp0iXY2tqidevWMDf/33O+efMGf/75Jzp16qTY0+djbTSpo8/0fmSViIiIiIhIEhreN1XRRkMVK1ZExYoVVZ6zt7eHr6+vRm00qaPP9H7NKhERERERERU9HFklIiIiIiJSJY/XrFLOmKwSERERERGpIhdaJKuFfkugfMNklYiIiIiISBUNb0WjaEM6wWSViIiIiIhIFU4DlhSTVSIiIiIiIlWEFrsBc2RVZ5isEhERERERqcKRVUnx1jVERERERESkdziySkREREREpApHViXFZJWIiIiIiEgVuRZrVnnrGp1hskpERERERKSKkL87NG1DOsFklYiIiIiISBWOrEqKySoREREREZEqXLMqKSarREREREREqnBkVVJFKlm99qc5ihmZSh0G5bG6M/jbrKJCmFtIHQLlE3mzulKHQPnEOOW81CFQPsno0EbqECgfZMQmAFgkdRhUQBWpZJWIiIiIiEhtcqHFNGCOrOoKk1UiIiIiIiJVOA1YUkxWiYiIiIiIVNLi1jXgkjRdYbJKRERERESkCkdWJcVklYiIiIiISBUmq5JiskpERERERKQK77MqKQOpAyAiIiIiIiL6EEdWiYiIiIiIVNHTacBPnz7FlStXYGtri2bNmsHExCTH+unp6QgKCkJ4eDjKli2L6tWrK52PiYlBYGBglnaffvopnJycdBq7JpisEhERERERqaKHyerixYsxc+ZMNGnSBE+fPgUAnDhxAu7u7irrHzhwAJMmTYKNjQ1cXV1x5swZ1KpVC3v37oWlpSUAIDQ0FAMGDMBnn30GMzMzRdu6desyWSUiIiIiItI7erZm9datW5g8eTL++OMPdO/eHampqWjRogXGjBmDffv2qWwjk8lw9OhRlCpVCgAQFRWFmjVrYs6cOZg3b55S3eXLl8PZ2TnP4tcUk1UiIiIiIiJVhHh3aNomj+zYsQNubm7o3r07AMDExASjR4/G4MGD8fbtW9ja2mZp06lTJ6XHjo6OaNKkCW7cuJGl7unTp2FqaoqKFSuicuXKeXINmuAGS0RERERERKpkTgPW9AAQGxurdKSkpOQ6nJs3b6JatWpKZdWrV0dGRgbu3r2rVh+JiYk4e/ZslnWrRkZG+OWXX7BmzRo0aNAAnTt3RmxsbK5jzg2OrBIREREREamSizWrH64hnTVrFvz8/JTK3rx5g4MHD+bYXeXKlVG3bl0A7zZC+rBfe3t7xTl1jBw5EhkZGZg0aZKizMHBAUFBQYpEODw8HPXr18eUKVOwcuVKtfrNC0xWiYiIiIiIdCw0NBTW1taKx6amplnqxMTE4PDhwx/tKzNZNTc3R3x8vNK5uLg4xbmPmThxIgIDA3HixAmUKFFCUe7i4gIXFxfFY1dXVwwfPhzr1q1jskpERERERKR3hBYbLIl39a2trZWSVVU8PT2xdetWtbsuU6YMzp07p1QWEhKiOJeTb775Bhs2bMCxY8dQp06djz6XpaUloqKi1I4tLxSINasJCQk4d+4cLl26hISEBKnDISIiIiKioiAXa1bzQqdOnXDt2jXcv39fURYQEIDq1asrpgdHR0dj69atiIyMVNSZPHky1q1bh2PHjqFevXpZ+g0PD1d6LJfLsWfPHjRo0CCPrkQ9ej2yKoTA5MmTsWXLFpQpUwaJiYkIDQ3Fr7/+it69e0sdHhERERERFWZyaLFmNU8iAfAuWe3UqRM6duyI0aNHIzg4GDt27FCaShwSEoIBAwbg1KlTcHJywsKFC7Fw4UKMHz8ewcHBCA4OBvBurWvHjh0BAOvXr8fZs2fRpk0bmJqa4vfff8f9+/fVmqKcl/Q+WXVycsKjR48UN6xdsmQJBg0ahKZNm8LNzU3iCImIiIiIqNDKxQZLeWXv3r3YuHEjLly4AFtbW1y+fBk1atRQnLe3t0e/fv3g5OQEALCwsEC/fv0QFRWllHyWKlVKkazOnDkTZ86cwYEDBxAXF4cvvvgCgwYNgo2NTZ5ey8fIhMjDGwHlgcjISDg7O+Ovv/5SvLgfExsbCxsbG5xq/CWKGWVd2EyFS90Z0n6pKP9ktGgmdQiUT2SvIj9eiQoF2fHzUodA+UTer7vUIVA+iI1NgIN9Z8TExHx0/aY+ycwfon8eBmtzE83aJqXCbtzaAnfN+kivR1ZVOXPmDACgYsWK2dZJSUlRuo+R1PcHIiIiIiKiAkiId4embUgnCsQGS5nCw8MxduxY+Pj4oGzZstnW8/f3h42NjeL48F5EREREREREpN8KTLL66tUrtG3bFlWqVMGKFStyrDt16lTExMQojtDQ0HyKkoiIiIiICg092w24qCkQ04CjoqLQqlUrODk5Yf/+/TAzM8uxvqmpqcqb7hIREREREalNDzdYKkr0PlnNTFSLFy+OAwcOwMLCQuqQiIiIiIioKGCyKim9TlZTU1Px6aefIjIyEtOnT8fJkycV52rWrMm1qERERERElHeYrEpKr5PV5ORkuLq6wtXVFb/99pvSuXHjxjFZJSIiIiKiPCOEgNAw+SxgdwbVa3qdrFpbW+PAgQNSh0FEREREREURR1YlVWB2AyYiIiIiIqKiQ69HVomIiIiIiCTDkVVJMVklIiIiIiJShcmqpJisEhERERERqSLEu0PTNqQTTFaJiIiIiIhUEPJ3h6ZtSDeYrBIREREREanCacCSYrJKRERERESkCpNVSfHWNURERERERKR3OLJKRERERESkAtesSovJKhERERERkSpCi2nA3A1YZ5isEhERERERqSL//0PTNqQTTFaJiIiIiIhUEHIBoeHIqqb1KXtMVomIiIiIiFThyKqkmKwSERERERGpIv7/0LQN6QSTVSIiIiIiogLm9evXsLS0hJmZWY710tPTERERkaW8ePHiMDU11brf/FCkktWnCZawMMz6hlDhUjHgodQhUD4pVsxS6hAov0S8ljoCyicZobFSh0D5RJaSLHUIlA8K+vusj2tWT58+jaFDh+LFixdIT09Hnz59sHr1apWJJwDcu3cP1atXh4uLCwwMDBTlu3btQqNGjbTuNz8YfLwKERERERFRESTX8sgjkZGR6Nq1K3r16oWYmBjcu3cPJ0+exJQpUz7a9urVqwgLC1Mc7yequek3LzFZJSIiIiIiUkHItTvyypYtWyCTyeDn5wcjIyN4enpiwoQJWL9+PVJSUnJsm5ycjOjoaJ33m5eYrBIREREREamiZyOrFy9eRL169WBsbKwoa9asGeLj43Hnzp0c21aqVAlubm5wcnLCwoULIZf/L9Dc9JuXitSaVSIiIiIiInVpM1KaWT82VnkNvqmpaZb1n2lpaYiMjMyxPysrK9jY2AAAXr16BWdnZ6Xzjo6OinOqmJmZYeXKlRg0aBDMzMwQGBiIXr16wcjICBMmTNC63/zAZJWIiIiIiEgVAc1HSv9/fyV3d3el4lmzZsHPz0+p7O7du+jYsWOO3fn6+iraGRgYID09Xel8WloaAMDQ0FBl+3LlyqFcuXKKx127dsXw4cOxevVqRbKqTb/5gckqERERERGRjoWGhsLa2lrxWNWuujVq1EBYWJjafbq5ueHx48dKZZkjs66urmr3U6ZMGTx9+lTn/eoa16wSERERERGpIIR2BwBYW1srHbq4BUzz5s1x8eJFxMTEKMoOHz4MZ2dnVKhQAcC7EdGwsDDFxkjvr03NdP78eZQpU0ajfqXAZJWIiIiIiEgFfdsNuG/fvnB3d0e/fv0QFBSEgIAALFmyBN99953iHqq3b9+Gu7s7zp07BwCYMmUK/P39cenSJdy4cQPTpk3Dzp07MWPGDI36lQKTVSIiIiIiIlX0bDdgc3NznDp1CjY2Nvj888+xaNEiLF68GGPGjFHUMTExgaurq2Ik18/PDxkZGRg7diz69OmDe/fu4Z9//kGfPn006lcKXLNKRERERESkQm52A84r7u7u2LZtW7bnq1SporQO1tLSEtOnT8f06dNz1a8UmKwSERERERGp8P4aVE3akG4wWSUiIiIiIlJFLnt3aNqGdIJrVomIiIiIiEjvMFklIiIiIiJSQd92A9Znd+7cQefOneHi4oJixYopHVWrVtWqT04DJiIiIiIiUkEIGYTQbFqvpvULiy5duqBatWpYtGgRLC0tlc5ZWFho1WeBSlbPnz+PrVu3omXLlvjss8+kDoeIiIiIiAoxfdwNWB+9fPkSUVFR+PPPP2FoaKizfgtMsvrmzRv07dsXb9++hZGREZNVIiIiIiLKU0JokawWwd2AbWxsYGRkhPT0dJ0mqwVmzaqPjw98fX3h4eEhdShERERERFQEZE4D1vQoakxNTTFy5EgMGzYM4eHhOuu3QCSrP//8M6Kjo/Htt99KHQoRERERERUVchmEhkdRvXVNs2bNEBAQADc3N8hkMqWjdOnSWvWp99OAr127hh9//BEXL16EgYF6uXVKSgpSUlIUj2NjY/MqPCIiIiIioiItPT0d/fv3R8+ePdGrV68sGyyZm5tr1a9eJ6sJCQno1asXli5dilKlSqndzt/fH99//30eRkZERERERIWdEJqvQS2Ka1Zfv36N9PR0bN26Ve0BRnXo9TTgrVu3IjIyEv/99x/GjBmDMWPGIDw8HH///TfGjBkDuVz1auepU6ciJiZGcYSGhuZz5EREREREVNBxzap6SpQoAUNDQ6XZrbqg1yOrjRs3xg8//KBUZmpqCjs7O1SqVAkymeoPgqmpKUxNTfMjRCIiIiIiKqQU61A1bFMU+fj4YNCgQZg7dy7c3NyUzslkMq3yM71OVqtXr47q1asrla1btw41a9bEmDFjJIqKiIiIiIiKAk4DVk9ISAgWL14MANi1a1eW86VKlcLTp0817levk1UiIiIiIiKpaDOttyhOA3ZxccG///6b7XkzMzOt+i1wyerUqVPh6uoqdRhERERERFTIyeUyyDWc1qtp/cIgKSkJly9fxldffaXTfvV6gyVVevXqhaZNm0odBhEREREREQFITk7GwoULdd5vgUtWiYiIiIiI8kPmmlVNj6LGyckJMpkMjx8/1mm/BW4aMBERERERUX7gmlX1JCcn4/PPP0ebNm0wfvx4lC1bFkZG/0s1zc3N0aJFC437ZbJKRERERESkApNV9bx8+RK//PILAGDSpElZzpcuXRoPHz7UuF8mq0RERERERCrIhQxyDZNPTesXBqVKlUJ6errO+2WySkREREREpIKQyyA03N1X0/qUPSarRERERERElGtJSUnYt28fHjx4ADMzM9SsWRNt27bVuj8mq0RERERERCpos7tvXu8G/OLFC8yYMQMXLlyAra0thg4disGDB2dbf9GiRVi1alWWckNDQ9y+fRtGRkZ48OABOnTokKXOtm3b0KBBA7Xiunz5Mnr06IGoqCiUKVMGKSkpePLkCRo2bIj9+/fD3t5e7WvMxGSViIiIiIhIBTm0WLOKvJsGnJKSglatWsHDwwOrV69GcHAwRo4cidTUVAwfPlxlGx8fH3h7eyuVdevWDe7u7oode1NSUvDo0SOcO3cOjo6Oinqurq5qx+bj44N27dph8eLFsLa2BgA8ffoUvXr1gp+fH37++WcNr5bJKhERERERkUr6thvwjh078OjRI/z333+ws7ND48aNcf/+fXz//ffw9fWFgYFBljYODg5wcHBQPL59+zbu3LmD2bNnZ6lbunRpODs7axxXVFQUHj16hKtXr8LY2FipvyVLlmDEiBEa9wkAWa+GiIiIiIiIIP5/N2BNjrxMVv/++2/Ur18fdnZ2irKOHTvi+fPnePDggVp9rF+/HiVKlEDXrl2znOvSpQtq1aqFXr164cKFC2rHJZPJIISAXC7Pci49PV1lEq0OJqtEREREREQqZI6sanoAQGxsrNKRkpKS63hCQ0OzjHxmPg4LC/to+9TUVGzZsgWDBw9WGgEFgF69esHf3x9r1qyBq6srGjdujCNHjqgVl4ODA6pWrYqhQ4ciKipKUX7nzh2MGzcO7dq1U6ufD3EaMBERERERkQry/z80bQMA7u7uSuWzZs2Cn5+fUtmtW7eyrCf9kI+PD7777rt3fcvlWZJMExMTAEBGRsZHY9u/fz+ioqLg6+urVF65cmUEBAQoHtevXx+hoaGYMWOG2onm5s2b0bNnTzg7O8PFxQUpKSl49eoVOnTogJkzZ6rVx4eYrBIREREREelYaGioYqMhADA1Nc1Sp3z58jh8+HCO/dja2ir+7ujoiNevXyudzxzJfH9jpOysX78eXl5eKF++vFK5oaFhlrpNmzbFwYMHP9pnpqpVq+LmzZs4fvw47t+/DzMzM9SqVQv169dXu48PMVklIiIiIiJSITcbLFlbWyslq6qYmpqiXLlyavddr149zJs3D+np6YqdfM+cOQMLCwtUqVIlx7ZhYWE4evQotmzZotZzPX36VClRVoeRkRHat2+P9u3ba9Qu2/500gsREREREVEhIxfQ/NY1eXif1QEDBmD27NmYO3cuZsyYgefPn2PZsmUYPHgwzMzMAAB3795Fly5dstwjdePGjbC1tUWPHj2y9Lty5UrUqlULDRs2hEwmw9GjR7FmzRpMmDBBo/iuXbuGq1evIjY2Vqnc2toaQ4YM0fh6i1Sy+iDOCGaGxh+vSAWa2Q0PqUOgfNL+4A2pQ6B8khqRx3dYJ72RHFOkfjQp0uxfvpQ6BMoHsrgkqUPIFX27dY2Liwv27NmDIUOGYOnSpUhISED37t2xaNEiRZ3Me6YmJf3vtRdCYOPGjRgwYIAiqX1fo0aN8PXXX+Py5cswMDCATCbDtGnT8O2336od28yZM/HDDz/Aw8MDxYoVUzrn6urKZJWIiIiIiEhX3o2sat4mL3366acICQnB8+fPVU41rlKlCh48eABXV9f/xSSX4+jRo9neQ7VWrVo4fvw4EhMTkZCQgOLFi2sUU2pqKubPn48TJ06gZcuWml9UNpisEhERERERqaBvI6uZDAwM4ObmpvKciYlJlnWwhoaGaq2NtbCwgIWFhcbxJCQkwMbGRqeJKqDlfVY9PT11GgQREREREZG+kUOm1VHU2NnZwcrKCs+ePdNpv1qNrMbExODt27ca7w5FREREREREhUtSUhK8vb3Rrl07TJw4Mcs9Zs3NzdGiRQuN+9UqWe3Vqxd++uknzJo1S5vmREREREREek+Id4embYqaly9f4qeffgIAjBo1Ksv50qVL4+HDhxr3q1WyGhYWhlWrViEgIACVK1eGiYmJ0vmAgABtuiUiIiIiItIbciHT4tY1RW8acKlSpZCenq7zfrVKVp2cnDB06FBdx0JERERERKQ3hBZrUEURXLOaV7RKVtetW6frOIiIiIiIiPQKpwFLi7euISIiIiIiUoHTgKWl1a1rMjIyMG/ePJQvXx6GhoaK8vHjx+PJkyc6C46IiIiIiEgqAjKtDtINrZLV+fPnY8OGDZgxYwbkcrmivH79+pg9e7bOgiMiIiIiIqKiSatkdf369di5cycGDhyoVN6yZUvs379fJ4ERERERERFJSS60O4oaIQQePnyIuLi4LOeSk5Nx/vx5rfrVKlkNCwtDpUqVAAAy2f+GuU1NTZGQkKBVIERERERERPokc82qpkdRcu/ePVSoUAHly5eHra0tRo0ahaSkJMX5iIgI9O7dW6u+tUpWy5UrhwsXLgBQTlZ37NiBatWqaRUIERERERGRPuGa1Y8bNWoUqlSpgtOnT2PNmjXYu3cv2rdvj/j4+Fz3rdVuwJMnT0b//v3h5+cHAPjrr79w+PBhrF69Glu3bs11UERERERERFLTZlpvUZoGnJKSgvPnz+PVq1coVqwYmjdvjg4dOqB9+/bo0KEDDh06lKv+tRpZHTRoEObMmQN/f3/I5XJ07twZ+/fvx5o1a/DFF1/kKiBVoqOjMW3aNDRs2BCtWrVCQECAzp+DiIiIiIjofRxZzVlCQgLMzc1RrFgxRVnJkiVx6tQpxMfH53qEVev7rPr4+MDHxwfR0dGQy+VwcHDQOoicvHnzBg0bNoS7uzv8/f1hYWGB5cuXw93dHU2aNMmT5yQiIiIiIuLIas7s7e1hY2ODkJAQlCpVSlHu4OCAEydO4NNPP8Xnn3+udf9ajazWqVMHy5cvR3R0NOzs7PIsUQWAmTNnIjU1FQcOHEDLli3RoEEDbNmyBQ0aNMiz5yQiIiIiIqKPGzt2LH7++ecs5fb29jh+/LjSqKumtEpWP/30U8ydOxcuLi7o3bs3jh07pnS/VV0KCAhA//79YW5urlRuZKT1oDAREREREdFHcTfgjxszZgzGjBmj8pydnR1OnjyJPXv2aNW3Vsnq/PnzERoail27diElJQWdOnWCp6cnZs2ahadPn2oViCqvXr3C69evUapUKfj6+qJ27dro1KkTfv/99xzbpaSkIDY2VukgIiIiIiLShNDyKEpMTEzg6emZ7Xm5XJ6/91kFAENDQ3Tp0gV79uxBWFgYhg0bhnnz5qFMmTJo06YN/vrrL227VkhNTQUAfPPNN6hRowbWr1+Pzp07Y8CAAVi9enW27fz9/WFjY6M43N3dcx0LEREREREVLQKaj6oWpQ2W1BEdHY358+dr1VbrZDXTxYsXMXPmTCxatAjFixfHtGnTUKZMGXzxxReYOHFirvq2t7eHgYEBunXrhnHjxqFOnToYNWoUBg0ahDVr1mTbburUqYiJiVEcoaGhuYqDiIiIiIiKHrmWB+mGVgs/X716hS1btmDDhg0IDg5Gx44dsXXrVnTo0AGGhoYAgHHjxqFBgwZYsmSJ1sGZm5ujWrVqsLe3Vyp3cHDIcQtkU1NTmJqaav28REREREREQsggNFyDqml9yp5WI6uurq5YsWIF+vXrh9DQUOzbtw+dO3dWJKoAUK1aNdSuXTvXAY4ZMwY7d+7EkydPAADPnj3Dtm3b0LFjx1z3TURERERElB2OrEpLq5HVw4cPo2XLlpDJcv6twZkzZ7QK6n3Dhg3D06dPUaNGDdjZ2eHVq1fo06cP5s6dm+u+iYiIiIiIChK5XI4jR45g//79qFmzJkaOHKlWm+3bt+PChQuwtbVFv379UKlSJY3rfOjt27dYtmzZR+toS6tktVWrVlo/oTZ+/PFHTJ8+HZGRkShZsiRMTEzy9fmJiIiIiKjokYt3h6Zt8sqLFy/QrFkzlC1bFuHh4Xj16pVayWrPnj1x9epVDB8+HMHBwahVqxaOHTuGZs2aaVTnQ/Hx8di0adNHn9/NzU2t6/uQ1jcrjYmJwenTp/Hs2TOkp6crnfvqq6+07TZb5ubmKF26tM77JSIiIiIiUkVA891983I3YEtLSxw7dgyenp7w9vZWq82RI0ewZ88e3Lp1C1WrVgUApKenY/z48bh69aradVRxc3PT6a1LP6RVsnrp0iV06tQJJiYmCA8PR9myZRESEoL09HRUqlQpT5JVIiIiIiKi/KRvI6vW1tawtrbWqE1gYCBq1KihSEIBoG/fvujcuTPCw8Ph6uqqVh0paLXB0qRJkzBu3DiEhYUBAB4+fIiwsDC0adMGn332mU4DJCIiIiIikkLmyKqmBwDExsYqHSkpKZJcw8OHD7PMUPX09FScU7eOFLQaWb127Rr2798PADAwMEBqaiqcnJywatUqtGjRAj/88INOgyQiIiIiIspvuRlZdXd3VyqfNWsW/Pz8lMpCQkIwZ86cHPv79NNP0atXL82CeE9SUhIcHR2VyqysrBTn1K0jBa2S1bi4ONja2gIASpQogWfPnqFcuXJwdHREVFSULuMjIiIiIiKSRG6S1dDQUKUpu6amplnqWlpaomHDhjn2V6pUKc0C+IC1tTWio6OVyt68eaM4p24dKWi9wVKmFi1aYMqUKRg1ahS2bNmCmjVr6iIuIiIiIiKiAkud9aWOjo7w9fXN0ziqVauGgIAApbLbt2/DwMAAlStXVruOFLRas+rv76/4+4IFC/Dy5Ut07NgRV65cwapVq3QWHBERERERkVRys2ZVKqGhofD19UVwcDAAoFevXggJCcGBAwcAvNvld+XKlWjfvj3s7OzUrpOT169fY/ny5Rg0aBA6duyILl26YOTIkQgICEBaWprW16LVyOq3336r+LuHhwf+/fdfCCEgk0n7xhAREREREemK0GIasMjD3YABYMyYMUhOTsa1a9cgk8ng6+sLKysrLF26FMC7xHH9+vXo378/KlasiFq1amHOnDno3bs3WrdujUePHiEhIQEnT55U9KlOnez8+++/6Nq1K1JTU1G7dm04OjoiLS0Nly5dwtq1a/HDDz/g+PHjcHZ21vhacz0NOBMTVSIiIiIiKkzk/39o2iYv1atXD2lpaUprXc3MzBR/9/DwwNq1a1GxYkVF2XfffYeePXvi0qVLsLW1RevWrWFubq7Urzp1VBk8eDB8fX3x/fffw8LCQulcREQE+vbti++++w7r16/X+FrVTlY7d+6sdqeZw8dEREREREQFlRAyCKHZoJym9TU1aNCgHM/b29urXAdbsWJFpQRWFXXqvC8yMhIvX77EvHnzYGhomOW8s7Mz/P39MXjwYLX7fJ/ayWq5cuW0egIiIiIiIqKCSB9HVvWJpaUlUlJSEBkZiZIlS6qs8+TJE9jY2GjVv9rJ6rJly7R6AiIiIiIiooIoN7euKQqKFSsGb29veHl5YfLkyWjcuDEcHByQnp6OiIgIHDlyBP7+/liwYIFW/etszSoREREREREVLRs3bsRXX32FMWPGICUlRemcg4MDvv/+e4waNUqrvpmsEhERERERqSD+/9C0TVFiaWmJtWvXYunSpbh+/TqioqJgYGAANzc3VK9eHUZG2qecTFaJiIiIiIhUeDcNWLMNk4rSNOD3FStWDE2aNNFpn0UqWb0VnQ5jA+1vSksFw4VXRfRfiCLoQXxZqUOgfLIi4prUIVA+Od7ISeoQKJ/YHz4ndQiUH5JSpY4gVziymnsJCQk4duwYvL29NW5roPtwiIiIiIiICr7MDZY0Peh/Xr16ha+++kqrtkVqZJWIiIiIiEhdvHVNzjIyMvDixYsc60RERGjdP5NVIiIiIiIiFYR4d2japqgIDQ2Fp6fnR+uVKlVKq/6ZrBIREREREZHGHBwcYGVlhZ07d8Lc3FxlnYiICHz77bda9c9klYiIiIiISAUBGeTQbDdgoWH9gszKygo9evRAREQEfHx8VNZ5+vSp1v1zgyUiIiIiIiIVMqcBa3oUJePGjcODBw+yPW9ra4uxY8dq1TdHVomIiIiIiFTgBksfV6dOHdSpUyfb87a2tpg0aZJWfTNZJSIiIiIiUkGbW9Hw1jW6w2SViIiIiIhIBfH/h6ZtipqEhATs2bNH5TmZTAYzMzNUqlQJVatW1ahfJqtEREREREQqcGRVPdHR0fj6668RGRkJ4N3U39TUVCQmJsLAwADW1tZ4+/YtvLy8cPDgwWx3Dv4QN1giIiIiIiIirbm5uaFnz57o3bs3wsLCEB0djYSEBFy5cgVVq1bF2rVrERwcjOjoaPzwww9q98tklYiIiIiISAXuBqyejIwMbNu2DZs2bYKrq6uivE6dOvjll1+wZs0aVKhQAfPnz8fff/+tdr+cBkxERERERKQCdwNWz6tXr5CamgpDQ8Ms54yNjREaGgoAcHFxQXJystr9cmSViIiIiIhIhcw1q5oeRY2TkxOcnJzw5Zdf4u3bt4ryhw8fYsKECWjYsCEA4O+//4aXl5fa/TJZJSIiIiIiUkFoeRQ1MpkMO3bswLFjx1CiRAmUKVMGrq6uqFChAszMzLBgwQLI5XIkJCRg5syZavdbYKYBp6Sk4O3btyhevDgMDJhjExERERFR3uJuwOpr0KAB7t+/j6NHj+Lhw4cwMzND9erV0bhxY0WdqVOnatSn3ierT58+ha+vL86cOQMbGxvEx8fD19cXS5YsUTknmoiIiIiISBcEZBCQadwmr7158wbHjh1DyZIl0axZs4/WT09PR1BQEMLDw1G2bFlUr15d6XxMTAwCAwOztPv000/h5OSkdlzGxsbo1KmT2vU/Ru+T1WHDhiEpKQmRkZGwsbHB1atX0axZM1SsWBFffvml1OERERERERHli5iYGIwfPx5Hjx6FXC5H06ZNP5qsHjhwAJMmTYKNjQ1cXV1x5swZ1KpVC3v37oWlpSUAIDQ0FAMGDMBnn30GMzMzRdu6detqlKwmJSVh3759ePDgAczMzFCzZk20bdtWu4tFAUhWHz16hIEDB8LGxgbAu+2Py5Urh0ePHkkcGRERERERFWYCmk/rzctZwElJSfDy8sLKlSvRp08ftdrIZDIcPXoUpUqVAgBERUWhZs2amDNnDubNm6dUd/ny5XB2dtYqtsuXL6NHjx6IiopCmTJlkJKSgidPnqBhw4bYv38/7O3tNe5T7xd/TpgwAZs2bcKRI0dw7949/Pzzz3j+/DmGDBkidWhERERERFSI6dtuwM7Ozhg8eDDMzc3VbtOpUydFogoAjo6OaNKkCW7cuJGl7unTp7F3717cvXtX49h8fHzQrl07RERE4NatW3jw4AEePnyItLQ0+Pn5adwfUABGVocMGYKzZ8+ic+fOsLOzQ2xsLJYuXYqqVatm2yYlJQUpKSmKx7GxsfkRKhERERERFSLa7O6bWf/DHMTU1BSmpqa6CCtXEhMTcfbsWfTv31+p3MjICL/88gusra1x5swZNG/eHNu3b4e1tfVH+4yKisKjR49w9epVGBsbK8pLly6NJUuWYMSIEVrFqvfJateuXSGXy/Hy5UvY2dkhKCgILVu2hBAi2zWr/v7++P777/M5UiIiIiIiKkxysxuwu7u7UvmsWbOyjDC+efMGBw8ezLG/ypUro27dupoFkYORI0ciIyMDkyZNUpQ5ODggKCgI1apVAwCEh4ejfv36mDJlClauXPnRPmUyGYQQkMvlWc6lp6drfTcXvU5WX7x4gZMnTyIwMBB2dnYAgNq1a6Nnz5747bffsk1Wp06diokTJyoex8bGZvmwEBERERER5UT8/x9N2wDvNi16f1RS1ahqTEwMDh8+/NE+dZWsTpw4EYGBgThx4gRKlCihKHdxcYGLi4visaurK4YPH45169aplaw6ODigatWqGDp0KJYtWwZHR0cAwJ07dzBu3Di0a9dOq3j1Olm1srKCTCbD27dvlcqjo6NzHI7WlyF2IiIiIiIquHIzsmptbf3RKbSenp7YunWrltFp5ptvvsGGDRtw7Ngx1KlT56P1LS0tERUVpXb/mzdvRs+ePeHs7AwXFxekpKTg1atX6NChA2bOnKlVzHqdrBYrVgyfffYZpk+fDltbW5QpUwZHjx7F3r178dtvv0kdHhERERERkV6Jjo7GX3/9pXSP1MmTJ2PdunU4duwY6tWrl6VNeHg4XF1dFY/lcjn27NmDBg0aqP28VatWxc2bN3H8+HHcv38fZmZmqFWrFurXr6/1teh1sgoAmzZtwpIlSzBv3jy8fv0apUqVwu+//47PPvtM6tCIiIiIiKgQy80GS3ll586dSEtLQ1hYGAwMDLB161aYmZmhZ8+eAICQkBAMGDAAp06dgpOTExYuXIiFCxdi/PjxCA4ORnBwMADA3t4eHTt2BACsX78eZ8+eRZs2bWBqaorff/8d9+/fV2uK8vuMjIzQvn17tG/fXifXqvfJqqWlJWbMmIEZM2ZIHQoRERERERUhuZkGnFeOHTuG5ORkVKpUCQBw+PBhWFlZKZJVe3t79OvXTzGqamFhgX79+iEqKkop+SxVqpQiWZ05cybOnDmDAwcOIC4uDl988QUGDRoEGxubbONITEz86OZQmSwtLdGhQweNr1Xvk1UiIiIiIiIpCPHu0LRNXlq3bl2O5z08PJTWwY4ePRqjR4/+aL9NmzZF06ZN1Y4jKioKvr6+atX18PBgskpERERERKQr8v8/NG1TFHh4eGTZCFfXmKwSERERERGpoI/TgIsSJqtERERERESqaDENOM93WCpCDKQOgIiIiIiIiOhDHFklIiIiIiJSgWtWpcVklYiIiIiISAV93A24KGGySkREREREpAJHVqXFZJWIiIiIiEgFIQSEhkOlmtan7DFZJSIiIiIiUoG3rpEWk1UiIiIiIiIVBDS/Ew1zVd3hrWuIiIiIiIhI73BklYiIiIiISAVOA5YWk1UiIiIiIiIVmKxKi8kqERERERGRCu/WrGq4G3DehFIkFalk9UzaCRjIDKUOg/JYVNxVqUOgfJIgvpI6BMonT2KPSB0C5ZM/n0yROgTKJ8MuPpQ6BMoH8ampUoeQKxxZlVaRSlaJiIiIiIjUJcS7Q9M2pBtMVomIiIiIiFQQEJBrPA2Y2aqu8NY1REREREREpHc4skpERERERKQCpwFLi8kqERERERGRCvL/PzRtQ7rBZJWIiIiIiEgFIQSEhkOlmtan7DFZJSIiIiIiUoG3rpEWk1UiIiIiIiIV5FrsBqxpfW1FRkbC2NgY9vb2OdZLT09HRERElvLixYvD1NQ0S/nr169haWkJMzMzncWqLe4GTEREREREpILA/zZZUvvIw3jS0tKwadMmNGjQAK6urhg+fPhH29y7dw/u7u6oX78+GjZsqDiuXr2qVO/06dMoV64cPDw8YGNjg8GDByMlJSWvLkUtTFaJiIiIiIgKgNDQUPz9999YtmwZOnfurFHbq1evIiwsTHE0atRIcS4yMhJdu3ZFr169EBMTg3v37uHkyZOYMmWKri9BI0xWiYiIiIiIVMicBqzpkVfKlCmDTZs2KSWa6kpOTkZ0dLTKc1u2bIFMJoOfnx+MjIzg6emJCRMmYP369ZKOrjJZJSIiIiIiUkHjKcBa3Jc1v1SqVAlubm5wcnLCwoULIZf/7yY7Fy9eRL169WBsbKwoa9asGeLj43Hnzh0pwgXADZaIiIiIiIhUys0GS7GxsUrlpqamWTY0SktLQ2RkZI79WVlZwcbGRqMY3mdmZoaVK1di0KBBMDMzQ2BgIHr16gUjIyNMmDABAPDq1Ss4OzsrtXN0dFSckwqTVSIiIiIiIhXkQotk9f+HVt3d3ZXKZ82aBT8/P6Wyu3fvomPHjjn25+vrm6WdJsqVK4dy5copHnft2hXDhw/H6tWrFcmqgYEB0tPTldqlpaUBAAwNDbV+7txiskpERERERKSC+P8/mrYB3m2GZG1trShXdZuYGjVqICwsLHdBaqFMmTJ4+vSp4rGbmxseP36sVCdzxNfV1TU/Q1OiF2tWz5w5g3Xr1uHFixcqz6elpeHYsWPYunUrrl+/ns/RERERERFRUSQAyDU8MlNba2trpUNVspoX0tLSEBYWptgY6f21qZnOnz+PMmXKKB43b94cFy9eRExMjKLs8OHDcHZ2RoUKFfI+6GxIOrL6119/YcqUKTAyMsL169dx6tQpuLi4KNV5/fo1Wrdujfj4eFSvXh2jR4/GgAEDsHz5comiJiIiIiIiksbz588hl8uRnJwMQ0NDhIWFwdDQUJFH3b59G7Vr18apU6fg5eWFKVOmwN7eHm3atIGpqSkCAgKwc+dObNu2TdFn37594e/vj379+mHOnDkIDg7GkiVLsGDBAhgYSDe+KWmyamBggICAANja2maZ051p6tSpSEtLw/Xr12FpaYlLly6hQYMG6NSpEzp06JDPERMRERERUVGRmw2W8krLli2RkJCgeNywYUPY29vjxo0bAAATExO4uroqRnL9/PywdOlSjB07FnFxcahYsSL++ecfNG3aVNGHubk5Tp06hW+//Raff/45bG1tsXjxYowaNSpPr+VjJE1WM5PN7OZpy+Vy7Ny5EzNnzoSlpSUAoF69emjYsCF27NjBZJWIiIiIiPKMEFqsWc3je9cEBwfneL5KlSpK+ZWlpSWmT5+O6dOn59jO3d1dabRVH+j1BkuhoaGIjY1FlSpVlMqrVq2KK1euZNsuJSVF6ea1H24bTURERERE9DH6OLJalOjFBkvZyUwy7ezslMrt7e1zTED9/f1hY2OjOLKbYkxERERERJSdzGRV04N0Q6+TVXNzcwBAXFycUnlcXJzinCpTp05FTEyM4ggNDc3TOImIiIiIqPDRLlXNuvsuaUevpwF7eHjA2NhY6R5AAPDkyROlG9t+yNTUNN+2hiYiIiIiIiLd0+uRVRMTE7Rr1w4BAQGKhcovXrzAqVOn0KVLF4mjIyIiIiKiwozTgKUl6cjqgwcPcPr0aURHRwN4d9/Vhw8fok6dOqhTpw4AYP78+WjcuDE+++wzNGzYEJs3b0bdunUxYMAAKUMnIiIiIqJCjhssSUvSkdVXr17h/PnzCA4OxtChQxEdHY3z588rbbVcpUoV3LhxA7Vq1UJISAjGjh2LkydPwtjYWMLIiYiIiIiosJNr+Yd0Q9KR1caNG6Nx48Yfrefh4YGZM2fmQ0RERERERETvCJmAkGmWfGp6X1bKnl5vsERERERERCQVocU0YCarusNklYiIiIiISAU55JBpOK2X04B1R693AyYiIiIiIqKiiSOrREREREREKmTejEbTNqQbTFaJiIiIiIhUkMvkkGm4wRKnAesOk1UiIiIiIiIVuGZVWkxWiYiIiIiIVGCyKi0mq0RERERERCpwzaq0mKwSERERERGpIEcGZMjQuA3pBm9dQ0RERERERHqHI6tEREREREQqCAgtpgGLPIqm6GGySkREREREpAJvXSMtJqtEREREREQqvFuzqtnKSa5Z1R0mq0RERERERCppvhswOLKqM0xWiYiIiIiIVJCLDGi6J+27NqQLRSpZDTyxDsWKWUkdBhERaWyk1AEQkY5lzJstdQiUDzLSZVKHkCv6eJ/Vx48fY/Xq1di/fz/atGmDX375Jcf6ixYtwqpVq7KUGxoa4vbt2zAyMsKDBw/QoUOHLHW2bduGBg0a6Cx2TRWpZJWIiIiIiKigevToEdq1a4ehQ4fCyckJL168+GgbHx8feHt7K5V169YN7u7uMDJ6lw6mpKTg0aNHOHfuHBwdHRX1XF1ddRq/ppisEhERERERqSCQAaHhNGCRhxsslSpVCg8ePIBMJsOFCxfUauPg4AAHBwfF49u3b+POnTuYPTvr7IbSpUvD2dlZZ/HmlmavPBERERERUREh1/JPXjEyMoJMlrup1evXr0eJEiXQtWvXLOe6dOmCWrVqoVevXmonw3mJI6tEREREREQqCAgt1qwKAEBsbKxSuampKUxNTXUWmzZSU1OxZcsWDBkyBMbGxkrnevXqBV9fX1hbWyMgIACNGzfGwYMH0a5dO4miZbJKRERERESkkhAZENBsJFP8/27A7u7uSuWzZs2Cn5+fUtmtW7eyrCf9kI+PD7777juNYsjO/v37ERUVBV9fX6XyypUrIyAgQPG4fv36CA0NxYwZM5isEhERERER6Zt3U3o1G1nNnAYcGhoKa2trRbmqUdXy5cvj8OHDOfZna2ur0fPnZP369fDy8kL58uWVyg0NDbPUbdq0KQ4ePKiz59YGk1UiIiIiIiIV3m2wpOHI6v9vsGRtba2UrKpiamqKcuXKaR2fJsLCwnD06FFs2bJFrfpPnz7VaaKsDW6wREREREREVEjcvXsX5cqVy7JB0saNG2Fra4sePXpkabNy5UqcO3cOQrxbb3v06FGsWbMGPj4++RJzdjiySkREREREpIIQcs03WBJ5txswANSsWRMJCQmIiIgAAJQrVw729va4ePEigP/dMzUpKem9mAQ2btyIAQMGwMzMLEufjRo1wtdff43Lly/DwMAAMpkM06ZNw7fffpun1/IxTFaJiIiIiIhUyM2a1byyZ88eyOXKz/H+mtMqVargwYMHcHV1/V9McjmOHj2a7T1Ua9WqhePHjyMxMREJCQkoXrx43gSvISarREREREREKuRmN+C8UqZMmRzPm5iYZFkHa2hoqNbaWAsLC1hYWOQqPl1iskpERERERKRCbu6zSrnHZJWIiIiIiEiFd2tWNR1ZzdtpwEUJk9VCKDk5GdevXUV6egZq1KwFKysrqUOiPJKUlIQb14OQnp6BmrVqoVgxvteFVVJSEq5fu4qMDDlq1a4NS8tiUodEeSQxMRHXr12FEAI1a/G9LswSExJw/XoQAKBmzdqwsLSUOCIiyipDi3HSvJ0GXJQwWS1k7ty+hbFfDoeNrS3MzMwR8vQx5i9ahqbNWkgdGunYzRvXMW7MCDg4OMLE2ATPnj3FgsU/oXGTZlKHRjp243oQxo0ZieKOJWBkbITQ0GdYvPQXNGjYWOrQSMeCrl7BhHGjUKKEEwwMDREeFoYlP/2KevUbSB0a6diVyxcxYdxoOLu4QCaTIeLFCyz5aTnqflJf6tCIiPSG5PdZDQsLg5+fH3r37o07d+5oXYfebUk9dcokNGjUGH/uO4jtO3ejV5/++O7bb5CQEC91eKRDcrkcUydPRNNmLfDHngPY/vuf+KxnL0z79mskJiZKHR7pUEZGBr6dPBEtW7bBrj2B2PH7HnTz/gxTJ09CcnKy1OGRDqWlpWHq5Ilo07Y9fv8zEAG79qJTl66YOmUSUlNTpA6PdCgtNRXfTp6IDp264Pfd+7Hzj31o264Dpk6ZhLTUVKnDI6L3CCHX6iDdkDRZXbJkCZo2bYrXr19j586dePnypVZ16J2bN6/j6ZPHGDhoiKKs34DBiIuLxZl//5EwMtK169eCEBr6DAMHD1WU9R/kg5i3b/Hf2X8ljIx0LejqZYSHhWHg4P99rwcO8sGbN69x/r+zEkZGunb1yiW8ePEcAwf973s9cPBQvHoZiQvnz0kYGenaxYsX8DIyUvl7PXgIIiMicOnSBQkjI6IPMVmVlqTJavfu3fHo0SNMmTIlV3XonQfBwZDJZChfoaKizMHBAQ6OjngQfE/CyEjXHty/9/9bkJdXlBUvXgJ2dvZ4cD9YwshI1+7fD4aRkTE8y5RVlDk5u8DGxhb37/N7XZjcDw6GmZkZPEqVUpSVLOkKKysr3A/m97oweXD/HiwtLeHq6qYoc/coBXNzC/4bTqRn5Fr+Id2QdM2qp6enTurQO/HxcbC0tFS6KTAA2NrYITYuTqKoKC/ExcWjWDEryGTKu9PZ2NoiLjZWoqgoL8THxcHaxjpLuY2tLeL4vS5U4uPjYGVtk6X83XvN73VhEh8XB2sV77Ut/w0n0jvvRkm5G7BUCuUGSykpKUhJ+d/6ntgi8g+/sYmJyjVsiUkJMDU1lSAiyismJiZITk7KUp6UmAgTvteFiomJCZKTVHyvExNhamIiQUSUV7L7XicmJvLf8ELGOIf3mv+GE+kXITTf2VebNqSa5Bss5QV/f3/Y2NgoDnd3d6lDyhdubu5IT0/Hq1f/W9eblpqKqKgouLkVjdegqHBzd0dKSgrevHmtKEtNffeY73Xh4ubmjsTEBMTGxCjKkpOTEf3mDVyLyL9tRYWrmzvi4+IQH/+/EfPExETEvH3L73Uh4+bujpiYGCQmJCjKEhLiERcXy/eaSM8ICAjINTw0v9kNqVYok9WpU6ciJiZGcYSGhkodUr6o+0k9mJmb49jRw4qyf/75G6kpKWjcpKmEkZGufVKvAczMzHD8vff671MnkZaWhsZNeeuawqRe/YYwMTFR+l6fOnEMQsjRuDG/14VJg4aNYGRkhOPHjijKThw/CplMhoaNm0gYGelaw4ZNYGBgiBMnjinKjh09DAMDQzRoxPeaiChToZwGbGpqWiSnTFlaFsPYcRPw05KFiI+Lg5mZOdavXYW+/QbC3aPUxzugAsPKygpfjhmPxYvmIzY2FsYmJli3eiUGDPJByZKuUodHOmRja4uRX47Dwvlz8fZtNIyMjLB2zUoM8vGFk7OL1OGRDtnZ2WP4yNGYP/cHvI56DUNDA6xdvQI+Q4ejePESUodHOuTg6Ajf4SMx78fvEfX/s6HWrl6BYSNGwcHBQeLoiOh92qw/5ZpV3SmUyWpR1n+gD0p7lsHxY0eQkZ6Ob6fNQPuOnaUOi/LAIB9flPYsg1MnjyMjPR3TZvihA9/rQmnosBEoW7YcTp06Drlcjpl+P6Btuw5Sh0V5YPjI0ShXvgL+PnUCQgj4zZ6LT/leF0qjRo9D+QoV8c/pUwCA2T/OR5tP20kcFRF9iMmqtGRCCMkmVZ8+fRorV65EUlIS9u/fj5YtW6JEiRLo2bMnevbsqXadj4mNjYWNjQ3OXriKYsWs8vKSiIiIiEgN7vNmSx0C5YPY1FSU3rkTMTExsLbOuru9vsrMH0xN3CCTabZyUgg5UlLDCtw16yNJR1Y9PDzg7e0NAOjTp4+ivFKlShrVISIiIiIi0jWOrEpL8vusfuw+qurUISIiIiIi0jUmq9LimlUiIiIiIiKVtEk8mazqSqG8dQ0REREREREVbBxZJSIiIiIiUoHTgKXFZJWIiIiIiEgFocWUXm3akGpMVomIiIiIiFR4d5dPzZJPCe8MWugwWSUiIiIiIlIpA4BMwzZMVnWFySoREREREZEK79afapas5vXI6v3797Fv3z6Eh4ejbNmyGDBgAGxtbXNsI5fLsX37dly4cAG2trbo168fKlWqpHGd/MbdgImIiIiIiFSSa3nkjSVLlqB79+549eoVPD09sXfvXlSoUAGPHj3KsV3Pnj0xffp0uLi44NmzZ6hVqxb+/fdfjevkN46sEhERERERFQBdunTBV199BQODd2OOY8aMQe3ateHv749169apbHPkyBHs2bMHt27dQtWqVQEA6enpGD9+PK5evap2HSlwZJWIiIiIiEgVIdfuyCPly5dXJKoAYGhoiHLlyiEyMjLbNoGBgahRo4YiCQWAvn37IigoCOHh4WrXkQKTVSIiIiIiIhWEln8AIDY2VulISUnReXwhISE4cuQIWrVqlW2dhw8fonTp0kplnp6einPq1pECpwETERERERGppPkGS5m7Abu7uyuVzpo1C35+fkplISEhmDNnTo69ffrpp+jVq1eW8ri4OPTo0QM1atTA6NGjs22flJQER0dHpTIrKyvFOXXrSIHJKhERERERkUpCizvRvGsQGhoKa2trRampqWmWmpaWlmjYsGGOvZUqVSpLWUJCAjp16oSMjAz89ddfMDExyba9tbU1oqOjlcrevHmjOKduHSkUiWQ1c/vohPh4iSMhIiIiIgCITU2VOgTKB3FpaQDy/nYueed/03o1ZW1t/dFEz9HREb6+vhr1m5iYiE6dOiEmJgYnT56Evb19jvWrVauGgIAApbLbt2/DwMAAlStXVruOJEQREBoaKvDuVxw8ePDgwYMHDx48ePDI5yM0NFTqlEAjSUlJwtnZWevrdXZ2FklJSTqPKzExUXh5eYkaNWqIV69eqazz7NkzMXToUHHv3j0hhBBBQUFCJpOJwMBAIYQQaWlpomnTpqJjx46KNurUkYJMiAL7aw61yeVyPH/+HFZWVpDJNJ1zXjDFxsbC3d09y/QDKnz4XhcdfK+LDr7XRQff66KjqL7XQgjExcWhZMmSSrvYFgTJyclI1XIGgImJCczMzHQcETB69GisWLEC3t7ecHBwUJR7eHhg5syZAIBr166hdu3aOHXqFLy8vAAAP/74I/z9/dG6dWs8evQICQkJOHnypGITJXXr5LcikawWRbGxsbCxsUFMTEyR+gexKOJ7XXTwvS46+F4XHXyviw6+16QLp06dwqNHj7KUOzg4oHv37gDerTX9888/0alTJ7i4uCjqBAcH49KlS7C1tUXr1q1hbm6epR916uQnJquFFP9BLDr4XhcdfK+LDr7XRQff66KD7zWR5grWWDwREREREREVCUxWCylTU1PMmjVL5RbZVLjwvS46+F4XHXyviw6+10UH32sizXEaMBEREREREekdjqwSERERERGR3mGySkRERERERHqHySoRERERERHpHSarRERE+SgpKQlDhgzB/v37pQ6FiIhIrzFZJSKSWGhoKOrUqYOQkBCpQ6F80LFjR6SkpKBVq1ZSh0JERKTXuBswEZHE5HI5WrRoAZlMhr///hsGBvw9YmF1//59VKxYETEx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0hLnz5/HypUr4ePjg+joaJw7dw4ZGRnYu3cvAKBjx45ZktWP9ZPpY9fx77//olu3bqhcuTJsbGxw7949LFiwAD179lRM8f3iiy+yjSsyMlJRR93nzIy9cePGSEhIQLFixXD69GmsWrVKKXYiIiL6gCAiogIvLS1NABA1a9YUcXFxQgghHj58KCwtLcX27duV6jRv3lwkJycr2v7xxx/C1NRU3Lp1SwghRHJysmjRooXo0qWLos6uXbsEALFy5UpF2dChQwUAsXHjRkVZr169RPfu3bO0mzhxoqJs5cqVolixYuLly5dCCCECAwOFqalpjtenTj/qXEfnzp3FuHHjFI/j4+PFkSNHhBBCHDp0SBgaGirOqYrrwzqavHbLly9XlC1atEiUKFEix2smIiIq6rhmlYioEBk1ahSKFSsGAChbtiw+++yzLLvXjhgxAqamporHAQEB6NatG6pWrQoAMDU1xTfffIPAwEAkJCQo6hkaGmLo0KGKx40aNYKZmRkGDhyoVHb//v0scU2ePFnx92HDhsHY2BgHDx7U+Ppy6ked6zA3N8eTJ08QFxcHALC0tETbtm01jiOTJq/d8OHDFY+9vLzw8uVLvH37VuvnJiIiKuyYrBIRFSKZU08zeXp6IiQkRKnMxcVF6XFISAjKlCmjVFa2bFkAwLNnzxRlxYoVU1qLaWpqChsbGxgYGCiVJScnK/VlZmamtI7V0NAQHh4eWeL6mI/1o851+Pv7Iy4uDk5OTmjZsiUWLlyolFRqKjevHYAsrxURERH9D5NVIqJCJDo6OstjR0dHpTKZTKb02NHREW/evFEqy3z8YVttJCcnIykp6aNx5bYfda6jbNmyOHXqFJ49e4bRo0dj+/bt6NKli0ZxvC+vXzsiIqKijMkqEVEhkrkZEACkpaUhMDAQTZo0ybFN06ZN8ddffyElJUVRtmvXLlSoUAHFixfXSVz79u1T/P3KlSsIDQ1F48aNAbwbdUxLS4NcLs9VP+pcR3h4OIB3iWTPnj0xbdo0XLhwQeVzqRNXfrx2RERERRV3AyYiKkROnDgBHx8fNGrUCAEBAUhLS8P48eNzbDN+/Hhs2rQJrVq1wsCBA3Hr1i2sXr1aKTHMDWNjY3z99dd49OgRLC0tsXDhQvTt2xe1atUCAFStWhUmJiaYMmUK6tatq/LWNer0o851DB8+HNbW1mjWrBmEEFi+fDl69uypMm5VcX0or187IiKioowjq0REhciWLVtQu3ZtXLx4EQ0bNsSFCxdga2sLADAwMECvXr1QokQJpTaWlpaKe5j+999/MDIywvnz59GhQwdFHXd3d3z22WdK7UqXLg1vb2+lsnLlyqFz585KZRYWFvjnn3+QmpqKa9eu4bvvvsPGjRsV54sXL47jx48jJSUF+/btw82bN1Ve28f6Uec6AgMD4e3tjdu3b+POnTuYPn061q9fD+DdWt5evXrlGNeHdbR97WxtbdGrVy+Ym5urvFYiIiICZEIIIXUQRESUO+np6TA2NsapU6fg5eUldTgKf/zxB3x9fXO9662u+iEiIqKCgyOrREREREREpHeYrBIRFQLZTfGVmqopsFL2Q0RERAUHpwETERERERGR3uHIKhEREREREekdJqtERERERESkd5isEhERERERkd5hskpERERERER6h8kqERERERER6R0mq0RERERERKR3mKwSERERERGR3mGySkRERERERHqHySoRERERERHpnf8DCdyAae3PEwIAAAAASUVORK5CYII=", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA6oAAAG4CAYAAABM/RqnAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAcMFJREFUeJzt3Qm8TPX/x/HPte87SdnJEklEll/xSxEtSguRJT9KJEuKfiFKUshSkVLapJQsFeUX1S97pFWiiLKVfcl6z//x/vab+c+95i5z3Wtm7ryePU733jPnzD0zZ8adz/l8vp9vnOd5ngEAAAAAECGyhPsAAAAAAAAIRKAKAAAAAIgoBKoAAAAAgIhCoAoAAAAAiCgEqgAAAACAiEKgCgAAAACIKASqAAAAAICIQqAKAAAAAIgoBKoAAAAAgIhCoAoAOGNPPvmkVa1a1eLj43k2o1Dnzp2tXLlylhktWLDA8uXLZ3/88Ue4DwUAEAICVQDpatq0aRYXFxd0GThwoH87fShOarujR4+6bVatWmW9evWyCy+80PLmzWtlypSxW2+91X766acUj+Oiiy5y23uel+Q2jRo1snPOOcdOnjxpmzdvdr979OjR/ts//fTTBMeVPXt2q1ChgnXs2NF++eWXM36uMosDBw7YqFGj7MEHH7QsWfizEqm2bdtmjzzyiK1du9ZiSYsWLaxSpUo2cuTIcB8KACAE2ULZGABSa/jw4Va+fPkE62rUqJHg54svvtj69+9/2r45cuRwXxX8LFmyxG655RYXeO7YscOeeeYZu+SSS2z58uWn3V+g9u3bu8D4v//9r11++eWn3a7AdNmyZS4QzpYt+X8Ke/fubZdeeqmdOHHC1qxZY1OmTLEPPvjAvv32WytVqpTFupdeeskF++3atQv3oSCFQHXYsGHuIpHee7Hkrrvusvvvv989/vz584f7cAAAqUCgCiBDXHPNNVa3bt1ktznvvPOsQ4cOSd7er18/mz59uj9wldtuu81q1qxpTzzxhL3++utJ7nv77bfboEGD3P7BAtU333zTZVsV0KbkH//4h918883u+y5dutgFF1zggtdXXnnF/Y5wOnz4sMs2h9PLL79s119/veXKlStTPS5EJpWXHz9+PKTXW5s2bezee++1mTNn2p133pmhxwcASB/UaAGIWA0bNkwQpErlypVdKfC6deuS3bd06dIuQH3nnXdcJjQxBbAVK1a0+vXrh3xc//znP93XTZs2JbudyoWVsZ09e7bL/ubMmdMdu8bMJfbVV1+54L5AgQJuPN2VV17pssbByqo/++wzu+eee6xEiRJ2/vnnu9uaNGnifsc333xjV1xxheXJk8eVO+rxi/bRY82dO7dVqVLF/vOf/yS474MHD1qfPn1ctk3Hqfu+6qqrXAY5OXoO9DubNWt22m27d++2O+64wz2mQoUKWadOnezrr792j0GPJXB8pB7zzz//bC1btnQZL98FBGXElVFXGbeOS+e1b9++9tdffyUIlHWfeg4Te/zxxy1r1qz2+++/29myb98+91zqWHXMOg+qDvCN39UFkqZNm1rx4sVt165d/v0UfOkijF6XCtTl119/deda50znrmjRou75UEVAsN+r58Z3DvXaUJn6n3/+6crYVRXgu9jiK2cPPA8+Oj7dxw033HDabSrLL1iwoMtQJkXnM6myfpUe+xw7dsyGDh3qnh/fuX3ggQfc+mDvozfeeMO9f7St7z2UmveN6PWsqow5c+YkedwAgMhCRhVAhti/f7/7gByoWLFiCX5WAJl4GwVYWpKiD9E7d+50H1hTomCne/fu9tFHH9m1117rX6+S3e+++86GDBliaaGAShQ0pOSLL76wWbNmuWBDAdiECRNcdmfLli3+/b///nuXtdWHbX1Q11jY559/3gWfvgAzkO5LQY6O3xfQyN69e93jbNu2rQtmJk2a5L7XB3wFTnfffbfLND/11FMuQ7x161Z/GaRuU1CrgKB69eouyNSx64KASq2TsnTpUvc18TYKyq677jpbuXKl9ejRwzVaUpCgYDUYlQ43b97cGjdu7MYJ+14DyoAdOXLE3YeeL93fxIkT7bfffnO3iR5Lz5493eOsXbt2gvvVOj2Pyt4nRa9DvV5To0iRIsmOw9Wx6kKBAmMFcwqw9Rwp8759+3YbN26cC7xULq3ASc+7Xh+ioE2vBQWVvmyyxmlrf51HBZ4KUHVe9Zh++OEH//N06NAh9xrS+VLGUOdD7625c+e656patWquHF+vGb0ntK3vYlBiOj5VOqhB1p49e9xj9pk3b54bk5xcJYQed+ILFwosdS4UMPpeH8rC6zWm49Hx6X359NNPuzHourgTaNGiRfb222+716f+HVEgHer7pk6dOqfdLwAggnkAkI5efvll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DgAAAABkVpT+hk+amin9+9//toceesj27NmT/kcEAAAAABFU+hvqgjOXpjGqzzzzjG3cuNFKlSplZcuWtbx58ya4fc2aNelwaAAAAACAWJSmQLV169bpfyQAAAAAEEE03jTUMaexNkY1ogLVoUOHpv+RAAAAAEAEiff+XkLdB2Eaoyr79u2zF1980QYNGuQfq6qS399//z0dDgsAAAAAwosxqsn766+/3DyqP/zww2m3HT161F599VU7q4HqN998YxdccIGNGjXKRo8e7YJW32SuClwBAAAAILOU/oa6xIKffvrJqlWrZpdffrnVrFnTrrjiCtu+fbv/9v3791uXLl3ObqDar18/69y5s23YsMFy5crlX9+yZUv7/PPP03wwAAAAABBppb+hLrHgwQcftBo1atiuXbts/fr1lj9/fmvUqJFt2bIlXe4/TYHqqlWr7K677jpt/XnnnWc7duxIj+MCAAAAgLAio5q0pUuX2siRI61YsWJWqVIlmzdvnjVv3tz+8Y9/2C+//GJhCVRz5sxpBw4cCJr+LV68uKUnjXnt0KGDFS1a1HLnzu3Syl9++WW6/g4AAAAAQGjjU7Nl+//evHFxcTZp0iS77rrrXBmwYsOz3vX3+uuvt+HDh9vbb7/tPyileJX+bdOmjaWXvXv3uvRx06ZNbf78+S4IVrlx4cKF0+13AAAAAEAwdP1NWtWqVV0CUeNUAz3zzDP+mPGsZ1THjBljhw4dshIlSrhIWhGz0r2qSx4xYoSlFzVrKl26tL388stWr149K1++vF199dVWsWLFdPsdAAAAABAMXX+TduONN9qbb74Z9DYFq+3atTPP885uoFqwYEFbuHChq0OeMGGC9erVyz788EP77LPPLG/evJZe5s6da3Xr1rVbbrnFBcW1a9e2F154Idl9jh075sqSAxcAAAAACJWXxiUWDBo0yMWASXnuuecsPj7+7AaqvsGxjRs3tnvuucceeOABa9asWZoPIrnfozrnypUr20cffWQ9evSw3r172yuvvJLkPhrQq0DatygjCwAAAABpaaYUalY1VqanScmPP/7opjQ9q4Gqynw1bvT11193E7lmFEXgl1xyiT3++OMum9q9e3fr1q2bTZ48OdnIXnP2+JatW7dm2PEBAAAAyLzi07jAXKXrzz//fHYD1TVr1thFF13k5lMtWbKkm6pmxYoV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jRo1yv1M4JI56bwePHjQbrrpJvfztGnT3Ffe45kT7+PYOc+qiFGlhKxevdqOHDkS7sMCMgUCVSDCBP7Be+2112z8+PH2wQcf2K5du/y3I3Np2LChPf300/bvf//b1qxZ4wIXPuRmzgsSl19+ud19992WP39+d3Hi6NGj4T4sZNDFRt8FiGPHjvEcZ+ILyjrPWbJkcf92X3TRRTZs2DBbt24df6uBdMA8qkAEUlB6/fXX2969e61MmTLuw+zJkydt6dKlZF8yqf3791unTp1s/fr17kMOMicFql988YU1b97clXwj89FFJl+Q+sQTT9i3335r2bJlc/+mt2nTJtyHhzPUoUMHe+yxx6xcuXL+dbqo3KtXL+vZs6fdcccddv7557uLUQDODBnVKEOWJTbOsUpA9Yfu66+/toULF1q3bt1sxYoV9uKLL4b78JAOgmXFCxYsaCNGjHAB67333uvW8X7PXGPW9H4uVqyYG6e6e/du+/DDD916qiQyFwWpeh//85//tOeff94uu+wyl3F78sknrW/fvuE+PJyBDRs2WJEiRVx1ROD7fNGiRda9e3d7/PHHrVq1ai5I/fPPPykBBs4QgWoEC/Yh1XeV9qeffnLZtc8//zzJbRH5gp03jWGbPXu23XXXXZYrVy4bNGiQC1yGDBlid955Z7L7IvLpQ40+tMo333xjv/32mwta5IILLrCnnnrKnn32WdcVlhLg6D7PvhJ+neMTJ05YrVq1bNasWS5YKV26tCv33rlzp3s9EKxmLh9//LF7/+o9rn+/lWVTpcSmTZsIXqJY5cqVbcKECe6CkzLlcujQIVuyZIn7m/zpp5+6v9W3336721YZVr0GAKQNgWoESRx4+IJS36B8BS/jxo1z3SK1XHnllfavf/3L3U4zjuihEl45fvx40POmD7ga56IPNPXr17c5c+bY3LlzXadQffCdN2+e+9DLOY9OOod6r+uDzI033mhXXXWVXX311fb7779b9uzZ3Tpl3Lp06eIuWnCeo/c8K0BVVk3Nkxo1auTKQKV69ep2yy23uH+7lYERBatcfIo+usAQ7LytXbvWypYt6zJrDzzwgLVu3dr69+9v77zzjuXJk4dzHeXef/99d+FJyQJVw/Tu3dut02ezX3/91WVVVSGj14H+fgNIGwLVCKErchqbFhjI/PHHH/boo4+6zFqNGjXc+If//ve/Vrt2bVcaWrJkSbv//vstd+7cYT56pMT3QaZHjx7WsmVL932OHDncVzVe0HmeMWOG+7lw4cLu9aCGK/pjpz90V1xxhb908M0333RNGxD559x33gM/yP7yyy/uAsT27dtddu29996zvHnzWtu2bd3YZH2Iffjhh61QoUKu5BvRSdmWevXq2XnnnecuMl1yySU2depUl1kTjVXUOFWNV50+fbpbR6AafUGqLjDoYpKGaOg8b9u2zd2mixAq/9V7XRcXFcQMHTrUjVX96KOPCF6inP426wKUMqaiz2fvvvuuq3abMmWKDR482O655x7LmTOn+zcAQNoQqEaIiRMnuqvsoj9ksmzZMheYqkRM/+BpDj5NZ6CrdPqHUGWCar5CxiXyP8z4zpGutuqcvvLKK+5nXZGdOXOm++CiDJovu6ILEcrI6Iq8SgOVfV21apXbRhnXKlWqhPUxIWU653/99Zf/ex+9jxs3buxKe3X+NfZYQY1Kx1QyJjrvqp54++23XXCD6KOOvpUqVXLjynVxatKkSe59/dZbb7mLTcqeK6uu6onhw4fbxo0b/SXhiA6+86WLirfeequ7uOwLVHVudRFKGVX9LVcTLVHlxOuvv+6+qjIG0alixYrWp08f19HZd0Hxwgsv9Dc/PHz4sPXr18+NU/V9tgOQBh4iwqlTp7wGDRp47dq1C3pboOeff9676qqrvK1btwa9HZEhPj7e//26deu8u+++21u1apX373//28ufP783b9487/7773e3796925s0aZKXJUsW7+OPP3brRo0a5dWuXdvLmzev16pVK69gwYLePffcE7bHg9C899573gUXXOC+X79+vdexY0fvp59+8nbs2OGtXbvWO3nypNe9e3fv/PPP995880236PzPnj3b7XPs2DFv/Pjx3nfffcdTH4Ueeughr0aNGgnWHThwwJ3zRo0aeUePHnXr5syZ4w0cONA7ePBgmI4UZ2LChAletWrVgr5Pe/fu7ZUoUcJ7+eWXvZUrV3qLFi3yatWq5f3jH//wfvvtN574KKf38JQpU7wCBQr4/93etGmT16lTJ69OnTpe1apVvW+++SbchwlENQLVMNAH1MDvfQGNgpkcOXJ4b731lvv5+PHjp+27ePFiF7A8+eSTZ/GIcSbnWAFnXFyc+9AiO3fu9P75z3+6dQ8//LB/O53vDh06eGXLlvUOHTrk1v3yyy/eq6++6r322mve8uXLORERThchfHS+KlSo4DVu3Nid6379+iW4ePHRRx+5CxFLlixxP+urtitZsqS3cePGBPfLxajo8/TTT3uXXXaZ9/nnnydY/8gjj3j16tXz9u/fH7ZjQ/rQv9lXX32116tXL/fziRMnEnyVzp07e5UrV3YXrcqVK+f17NmTpz8T2b59u9etWzevdOnS3r59+/zJhIkTJ4b70IBM4e8aU5xVKulUuYjKAjUOTVTeWbVqVRs9erQr71SJaIECBRJ0jty8ebMrHVOZyYABAzhrETx/ns7ZgQMH3LlcvHixa7igck8pXry4m4NNTXN8jbJUHqxSwJEjR9o//vEP1yXypZdesvLly7sFkU/jh1XWvWfPHjd9gcqzdV5V0qvSTo07DSwD17g1lQ42bNjQ/ayyQY1z+u6771wTpUCUhEbfvwHXXnutew9r3FqFChX849TUg6BEiRJuXDKiQ+Df4UD6N1tzXfv6DfiG7fi+ikq/9d72baeSUWQe6hWi3hPqG9GqVSs35lzT1ABIH3GKVtPpvpBKClDr1q1rTZs2ddMT3HzzzW7AvRpq6A+cPuDog6rGOAV+8NG0Fa+++qob36jGHL5GDog8GoOmCwoKRJs0aeLmWAv8wKMPq5qCRF0gNfZUDbJ851lzK+o1oLGrTA4fnfShVIGIpjHQ+FNdrNAUBbowpfHG+sCqLt5qxqG5FfWzLlKpqZbGnSO6+f5tfvnll91YY3UF7dChg+sCPHbsWBs/frzr7IzI5/t3WdSxV0Gnxh6rW7fey2qmow7t+ltes2ZN92+7/o5/9dVXbnyiuvPzdzpz0znX33z9Lde/+YGvGQBnKNwp3Vi1cOFCL2fOnK6Mt0mTJq58xEdj2IoUKeKNGTPGX0aqpX379m5cIyJbs2bN3JgVjV1ReadKOZ944onTSoL37t3rXX/99W58U2IvvPCCG7eK6PPcc895xYsX95/r1atXuxLfG2+88bRS3gceeMCNWVO597PPPuu/PbBEGNEn8PxpzPnNN9/sSsAvueQS928Cous86r2scYfqF6BybpXojx071t22YMECr379+t6dd97pL/lVWbf+bdc+R44cCetjwNl5jQQbqgXgzBGonkWBQcq0adPcH7tixYp5u3bt8v+D5/tH75133vGyZs3qmq8ENuJIvB0ij8Yk+cYYaszKsGHD3AWJr7/++rQ/aGrAcd5553n33Xef+5nzGr3vaR81z9A51bglX1OkGTNmuItPugAReEFKAasuWASOV2Q8auaQ+DzqHPP+jj7Lli3z5s+f791xxx2ugaHOq4JUXYycOXOm22by5MlezZo13QWq6667zr3/1TDpzz//DPfhA0BUo/T3LNMUBJqOQq3sVRrUsWNHV8araWd841x8/vWvf9nSpUvddBb/y35TThKF5X8653379nXTEfjmP/WVAOucaqoClXtqbItvvCKia9ya5kRVqa/Gleucqmxb86JqfKLGIqtcUKWBWlQ+qHOt6WlU7q/5+IRSfiDylCtXzrZs2WJ33nmnG2/q07p1a/v111/97+Hdu3fb888/797HpUqVctsDAM4MgepZpnEtmmfLNzeixq5p3eTJk11gGvhhVWNZNf+WPuhqXBMfZKOXzrM+uPjOZeBFB8239vHHH7vbEF0B6rp166xdu3buvan3q+bB1Zg0NVPS3IoKSjds2GCFCxd24xPVUElBqsak60NvgwYNwv1wAARpmOT7+eeff7YaNWq4XhKavzx37tzu9n379rn16icwePBgf7MsAED6oRPPWaYPsMq6iIIVNVR68MEH3fLjjz+6IFWByyeffOL+IKp5kgbnq5uobtOgfUQPX6+yyy67zDVOUnDyn//8xwWpvsnelYkjSI0evg+z6tTduXNn181ZTZAuv/xyGzRokGuKpMBVnX7VEVJVE3L++ee7yom5c+fa999/74JUbQcgcoJUVTH99NNP/vemuvSOGDHCXXRSB2/fv+lqjKaGeApe1cFb9wEASF9kVDPYBx98YO+//75rX67yoGbNmrmgRdPMBHaA1YdcTWtxyy23uOyqvuqPoAKaf//73/bMM8/Y/v37M/pwkYFU+qsr73o9qJQsV65cPN9RSB9Ub7/9dtu1a5cr8dMUJJqmQoYMGeI6PGt6odtuu81937JlS/dBVxcqUjPlBYDw/Pus96wqH/Te1LRg+husTr5yxRVXuIvIb7/9tptuyOehhx5y2VYN4QEApC8yquko8Uw/+mP35Zdf2vLly914losvvti1sxfNo+r7kKpW9gpo9YdO41c1x+Zzzz3nLw1VtuaGG25wY9oQvVQapvJffcjRVCXMDBUdEmc99b5Uub7KuTU2NfA8aqoKVT6ovFc05vi+++5z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tGn29ttvuxJgZVWXLVvmOvz6glQ1VhKCVAAAAAAIn6jMqCqg9AWiGlOqn5VJLV26tFunct6cOXO6Ut8yZcr49+vQoYOtWLHCBaxqqpQ48GUsKgAAAACEX1RmVBWkKr5Wo6Rbb73V2rZta7fffrs9/vjj7vaZM2faypUr7cUXX7QjR47495syZYoLRoMFpASpAAAAABAZIj5QVcZ0x44d7ntf8ldTy2gM6vbt2+3jjz92zZOuueYae/jhh2358uVWvnx51xhJ5b6fffaZfz81VPr222+tQYMGYX1MAAAAAIAoDVRVpnv//fe7caWBY0fVDEnBqUp4K1SoYLNnz7ann37aZVUvuOACt02PHj2sWbNm1q1bN9uwYYP/PnPkyOEPXAEAAAAAkSeiA9X69evbFVdc4cp4Fy5c6F+vQPPcc8+1ffv2udLfgQMH2tixY+3111+3IkWKuKypaK5UjVHNmzdvgvulWRIAAAAARK6IDVR9HXjvuusuy5Ytm82bN89+/vlnt07jTjXljDKmClZVHqxSYFFQq3lRN23aZPnz57elS5faeeedF9bHAgAAAADIBIGqGiapxFfdfNWt97fffrP33nvPjh49atWqVbPbbrvNjVXt27evPxDVNo888oj99ddfVqBAgWSnsgEAAAAARKaInZ7m3XfftaFDh7pmSEWLFnWlvZr3VKW+6vYraoqkwLVw4cJuShqNWb3sssvcmNZcuXKF+yEAAAAAAKI9o+rLfCqT+tZbb7nSXgWp0r17dytWrJgbq7p69Wq3btasWfbggw+6jOrBgwftqaeesrlz57og1Vc6DAAAAACILhGZUR08eLC9+eabNn36dKtXr54LYDXP6apVq+zJJ5+0mjVr2r333usyqT6+bURBqkqHAQAAAADRJ0ukZVPVuVfLpEmTXJAa2KX30ksvtebNm9vatWtdcyUfxdoKUn0xN0EqAAAAAESviAhUfYHm8ePH7Y033nBjUJs0aeK/XU2T1OlX7rzzTjd36ieffOI6+gYGskw7AwAAAADRLyICVQWYaor0r3/9y/bu3WuPPvqoHThwwDZu3GiDBg2yXr16WenSpW3ChAkuoNWUNQpqlVXdvn27u48IrGAGAAAAAETzGNWPP/7Yunbt6qad0TQzCli///57K1eunGuqdM4557hsao4cOVxgq67AL730kl177bXWo0ePcB8+AAAAACCdZLMIoFj5008/dV//85//uLGo7dq1c8GpppsJbJTki6vbtGljv/zyi1t/+PBhy5s3b5gfBQAAAAAgU2VU9+3bZ3v27LH8+fNb8eLFk93WF7hq3KoaJ+XMmfOsHScAAAAAIEYC1UC+Q6I5EgAAAADEnogo/U2MABUAAAAAYldEdP0FAAAAAMCHQBUAAAAAEFEIVAEAAAAAEYVAFQAAAAAQUQhUAQAAAAARhUAVAAAAABBRCFQBAAAAABGFQBUAAAAAEFEIVAEAAAAAEYVAFQAQ8zZv3mxxcXG2du3aZJ+LJk2aWJ8+fWL++QIAIKPFeZ7nZfhvAQAgiEceecRmz56dYoCY0U6dOmV//PGHFStWzLJly2affvqpNW3a1Pbu3WuFChXyb7dnzx7Lnj275c+fP6zHCwBAZpct3AcAAAif48ePW44cOWL+FGTNmtVKliyZ4vNQpEiRmH+uAAA4Gyj9BYBMQmWpvXr1ckvBggVddnDw4MEWWDhTrlw5e/TRR61jx45WoEAB6969u1v/7rvv2oUXXmg5c+Z024wZMybBfWvdY4895vbLly+flS1b1ubOneuykDfccINbd9FFF9mXX37p32fatGkuG6mMaeXKlS1XrlzWvHlz27p1q//2YcOG2ddff+3KbrVoXTCdO3e21q1bu+2LFy/ujv3uu+92gbbPsWPHrHfv3laiRAn3uxo3bmyrVq3y367saPv27d3+uXPndsf08ssvn1b6q++VTZXChQu79fr9wUp/dZ96TrRdnjx57JprrrENGzac9hx89NFHVq1aNfc8tWjRwrZv357m8wwAQCwgUAWATOSVV15xpasrV6608ePH29ixY+3FF19MsM3o0aOtVq1a9tVXX7lAdvXq1Xbrrbda27Zt7dtvv3XluFqfOGh8+umnrVGjRm6/Vq1a2R133OGCtA4dOtiaNWusYsWK7ufAwPjIkSM2YsQIe/XVV23JkiW2b98+93vktttus/79+7sAWYGbFq1LyieffGLr1q1zZblvvvmmzZo1ywWuPg888IALuPUc6HgqVarkAmOV64oe0w8//GDz58939zNp0iQXzCdWunRpdz+yfv16d1x6LoNRAKvgXEH7smXL3GNv2bKlnThxIsFzoOf8tddes88//9y2bNli999/f4rnEgCAmKYxqgCA6HfFFVd41apV8+Lj4/3rHnzwQbfOp2zZsl7r1q0T7Hf77bd7V111VYJ1AwYM8KpXr55gvw4dOvh/3r59u6JRb/Dgwf51y5Ytc+t0m7z88svu5+XLl/u3WbdunVu3YsUK9/PQoUO9WrVqpfjYOnXq5BUpUsQ7fPiwf92kSZO8fPnyeadOnfIOHTrkZc+e3XvjjTf8tx8/ftwrVaqU9+STT7qfr7vuOq9Lly5B73/Tpk3uuL766iv38+LFi93Pe/fuPe05vu+++9z3P/30k9tmyZIl/tv//PNPL3fu3N7bb7+d4DnYuHGjf5tnn33WO+ecc1J8zAAAxDIyqgCQiVx22WWuVNWnQYMGrhRVzYJ86tatm2AfZReVKQ2knxPvp9Jen3POOcd9rVmz5mnrdu3a5V+n7O6ll17q/7lq1aquFFa/M1TKAqu8NvCxHTp0yJUS//zzzy6LGfg41PSoXr16/t/Vo0cPmzFjhl188cUu+7p06VI7E7pfPb769ev71xUtWtSqVKmS4PHpmJVt9jn33HMTPEcAAOB0BKoAEGPy5s2bpv0U+Pn4guFg6+Lj4y0Safzor7/+an379rVt27bZlVdeeVZKcAOfI9/zRMN9AACSR6AKAJnIihUrEvy8fPly1zRIXW2ToiY/Gj8aSD9fcMEFye6XGidPnkzQYEljPjVOVb9T1HE4MGubHDVd+uuvvxI8NjUn0phSZSx1X4GPQxlWNVOqXr26f50aKXXq1Mlef/11GzdunE2ZMiXo7/J1Qk7u2PQY9PgCn/Pdu3e7xxj4OwEAQOgIVAEgE1Gjnn79+rlgSQ2HJk6caPfdd1+y+6ihkRoVqRvwTz/95JoRPfPMM+mSbVQ28d5773XBnJo2qfmQypNVkuvrJrxp0ybXbffPP/90nXuTog6/Xbt2dQ2RPvzwQxs6dKjrcJwlSxaXJVZp74ABA2zBggVum27durlGRtpHhgwZYnPmzLGNGzfa999/b++//74/YE5MXY2V+dQ26mysEuPEdAFAHY/1e7744gsXSKux1HnnnefWAwCAtCNQBYBMRF13lXVUINizZ08XpPqmoEnKJZdcYm+//bYbv1mjRg0X0A0fPtw/JcuZ0PjMBx980G6//XY3flQZ0Lfeest/e5s2bdx0LZoORtlOBddJUamugsPLL7/cdQe+/vrrXYdinyeeeMLdn7oR6zEpINW0MJo6xpclHTRokBtrq/tQtliPORgFm+ooPHDgQDf2VgFxMJrepk6dOnbttde6MbMq6VUQnbjcFwAAhCZOHZVC3AcAEIE0x6caBamkNRJoehvNOapS3zOloFn3ozlZAQBA5kdGFQAAAAAQUQhUAQAAAAARhdJfAAAAAEBEIaMKAAAAAIgoBKoAAAAAgIhCoAoAAAAAiCgEqgAAAACAiEKgCgAAAACIKASqAAAAAICIQqAKAAAAAIgoBKoAAAAAgIhCoAoAAAAAsEjyfwTt+WXsKo/BAAAAAElFTkSuQmCC", "text/plain": [ "
" ] @@ -1441,121 +1141,31 @@ "name": "stdout", "output_type": "stream", "text": [ - "Near-zero threshold: 1.442e-08\n" + "Final-layer FF2 rows before the last position are exactly zero: True\n" ] } ], "source": [ "vjp_norms = np.stack(\n", - " [layer.output_projection.factor_norms.numpy() for layer in primary_result.layers]\n", + " [primary[\"factors\"][layer, \"out\"][\"residual\"].norm(dim=-1).numpy() for layer in DETAILED_LAYERS]\n", ")\n", - "near_zero_threshold = max(\n", - " float(vjp_norms.max()) * 1e-8, torch.finfo(torch.float32).tiny\n", - ")\n", - "near_zero_mask = vjp_norms <= near_zero_threshold\n", - "log_vjp_norms = np.log10(np.maximum(vjp_norms, near_zero_threshold))\n", - "masked_log_vjp_norms = np.ma.masked_where(near_zero_mask, log_vjp_norms)\n", + "zero_mask = vjp_norms == 0\n", + "masked_log_norms = np.ma.masked_where(zero_mask, np.log10(np.where(zero_mask, 1.0, vjp_norms)))\n", "cmap = plt.colormaps[\"magma\"].copy()\n", "cmap.set_bad(color=\"#d1d5db\")\n", "\n", "fig, ax = plt.subplots(figsize=(10, 4.5))\n", - "image = ax.imshow(masked_log_vjp_norms, aspect=\"auto\", cmap=cmap)\n", + "image = ax.imshow(masked_log_norms, aspect=\"auto\", cmap=cmap)\n", "ax.set_yticks(range(len(DETAILED_LAYERS)), labels=DETAILED_LAYERS)\n", - "ax.set_xticks(\n", - " range(len(position_tokens)), labels=position_tokens, rotation=35, ha=\"right\"\n", - ")\n", - "for layer_index, position_index in np.argwhere(near_zero_mask):\n", - " label = \"0\" if vjp_norms[layer_index, position_index] == 0 else \"≈0\"\n", - " ax.text(position_index, layer_index, label, ha=\"center\", va=\"center\")\n", - "ax.set(\n", - " xlabel=\"prompt position\",\n", - " ylabel=\"layer\",\n", - " title=\"FF2 VJP norms by layer and position (gray = zero/near-zero)\",\n", - ")\n", + "ax.set_xticks(range(len(position_tokens)), labels=position_tokens, rotation=35, ha=\"right\")\n", + "for layer_index, position_index in np.argwhere(zero_mask):\n", + " ax.text(position_index, layer_index, \"0\", ha=\"center\", va=\"center\")\n", + "ax.set(xlabel=\"prompt position\", ylabel=\"layer\", title=\"FF2 VJP norms (gray = exactly zero)\")\n", "fig.colorbar(image, ax=ax, label=\"log10 L2 norm\")\n", "fig.tight_layout()\n", "plt.show()\n", - "print(f\"Near-zero threshold: {near_zero_threshold:.3e}\")" - ] - }, - { - "cell_type": "markdown", - "id": "cd704a4a", - "metadata": {}, - "source": [ - "## Beyond-paper diagnostic: contribution size and cumulative reconstruction\n", - "\n", - "For one middle-layer FF2 gradient, the Frobenius norm of position $i$'s outer product is $\\lVert x_i\\rVert_2\\lVert\\delta_i\\rVert_2$. The contribution bars retain prompt order. The cumulative curve adds positions from largest to smallest contribution norm and reports how much of the full reconstructed gradient remains." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "22bdaffd", - "metadata": { - "execution": { - "iopub.execute_input": "2026-09-02T13:05:35.848878Z", - "iopub.status.busy": "2026-09-02T13:05:35.848671Z", - "iopub.status.idle": "2026-09-02T13:05:36.145144Z", - "shell.execute_reply": "2026-09-02T13:05:36.144305Z" - } - }, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABKYAAAGGCAYAAABBiol3AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAxhlJREFUeJzs3XdYFFcXBvB3doGl9yIo2BURBRQUFAv2hj1qoondaGLXxBq70STGFo0m9hgTjZrYxa6xKyhWVOwFpDcLq7Dz/UHYzxVQWBZG4P09zz7J3pm5c+bsys6enXtHEEVRBBERERERERERUSGTSR0AERERERERERGVTCxMERERERERERGRJFiYIiIiIiIiIiIiSbAwRUREREREREREkmBhioiIiIiIiIiIJMHCFBERERERERERSYKFKSIiIiIiIiIikgQLU0REREREREREJAkWpoiIiIiIiIiISBIsTBERFYItW7ZAEASEhoaq21auXAlBEHD79m1J4/iQfOjxEREVNkEQMHny5CLTL33Yfv/9dwiCgBs3bkgditqHGBPlrEePHqhUqZJGW6tWreDp6Sl5HIVpx44dcHd3h4GBAQRBwLNnz9CxY0e4urpKFlNRxsIU0TscPHgQgiBg+fLlUoeiMzdv3kTPnj1RqlQpmJqaomHDhti3b997t8vMRXaPNz8UcrtefHw8Fi5ciPr168PKygr29vYICAjA3r17C+S4dSXz5Onq1atSh5KjohAjEVFBe/XqFZYtW4bGjRvD2toahoaGqFChAtq0aYNNmzbh1atXUodYoFJTUyEIAqZNmyZ1KCWGFD84fchxlFRFpThRFOL8UGOMjIxEjx490LVrVzx79gyiKMLU1LRA9vWh5kDXWJgiKkFOnDgBb29v2Nra4syZM4iPj8ePP/6IhQsX5rqPZcuWQRRFjUd2Jz7vW+/rr7/GzJkz8emnn+Lu3bu4fPky6tSpgzZt2uQpnqKia9euEEWx0H9N+lDjyMmHHh8RFQ2xsbGoX78+pk6dih49euDKlStITk7Gvn374OXlhc8++wx///231GFKShRFzJo1S+owqJD16tULoiiWiC+6VDA2btz4QRQ9pYzj5MmTePnyJbp37w4DAwN1+7Zt23jln5b0pA6AiApHamoqPv74Y3Tq1AmLFi1St/v4+EhylVL16tUxdepUODs7q9u+++47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nJycpA6JiIiIiKhYqlGjBlxcXNCrVy/Y29ujadOmMDU1xfHjx1G1alWpwyOiN3COKSIiIiIiIiIikgSvmCIiIiIiIiIiIkmwMEVERERERERERJLQkzqAwqBSqRAREQEzMzMIgiB1OERERFQEiKKIlJQUODk5QSYrub/l8TyKiIiI8iov51ElojAVEREBZ2dnqcMgIiKiIujRo0coU6aM1GFIhudRREREpK3cnEeViMKUmZkZgIyEmJubSxxNwVCpVEhISICVlVWJ/lU3v5hH3WEudYe51B3mUjdKSh6Tk5Ph7OysPo8oqQr6PKqkvJ8+VMy/tJh/aTH/0mL+pVXQ+c/LeVSJKExlXnZubm5erAtTaWlpMDc35z/qfGAedYe51B3mUneYS90oaXks6cPXCvo8qqS9nz40zL+0mH9pMf/SYv6lVVj5z815FF99IiIiIiIiIiKSBAtTREREREREREQkCRamiIiIiIiIiIhIEixMERERERERERGRJErE5OdERERE9OER09OReCoUiXceQlbRBVb1PCHI5VKHRURERIWIhSkiIiIiKnQxO48hfOIiKCNiAACPACic7FD52xGwC2wkbXBERERUaDiUj4iIiIgKVczOY7jad7K6KJVJGRmDq30nI2bnMYkiIyIiosLGwhQRERERFRoxPR3hExcBYnYLM/4TPmkxxPT0Qo2LiIiIpMHCFBEREREVmsTTl7NcKaVBBJRPopF4+nLhBUVERESSYWGKiIiIiArNq6g4na5HRERERRsnPyciIipiOraMkHT/giDCwTEFUZFKiKIgaSzb9jlJun/KOwMHG52uR0REREUbr5giIiIiokJj6VcTCic74B01TZmhAUzcyhdeUERERCQZFqaIiIiIqNAIcjkqfzvivyfZr6NKfYULLQbj2dXbhRcYERERSYKFKSIiIiIqVHaBjeC+ZhYUjnYa7YrS9qg4bQgMXRzx8t4ThLQajKebgiSKkoiIiAoD55giIiIiokJnF9gItm38kXAqFPF3HsK6ogus6nlCkMvh2Ksdrn8+A/GHziLsi9lIOn8NlWcPg0xhIHXYREREpGO8YoqIiIiIJCHI5bCs7wXLdg1gWd8LglwOANC3MkfNjd+j3Nd9AUFAxJptuBg4DKlPoiWOmIiIiHSNhSkiIiIi+uAIMhnKj+uHmn9+Bz1LMySHXEdwk/5I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KY+xMEVEREREREREb5VGo0Hjxo2xbt06vfYSJUrg66+/xq+//orIyEilfciQIUhISEBcXFye5qS8x8IUEREREREREb11n3zyCfbt24f9+/frtXfp0gVOTk567W3atEFMTAycnZ3zOiblMRamiIiIiIiIiOit69atG9q3b49Bgwbhzp07etcqVqyI1NRUvTZTU9O8jEcqYWGKiIiIiIiIiPLE+vXrYW5ujtatW+PatWsAgIsXL+L8+fPo3LmzyulIDSxMEREREREREVGesLGxwZEjR2BlZYVatWqhfv368PLywqJFi1C/fn2145EKNCIiaocgIiIiIiIioqIlPDwc9+/fh6enJ2xsbNSOQyphYYqIiIiIiIiIiFTBpXxERERERERERKQKFqaIiIiIiIiIiEgVLEwREREREREREZEqWJgiIiIiIiIiIiJVsDBFRERERERERESqYGGKiIiIiIiIiIhUwcIUERERERERERGpgoUpIiIiIiIiIiJSBQtTRERERERERESkChamiIiIiIiIiIhIFSxMERERERERERGRKliYIiIiIiIiIiIiVbAwRUREREREREREqvg/hzNSLYSdScMAAAAASUVORK5CYII=", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "middle_layer = DETAILED_LAYERS[len(DETAILED_LAYERS) // 2]\n", - "factors = primary_result.layer(middle_layer).output_projection.factors\n", - "contribution_norms = (\n", - " factors.forward_inputs.norm(dim=-1) * factors.output_gradients.norm(dim=-1)\n", - ").numpy()\n", - "sorted_positions = np.argsort(-contribution_norms, kind=\"stable\")\n", - "running = torch.zeros_like(factors.reconstructed_gradient)\n", - "full_norm = factors.reconstructed_gradient.norm().clamp_min(\n", - " torch.finfo(torch.float32).eps\n", - ")\n", - "remaining_error = []\n", - "for position in sorted_positions:\n", - " x_row = factors.forward_inputs[position]\n", - " delta_row = factors.output_gradients[position]\n", - " running += torch.outer(x_row, delta_row)\n", - " remaining_error.append(\n", - " float((factors.reconstructed_gradient - running).norm() / full_norm)\n", - " )\n", - "sorted_labels = [\n", - " f\"{position}:{position_tokens[position]}\" for position in sorted_positions\n", - "]\n", - "\n", - "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n", - "axes[0].bar(range(len(position_tokens)), contribution_norms, color=\"#4f46e5\")\n", - "axes[0].set_xticks(\n", - " range(len(position_tokens)), labels=position_tokens, rotation=35, ha=\"right\"\n", - ")\n", - "axes[0].set(\n", - " ylabel=\"outer-product Frobenius norm\",\n", - " title=f\"Layer {middle_layer} FF2 contribution size\",\n", - ")\n", - "axes[1].plot(range(len(sorted_positions)), remaining_error, marker=\"o\", color=\"#be123c\")\n", - "axes[1].set_xticks(\n", - " range(len(sorted_positions)), labels=sorted_labels, rotation=35, ha=\"right\"\n", - ")\n", - "axes[1].set(\n", - " ylabel=\"relative error remaining\",\n", - " title=\"Cumulative reconstruction: largest contribution first\",\n", - ")\n", - "for ax in axes:\n", - " ax.grid(alpha=0.25)\n", - "fig.tight_layout()\n", - "plt.show()" + "final_rows_zero = bool(zero_mask[-1, :-1].all())\n", + "print(f\"Final-layer FF2 rows before the last position are exactly zero: {final_rows_zero}\")" ] }, { @@ -1565,19 +1175,19 @@ "source": [ "## Predeclared examples: report every outcome\n", "\n", - "The remaining examples are analyzed only at GPT-2's final layer to keep the demo compact. The table reports one-decimal vocabulary rank percentiles so nearly tied logits remain stable across numeric backends. It also records whether unit normalization improves, worsens, or leaves unchanged the target's exact ascending raw-gradient rank. No example is removed based on its result." + "The remaining examples are analyzed only at GPT-2's final layer to keep the demo compact. The table reports one-decimal vocabulary rank percentiles so nearly tied logits remain stable across numeric backends. It also records whether unit normalization improves, worsens, or leaves unchanged the target's exact ascending rank. No example is removed based on its result." ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 8, "id": "5ba7e1da", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T13:05:36.147093Z", - "iopub.status.busy": "2026-09-02T13:05:36.146767Z", - "iopub.status.idle": "2026-09-02T13:05:36.888196Z", - "shell.execute_reply": "2026-09-02T13:05:36.887405Z" + "iopub.execute_input": "2026-10-14T19:03:29.313242Z", + "iopub.status.busy": "2026-10-14T19:03:29.313182Z", + "iopub.status.idle": "2026-10-14T19:03:29.390551Z", + "shell.execute_reply": "2026-10-14T19:03:29.390244Z" } }, "outputs": [ @@ -1607,7 +1217,6 @@ " raw target rank percentile\n", " normalized target rank percentile\n", " normalization outcome\n", - " VJP norm\n", " \n", " \n", " \n", @@ -1618,7 +1227,6 @@ " 0.0\n", " 0.0\n", " unchanged\n", - " 0.260877\n", " \n", " \n", " 1\n", @@ -1627,7 +1235,6 @@ " 0.0\n", " 0.0\n", " unchanged\n", - " 0.262014\n", " \n", " \n", " 2\n", @@ -1636,7 +1243,6 @@ " 0.0\n", " 0.0\n", " unchanged\n", - " 0.280860\n", " \n", " \n", "\n", @@ -1653,10 +1259,10 @@ "1 0.0 0.0 \n", "2 0.0 0.0 \n", "\n", - " normalization outcome VJP norm \n", - "0 unchanged 0.260877 \n", - "1 unchanged 0.262014 \n", - "2 unchanged 0.280860 " + " normalization outcome \n", + "0 unchanged \n", + "1 unchanged \n", + "2 unchanged " ] }, "metadata": {}, @@ -1671,54 +1277,21 @@ } ], "source": [ + "final_layer = DETAILED_LAYERS[-1]\n", + "OUTCOMES = {1: \"improved\", 0: \"unchanged\", -1: \"worsened\"}\n", "comparison_rows = []\n", - "for example_index, (example_prompt, example_target) in enumerate(EXAMPLES):\n", - " if example_index == 0:\n", - " result = primary_result\n", - " else:\n", - " result = lens.analyze(\n", - " example_prompt,\n", - " example_target,\n", - " [DETAILED_LAYERS[-1]],\n", - " normalized=True,\n", - " )\n", - " summary = final_position_summary(result).iloc[-1]\n", - " raw_rank = int(summary[\"raw target rank\"])\n", - " normalized_rank = int(summary[\"normalized target rank\"])\n", - " vocab_size = result.layers[-1].output_projection.vocabulary_size\n", - " outcome = (\n", - " \"improved\"\n", - " if normalized_rank < raw_rank\n", - " else \"worsened\" if normalized_rank > raw_rank else \"unchanged\"\n", - " )\n", - " comparison_rows.append(\n", - " {\n", - " \"prompt\": example_prompt,\n", - " \"target\": example_target,\n", - " \"raw target rank percentile\": 100 * raw_rank / vocab_size,\n", - " \"normalized target rank percentile\": 100 * normalized_rank / vocab_size,\n", - " \"normalization outcome\": outcome,\n", - " \"VJP norm\": float(summary[\"final-position VJP norm\"]),\n", - " }\n", - " )\n", - " if example_index != 0:\n", - " del result\n", - " gc.collect()\n", + "for example_prompt, example_target in EXAMPLES:\n", + " result = mlp_gradient_factors(model, example_prompt, example_target, [final_layer])\n", + " summary = final_position_ranks(result, [final_layer]).iloc[0]\n", + " outcome = OUTCOMES[int(np.sign(summary[\"raw rank\"] - summary[\"normalized rank\"]))]\n", + " percentiles = summary[[\"raw target rank percentile\", \"normalized target rank percentile\"]]\n", + " comparison_rows.append((example_prompt, example_target, *percentiles, outcome))\n", "\n", - "comparison = pd.DataFrame(comparison_rows)\n", - "display(\n", - " comparison.round(\n", - " {\n", - " \"raw target rank percentile\": 1,\n", - " \"normalized target rank percentile\": 1,\n", - " \"VJP norm\": 8,\n", - " }\n", - " )\n", + "comparison = pd.DataFrame(\n", + " comparison_rows, columns=[\"prompt\", \"target\", *percentiles.index, \"normalization outcome\"]\n", ")\n", - "print(\n", - " \"Normalization outcomes:\",\n", - " comparison[\"normalization outcome\"].value_counts().to_dict(),\n", - ")" + "display(comparison)\n", + "print(\"Normalization outcomes:\", comparison[\"normalization outcome\"].value_counts().to_dict())" ] }, { @@ -1726,40 +1299,44 @@ "id": "390b96bc", "metadata": {}, "source": [ - "## Beyond GPT-2: a dense-MLP Bridge contract\n", + "## Beyond GPT-2: the other weight layout\n", "\n", - "`BackwardLens` accepts any raw, non-gated dense-MLP `TransformerBridge` and reads each MLP projection's weight layout from the Bridge component instead of the model class. GPT-2's `Conv1D` projections store `[in, out]`; Pythia/GPT-NeoX's `torch.nn.Linear` projections store `[out, in]`. The single-layer reconstruction below repeats the first example above against a raw Pythia-70m Bridge and reports its resolved weight layout alongside the reconstruction error." + "The same functions run unchanged on any raw, non-gated dense-MLP Bridge. GPT-2's `Conv1D` projections store `[in, out]`; Pythia/GPT-NeoX's `torch.nn.Linear` projections store `[out, in]`, so their weight gradient is the transpose of the outer-product sum. The single-layer reconstruction below repeats the first example on a raw Pythia-70m Bridge and reports the layout that the gradient's shape resolved to, alongside the reconstruction check." ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 9, "id": "1020c8d5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T19:03:29.391720Z", + "iopub.status.busy": "2026-10-14T19:03:29.391659Z", + "iopub.status.idle": "2026-10-14T19:03:29.582873Z", + "shell.execute_reply": "2026-10-14T19:03:29.582481Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "Pythia-70m input_projection weight layout: out_in\n", + "Pythia-70m input projection weight layout: out_in\n", "Pythia-70m reconstruction error within tolerance: True\n" ] } ], "source": [ - "with warnings.catch_warnings():\n", - " warnings.simplefilter(\"ignore\")\n", - " pythia_model = TransformerBridge.boot_transformers(\n", - " \"EleutherAI/pythia-70m\", device=DEVICE, dtype=torch.float32\n", - " )\n", + "pythia_model = TransformerBridge.boot_transformers(\n", + " \"EleutherAI/pythia-70m\", device=DEVICE, dtype=torch.float32\n", + ")\n", "pythia_model.eval()\n", "\n", - "pythia_result = BackwardLens(pythia_model).analyze(prompt, target, [0])\n", - "pythia_factors = pythia_result.layer(0).input_projection.factors\n", - "print(f\"Pythia-70m input_projection weight layout: {pythia_factors.weight_layout}\")\n", + "pythia_factors = mlp_gradient_factors(pythia_model, prompt, target, [0])[\"factors\"][0, \"in\"]\n", + "print(f\"Pythia-70m input projection weight layout: {pythia_factors['layout']}\")\n", "print(\n", " \"Pythia-70m reconstruction error within tolerance: \"\n", - " f\"{pythia_factors.absolute_reconstruction_error < 1e-4}\"\n", + " f\"{pythia_factors['relative_error'] < 1e-5}\"\n", ")\n", "\n", "del pythia_model\n", @@ -1774,13 +1351,17 @@ "## Interpretation checklist\n", "\n", "- FF1 and FF2 tables project different factors; do not compare them as if they were the same signal.\n", - "- FF2 uses raw loss-gradient signs. SGD subtracts the gradient, so ascending ranks are highlighted.\n", + "- FF2 uses raw loss-gradient signs. Gradient descent subtracts the gradient, so ascending ranks are highlighted.\n", "- LayerNorm bias and epsilon make projection non-linear enough that negating projected logits is not an exact update readout.\n", "- Normalized Logit Lens isolates direction from factor norm, but it need not improve target rank. The table above records null and contradictory outcomes.\n", "- Small reconstruction error validates the factorization numerically; it does not validate a semantic interpretation.\n", - "- This fixed GPT-2 sample demonstrates tooling and diagnostics, not population-level evidence about language-model learning or memory.\n", + "- Hooks already installed on the model still apply during the forward pass, so remove them to analyze the unmodified computation.\n", + "- This fixed GPT-2 sample demonstrates the method and its diagnostics, not population-level evidence about language-model learning or memory.\n", + "\n", + "## References\n", "\n", - "See the [Backward Lens reference documentation](../docs/source/content/backward_lens.md) and the [authors' research repository](https://github.com/shacharKZ/BackwardLens) for more context." + "- Shahar Katz, Yonatan Belinkov, Mor Geva, and Lior Wolf. 2024. [Backward Lens: Projecting Language Model Gradients into the Vocabulary Space](https://aclanthology.org/2024.emnlp-main.142/). *Proceedings of EMNLP 2024*, pages 2390–2422.\n", + "- [Authors' research repository](https://github.com/shacharKZ/BackwardLens). This notebook does not import, vendor, or depend on that repository's code." ] } ], @@ -1800,7 +1381,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.14" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb b/demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb index bf657f3040..4cd8ac876a 100644 --- a/demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb +++ b/demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb @@ -48,9 +48,9 @@ "prompts behind it.\n", "\n", "**This notebook loads a frozen artifact and never calls the model.** The artifact was\n", - "generated once, out-of-band, by running the benchmark's CLI entry point; see the load cell\n", - "below for the exact command. Re-running the sweep is a separate, explicit step, not something\n", - "this notebook triggers.\n" + "generated once, out-of-band, by `scripts/jacobian_lens_causal_swap_benchmark.py`; the load\n", + "cell below names the exact command. Re-running the sweep is a separate, explicit step, not\n", + "something this notebook triggers.\n" ] }, { @@ -59,10 +59,10 @@ "id": "8a272a14", "metadata": { "execution": { - "iopub.execute_input": "2026-09-24T12:26:45.620923Z", - "iopub.status.busy": "2026-09-24T12:26:45.620839Z", - "iopub.status.idle": "2026-09-24T12:26:45.623972Z", - "shell.execute_reply": "2026-09-24T12:26:45.623559Z" + "iopub.execute_input": "2026-10-14T18:27:57.152894Z", + "iopub.status.busy": "2026-10-14T18:27:57.152687Z", + "iopub.status.idle": "2026-10-14T18:27:57.158584Z", + "shell.execute_reply": "2026-10-14T18:27:57.157853Z" } }, "outputs": [ @@ -88,10 +88,10 @@ "id": "bc924b1c", "metadata": { "execution": { - "iopub.execute_input": "2026-09-24T12:26:45.625566Z", - "iopub.status.busy": "2026-09-24T12:26:45.625485Z", - "iopub.status.idle": "2026-09-24T12:26:49.109284Z", - "shell.execute_reply": "2026-09-24T12:26:49.108728Z" + "iopub.execute_input": "2026-10-14T18:27:57.160521Z", + "iopub.status.busy": "2026-10-14T18:27:57.160357Z", + "iopub.status.idle": "2026-10-14T18:27:58.846420Z", + "shell.execute_reply": "2026-10-14T18:27:58.845982Z" } }, "outputs": [ @@ -111,14 +111,18 @@ } ], "source": [ + "import hashlib\n", + "import json\n", "from pathlib import Path\n", "\n", "import numpy as np\n", "import pandas as pd\n", "\n", - "from transformer_lens.tools.analysis import load_artifact, success_rate_ci\n", + "from transformer_lens.utilities import success_rate_ci\n", "\n", "ARTIFACT_NAME = \"jacobian_lens_causal_swap_benchmark_gpt2.json\"\n", + "SCHEMA_VERSION = 2\n", + "RESULT_BLOCKS = (\"trials\", \"excluded_baselines\", \"real_success_ci\", \"control_success_ci\")\n", "\n", "\n", "def _find_artifact() -> Path:\n", @@ -137,15 +141,30 @@ " \"directory. This notebook requires a repository checkout: it loads a frozen \"\n", " \"artifact that is not packaged in the transformer_lens wheel. Clone the repository \"\n", " \"and run the notebook from within it, or regenerate the artifact with \"\n", - " \"`uv run python -m transformer_lens.tools.analysis.\"\n", - " \"jacobian_lens_causal_swap_benchmark_cli`.\"\n", + " \"`uv run python scripts/jacobian_lens_causal_swap_benchmark.py`.\"\n", " )\n", "\n", "\n", + "def _fingerprint(payload) -> str:\n", + " \"\"\"SHA-256 over canonical JSON, the same recipe the generation script uses.\"\"\"\n", + " canonical = json.dumps(payload, sort_keys=True, separators=(\",\", \":\"))\n", + " return hashlib.sha256(canonical.encode()).hexdigest()\n", + "\n", + "\n", "ARTIFACT_PATH = _find_artifact()\n", "REPO_ROOT = ARTIFACT_PATH.parents[2]\n", - "artifact = load_artifact(ARTIFACT_PATH)\n", + "artifact = json.loads(ARTIFACT_PATH.read_text())\n", "manifest = artifact[\"protocol_manifest\"]\n", + "# Recomputed rather than trusted, so a hand-edited artifact or one from another schema fails\n", + "# here instead of rendering as if the script had produced it.\n", + "expected = {\n", + " \"schema_version\": SCHEMA_VERSION,\n", + " \"protocol_fingerprint\": _fingerprint(manifest),\n", + " \"results_fingerprint\": _fingerprint({key: artifact[key] for key in RESULT_BLOCKS}),\n", + "}\n", + "for field, value in expected.items():\n", + " if artifact[field] != value:\n", + " raise ValueError(f\"artifact {field} is {artifact[field]!r}, expected {value!r}\")\n", "print(f\"artifact={ARTIFACT_PATH.relative_to(REPO_ROOT)}\")\n", "print(f\"schema_version={artifact['schema_version']}\")\n", "print(f\"protocol_fingerprint={artifact['protocol_fingerprint']}\")\n", @@ -188,10 +207,10 @@ "id": "1feb4158", "metadata": { "execution": { - "iopub.execute_input": "2026-09-24T12:26:49.110784Z", - "iopub.status.busy": "2026-09-24T12:26:49.110543Z", - "iopub.status.idle": "2026-09-24T12:26:49.123206Z", - "shell.execute_reply": "2026-09-24T12:26:49.122757Z" + "iopub.execute_input": "2026-10-14T18:27:58.847684Z", + "iopub.status.busy": "2026-10-14T18:27:58.847570Z", + "iopub.status.idle": "2026-10-14T18:27:58.860441Z", + "shell.execute_reply": "2026-10-14T18:27:58.860108Z" } }, "outputs": [ @@ -391,10 +410,10 @@ "id": "c74c1711", "metadata": { "execution": { - "iopub.execute_input": "2026-09-24T12:26:49.124146Z", - "iopub.status.busy": "2026-09-24T12:26:49.124073Z", - "iopub.status.idle": "2026-09-24T12:26:49.486825Z", - "shell.execute_reply": "2026-09-24T12:26:49.486520Z" + "iopub.execute_input": "2026-10-14T18:27:58.861362Z", + "iopub.status.busy": "2026-10-14T18:27:58.861303Z", + "iopub.status.idle": "2026-10-14T18:27:59.241197Z", + "shell.execute_reply": "2026-10-14T18:27:59.240779Z" } }, "outputs": [ @@ -516,10 +535,10 @@ "id": "dedef2ac", "metadata": { "execution": { - "iopub.execute_input": "2026-09-24T12:26:49.488012Z", - "iopub.status.busy": "2026-09-24T12:26:49.487879Z", - "iopub.status.idle": "2026-09-24T12:26:49.494153Z", - "shell.execute_reply": "2026-09-24T12:26:49.493735Z" + "iopub.execute_input": "2026-10-14T18:27:59.242250Z", + "iopub.status.busy": "2026-10-14T18:27:59.242130Z", + "iopub.status.idle": "2026-10-14T18:27:59.248415Z", + "shell.execute_reply": "2026-10-14T18:27:59.248094Z" } }, "outputs": [ @@ -764,7 +783,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.14" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/demos/Jacobian_Lens_Demo.ipynb b/demos/Jacobian_Lens_Demo.ipynb index addc6ab782..f608bf97d8 100644 --- a/demos/Jacobian_Lens_Demo.ipynb +++ b/demos/Jacobian_Lens_Demo.ipynb @@ -334,9 +334,9 @@ "(pseudoinverse), and write back $h \\leftarrow h + \\alpha\\, V(\\sigma(c) - c)$ where $\\sigma$\n", "exchanges the two coordinates — leaving the orthogonal complement of the activation untouched.\n", "The paper clamps the exchanged clean-run coordinates at every token position across an\n", - "intermediate-layer band (layers 10–24 ≈ the paper's 38–92% workspace band). Unlike\n", - "`swap_hooks`, which re-reads and swaps the live coordinates at each layer,\n", - "`swap_clamp_hooks` holds every layer at the same clean-run counterfactual target." + "intermediate-layer band (layers 10–24 ≈ the paper's 38–92% workspace band). By default\n", + "`swap_hooks` re-reads and swaps the live coordinates at each layer; passing the clean cache\n", + "as `clamp_to` instead holds every layer at the same clean-run counterfactual target." ] }, { @@ -393,7 +393,7 @@ " tokens = model.to_tokens(prompt)\n", " with torch.no_grad():\n", " _, clean_cache = model.run_with_cache(tokens)\n", - " hooks = lens.swap_clamp_hooks(model, source, target, layers, clean_cache)\n", + " hooks = lens.swap_hooks(model, source, target, layers, clamp_to=clean_cache)\n", " with model.hooks(fwd_hooks=hooks):\n", " return model(tokens)[0, -1].float()\n", "\n", diff --git a/demos/SVD_Circuits_Demo.ipynb b/demos/SVD_Circuits_Demo.ipynb index 7354d5f280..364872d6e7 100644 --- a/demos/SVD_Circuits_Demo.ipynb +++ b/demos/SVD_Circuits_Demo.ipynb @@ -15,7 +15,7 @@ "source": [ "# SVD Circuits Demo\n", "\n", - "This notebook decomposes one attention head's QK ($W_Q W_K^\\top$) and OV ($W_V W_O$) weight maps into orthogonal singular directions. It displays descriptive vocab and activation projections, then measures the effects of retaining or ablating eligible OV directions on one IOI prompt's logit difference. Mathematically distinct directions need not be semantically or causally distinct subfunctions.\n", + "This notebook decomposes one attention head's QK ($W_Q W_K^\\top$) and OV ($W_V W_O$) weight maps into orthogonal singular directions with `FactoredMatrix.svd`, reads the OV directions out through the final norm and unembedding with `logit_readout`, projects the head's cached output onto them, and measures the effect of retaining or ablating single directions on one IOI prompt's logit difference with a hook on `hook_result`. Mathematically distinct directions need not be semantically or causally distinct subfunctions.\n", "\n", "Based on Areeb Ahmad, Abhinav Joshi, Ashutosh Modi, \"Beyond Components: Singular Vector-Based Interpretability of Transformer Circuits\", [arXiv 2511.20273](https://arxiv.org/abs/2511.20273)." ] @@ -34,10 +34,10 @@ "id": "51b1c187", "metadata": { "execution": { - "iopub.execute_input": "2026-09-28T03:32:15.180735Z", - "iopub.status.busy": "2026-09-28T03:32:15.180507Z", - "iopub.status.idle": "2026-09-28T03:32:15.249892Z", - "shell.execute_reply": "2026-09-28T03:32:15.249432Z" + "iopub.execute_input": "2026-10-14T19:03:20.859957Z", + "iopub.status.busy": "2026-10-14T19:03:20.859770Z", + "iopub.status.idle": "2026-10-14T19:03:20.890270Z", + "shell.execute_reply": "2026-10-14T19:03:20.889776Z" } }, "outputs": [ @@ -81,27 +81,13 @@ "id": "b260d8c2", "metadata": { "execution": { - "iopub.execute_input": "2026-09-28T03:32:15.251062Z", - "iopub.status.busy": "2026-09-28T03:32:15.250983Z", - "iopub.status.idle": "2026-09-28T03:32:24.853702Z", - "shell.execute_reply": "2026-09-28T03:32:24.853179Z" + "iopub.execute_input": "2026-10-14T19:03:20.891623Z", + "iopub.status.busy": "2026-10-14T19:03:20.891527Z", + "iopub.status.idle": "2026-10-14T19:03:23.213210Z", + "shell.execute_reply": "2026-10-14T19:03:23.212894Z" } }, "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "0d7d9821c27948d385b373deb184db49", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Loading weights: 0%| | 0/148 [00:004} {'sigma':>10} {'sigma/sigma_max':>16} {'degenerate':>11} {'null':>6}\")\n", - "for row in ov.rank_report[:12]:\n", + "for row in report[:12]:\n", " print(\n", - " f\"{row.idx:>4} {row.sigma:>10.4f} {row.sigma_ratio:>16.4f} \"\n", - " f\"{str(row.is_degenerate):>11} {str(row.is_null):>6}\"\n", + " f\"{row['idx']:>4} {row['sigma']:>10.4f} {row['ratio']:>16.4f} \"\n", + " f\"{str(row['degenerate']):>11} {str(row['null']):>6}\"\n", " )" ] }, @@ -203,11 +238,9 @@ "source": [ "### Reading the rank report\n", "\n", - "Near-equal consecutive singular values define a subspace whose basis is only defined up to a rotation, so individual directions inside such a block are not attributable. Numerically null directions are arbitrary vectors from the map's null space, and are equally unusable.\n", - "\n", - "`decompose_head` marks degenerate and null directions in the rank report. `logit_signature` refuses individual directions that are degenerate or null. `patch_along_directions` refuses selections that split a degenerate block, but permits complete blocks as subspaces; empty or full-span retained sets require an explicit threshold.\n", + "Singular directions are unique only when the singular values are distinct. Near-equal consecutive singular values define a subspace whose basis is only defined up to a rotation, so individual directions inside such a block are not attributable: \"direction 3 is the surname subfunction\" would be a statement about an arbitrary basis choice rather than about the model. A block is a run of neighbours each within `EPS` of the previous one, so a block can span more than `EPS` end to end. Numerically null directions are arbitrary vectors from the map's null space and are equally unusable.\n", "\n", - "`vocab_readout` and `project_activations` return raw numerical projections, including degenerate columns, without replacing them with block summaries. The displays below therefore select only isolated, non-null directions from the rank report. Without this filtering, \"direction 3 is the surname subfunction\" would be a claim about an arbitrary basis choice." + "The rest of the notebook therefore reads, projects and patches only isolated, non-null directions. A complete degenerate block can still be retained or removed as a subspace; only splitting it is meaningless." ] }, { @@ -215,40 +248,61 @@ "id": "d7082dbb", "metadata": {}, "source": [ - "## 2. Vocab projections of eligible directions" + "## 2. Vocab projections of eligible directions\n", + "\n", + "`logit_readout` reads any $d_{\\text{model}}$ vector through the model's final norm and unembedding. Two adjustments make that a readout of a *direction*:\n", + "\n", + "- Singular vectors are defined only up to sign: flipping a column of $U$ and the matching column of $V$ leaves the map unchanged. Both ends of each direction are shown.\n", + "- The readout of the zero vector is the part of the final norm and unembedding that does not depend on the input (the folded final-norm bias). Subtracting it leaves logits that flip sign with the direction." ] }, { "cell_type": "code", "execution_count": 4, "id": "d553bfc3", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T19:03:23.248055Z", + "iopub.status.busy": "2026-10-14T19:03:23.247987Z", + "iopub.status.idle": "2026-10-14T19:03:23.268975Z", + "shell.execute_reply": "2026-10-14T19:03:23.268611Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "direction 0: ['Sav', 'AV', 'av', 'adh', 'avour', 'aw', ' Gaw', 'tein', ' Sav', 'amac']\n", - "direction 1: ['NI', 'ANI', 'irin', 'Ir', ' NI', 'tro', ' Irwin', 'iq', 'ani', ' Ir']\n", - "direction 2: [' Lindsay', ' McKenna', 'etr', ' 339', 'Lind', ' copper', ' seiz', ' Ack', 'gae', ' Instr']\n", - "direction 23: [' Sieg', ' Mahjong', ' Buk', 'abal', 'tery', 'Mp', 'jp', 'hap', 'YN', 'ambo']\n", - "direction 48: ['û', 'Rand', ' Pupp', ' DN', 'Doc', 'iris', ' Blog', ' Ning', ' Wade', ' Ashe']\n" + "direction 0 +: ['Sav', 'AV', 'av', 'adh', 'avour', 'aw']\n", + " -: ['tein', ' Kling', ' Pes', ' Monk', ' Schmidt', 'enegger']\n", + "direction 1 +: ['NI', 'ANI', 'irin', 'Ir', ' NI', ' Irwin']\n", + " -: ['tro', 'Tro', 'ero', 'bos', ' Merc', ' tom']\n", + "direction 2 +: [' McKenna', 'etr', ' copper', ' Ack', 'eter', 'EP']\n", + " -: [' Lindsay', ' 339', 'Lind', ' seiz', 'gae', ' Instr']\n", + "direction 23 +: [' Mahjong', ' Buk', 'abal', 'Mp', 'jp', 'hap']\n", + " -: [' Sieg', 'tery', 'YN', ' IG', ' Ez', ' Ys']\n", + "direction 48 +: [' Pupp', ' DN', 'Doc', ' Blog', ' Ning', ' doct']\n", + " -: ['û', 'Rand', 'iris', ' Wade', ' Ashe', 'rain']\n" ] } ], "source": [ "# NBVAL_IGNORE_OUTPUT\n", "# Token rankings can change across kernels when float32 projection magnitudes nearly tie.\n", - "assert all(not ov.rank_report[i].is_degenerate and not ov.rank_report[i].is_null for i in display_ids)\n", - "if display_ids:\n", - " # Readout columns retain the original direction IDs, so request the full prefix.\n", - " readout = vocab_readout(model, ov, k=max(display_ids) + 1)\n", - " for i in display_ids:\n", - " top = torch.topk(readout[:, i].abs(), 10).indices\n", - " tokens = [model.tokenizer.decode([int(t)]) for t in top]\n", - " print(f\"direction {i}: {tokens}\")\n", - "else:\n", - " print(\"No attributable directions to display.\")\n" + "def read_directions(directions):\n", + " \"\"\"Vocab logits of [k, d_model] directions, less the input-independent offset.\"\"\"\n", + " zero = torch.zeros_like(directions[:1])\n", + " return logit_readout(model, directions).values - logit_readout(model, zero).values\n", + "\n", + "\n", + "def decode(ids):\n", + " return [model.tokenizer.decode([int(token)]) for token in ids]\n", + "\n", + "\n", + "readout = read_directions(V[:, display_ids].T)\n", + "for idx, logits in zip(display_ids, readout):\n", + " print(f\"direction {idx:>2} +: {decode(logits.topk(6).indices)}\")\n", + " print(f\" -: {decode((-logits).topk(6).indices)}\")" ] }, { @@ -256,7 +310,7 @@ "id": "ea384ef5", "metadata": {}, "source": [ - "Vocab projections can motivate hypotheses but do not establish semantic labels. `SVDInterpreter`'s own docstring warns that singular directions are not reliably interpretable. The next section describes activation coefficients; the intervention section measures changes in the selected prompt's logit difference, without confirming a named subfunction." + "Vocab projections can motivate hypotheses but do not establish semantic labels. The next section describes activation coefficients; the intervention section measures changes in the selected prompt's logit difference, without confirming a named subfunction." ] }, { @@ -264,21 +318,28 @@ "id": "b8bba160", "metadata": {}, "source": [ - "## 3. Activation projections on a prompt" + "## 3. Activation projections on a prompt\n", + "\n", + "With `use_attn_result` on, `blocks.9.attn.hook_result` holds each head's output separately, shaped `[batch, pos, head, d_model]`. Caching only that hook and projecting head 9's slice onto $V$ gives the coefficient of every write direction at every position. The sign of a coefficient follows the arbitrary sign of its direction, so magnitudes are shown." ] }, { "cell_type": "code", "execution_count": 5, "id": "ec0c9cdc", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T19:03:23.269942Z", + "iopub.status.busy": "2026-10-14T19:03:23.269864Z", + "iopub.status.idle": "2026-10-14T19:03:23.309815Z", + "shell.execute_reply": "2026-10-14T19:03:23.309408Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "prompt tokens: ['<|endoftext|>', 'When', ' Mary', ' and', ' John', ' went', ' to', ' the', ' store', ',', ' John', ' gave', ' a', ' drink', ' to']\n", - "\n", " token dir0 dir1 dir2 dir23 dir48\n", "<|endoftext|> 0.34 0.20 0.17 0.14 0.27\n", " When 0.34 0.21 0.19 0.12 0.29\n", @@ -300,22 +361,15 @@ ], "source": [ "# NBVAL_IGNORE_OUTPUT\n", - "# The coefficients are float32 reductions over the head's output, so the second\n", - "# decimal can differ between CPU kernels (1.75 vs 1.74 on the same input). The\n", - "# table is here to show which directions fire at which positions, not to pin a\n", - "# numeric value, so its output is not compared.\n", - "projection = project_activations(model, ov, PROMPT)\n", + "# The coefficients are float32 reductions over the head's output, so the second decimal can\n", + "# differ between CPU kernels; the table shows which directions fire at which positions.\n", + "hook_name = f\"blocks.{LAYER}.attn.hook_result\"\n", + "_, cache = model.run_with_cache(PROMPT, names_filter=hook_name)\n", + "coefficients = cache[hook_name][0, :, HEAD] @ V # [pos, d_head]\n", "\n", - "print(\"prompt tokens:\", projection.str_tokens)\n", - "print()\n", - "assert all(not ov.rank_report[i].is_degenerate and not ov.rank_report[i].is_null for i in display_ids)\n", - "if display_ids:\n", - " print(f\"{'token':>10} \" + \" \".join(f\"{f'dir{i}':>8}\" for i in display_ids))\n", - " for pos, token in enumerate(projection.str_tokens):\n", - " coeffs = \" \".join(f\"{abs(float(projection.coefficients[pos, i])):>8.2f}\" for i in display_ids)\n", - " print(f\"{token:>10} {coeffs}\")\n", - "else:\n", - " print(\"No attributable directions to display.\")\n" + "print(f\"{'token':>10} \" + \" \".join(f\"{f'dir{idx}':>8}\" for idx in display_ids))\n", + "for token, row in zip(model.to_str_tokens(PROMPT), coefficients):\n", + " print(f\"{token:>10} \" + \" \".join(f\"{abs(float(row[idx])):>8.2f}\" for idx in display_ids))" ] }, { @@ -325,283 +379,167 @@ "source": [ "## 4. Intervention comparisons\n", "\n", - "`patch_along_directions` projects the head's output onto a chosen singular subspace and measures the resulting change in the selected metric, including downstream computation. It reports:\n", + "A hook on `hook_result` replaces head 9's output $r$ at every position with its image under a projector and leaves every other head untouched:\n", "\n", - "- `delta_metric`: patched metric minus original metric.\n", - "- `baseline_delta_metric`: the mean magnitude over random same-width subspaces drawn inside the head's own OV span, so the control is an arbitrary subspace of *this head's* output rather than an unrelated residual-stream direction.\n", - "- `gated`: whether the metric change passed the mode-specific threshold comparison.\n", + "- **keep** direction $i$: $r \\mapsto (r \\cdot v_i)\\, v_i$, retaining only that direction;\n", + "- **ablate** direction $i$: $r \\mapsto r - (r \\cdot v_i)\\, v_i$, removing it.\n", "\n", - "The meaning of `gated` depends on the mode:\n", + "Each run reports `delta`, the change in the logit difference `\" Mary\"` minus `\" John\"` at the final position, which includes all downstream computation. As a reference, the same intervention is applied along 32 random unit directions drawn inside the head's own OV span, so a control is an arbitrary direction of *this head's* output rather than of the whole residual stream. `share` is the fraction of those controls whose `|delta|` is smaller than the direction's own:\n", "\n", - "- With `keep=[i]` (retain only direction $i$), `gated` means `abs(delta_metric) < baseline`: the selected metric changes less than the sampled mean control magnitude.\n", - "- With `ablate=[i]` (zero direction $i$), `gated` means `abs(delta_metric) > baseline`: the selected metric changes more than that mean.\n", + "- In keep mode, a low share means retaining the direction moves the metric less than most random in-span directions do.\n", + "- In ablate mode, a high share means removing it moves the metric more than most random in-span directions do.\n", "\n", - "`keep=S` and `ablate=complement(S)` resolve to the same retained set, which is why the comparison must depend on the mode. The mean control magnitude is not a confidence bound or p-value; a passing comparison does not establish reconstruction of the head's entire behaviour or statistical separation from arbitrary directions.\n", - "\n", - "The whole-head ablation below provides context for the direction tables. It reports the unmodified metric and the signed change after zeroing the head's output. Its empty-span control coincides with the intervention, so its gate flag is not interpreted." + "These are comparisons with a small sample of controls on one prompt, not significance tests. The whole-head ablation first gives context: it zeros the head's output, which a same-width control cannot distinguish from itself." ] }, { "cell_type": "code", "execution_count": 6, "id": "70138572", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T19:03:23.310883Z", + "iopub.status.busy": "2026-10-14T19:03:23.310810Z", + "iopub.status.idle": "2026-10-14T19:03:23.362565Z", + "shell.execute_reply": "2026-10-14T19:03:23.362219Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "original logit difference: 3.362\n", - "whole-head ablation delta: +0.129\n", - "\n", - " dir sigma delta baseline gated\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 0 8.4096 0.154 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 1 8.2600 0.141 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 2 8.1019 0.132 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 23 6.8420 0.193 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 48 5.5535 0.131 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 49 5.4845 0.113 0.127 True\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 50 5.3609 0.131 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 51 5.2535 0.163 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 55 5.0464 0.078 0.127 True\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 56 4.9738 0.114 0.127 True\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 59 4.7041 0.131 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 60 4.6103 0.136 0.127 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 61 4.4351 0.172 0.127 False\n" + "whole-head ablation delta: +0.129\n" ] } ], "source": [ "# NBVAL_IGNORE_OUTPUT\n", - "# The deltas are float32 reductions, and a value sitting on a rounding boundary\n", - "# can print one digit apart across CPU kernels. The gate's polarity is asserted\n", - "# by the test suite; this table is the illustration.\n", - "mary_token = model.to_single_token(\" Mary\")\n", - "john_token = model.to_single_token(\" John\")\n", - "metric = lambda logits: float(logits[0, -1, mary_token] - logits[0, -1, john_token])\n", - "\n", - "whole_head = patch_along_directions(\n", - " model, ov, PROMPT, metric, keep=[], threshold=0.0, n_baseline=1,\n", - " rng=torch.Generator().manual_seed(0),\n", - ")\n", - "print(f\"original logit difference: {whole_head.original_metric:.3f}\")\n", - "print(f\"whole-head ablation delta: {whole_head.delta_metric:+.3f}\")\n", - "print()\n", + "# The deltas are float32 reductions, and a value sitting on a rounding boundary can print one\n", + "# digit apart across CPU kernels.\n", + "mary, john = model.to_single_token(\" Mary\"), model.to_single_token(\" John\")\n", + "identity = torch.eye(model.cfg.d_model, device=V.device)\n", "\n", - "# Sweep every attributable direction, not just the first few: the gate is a\n", - "# comparison against a random control, so which directions pass is the result.\n", - "# Deltas print at three decimals because the fourth differs between CPU kernels.\n", - "print(f\"{'dir':>4} {'sigma':>9} {'delta':>10} {'baseline':>10} {'gated':>6}\")\n", - "for idx in eligible_ids:\n", - " ov.require_isolated(idx)\n", - " row = ov.rank_report[idx]\n", - " result = patch_along_directions(\n", - " model, ov, PROMPT, metric, keep=[idx], rng=torch.Generator().manual_seed(0)\n", - " )\n", - " print(\n", - " f\"{row.idx:>4} {row.sigma:>9.4f} {result.delta_metric:>10.3f} \"\n", - " f\"{result.baseline_delta_metric:>10.3f} {str(result.gated):>6}\"\n", - " )\n" + "\n", + "def logit_diff(logits):\n", + " return float(logits[0, -1, mary] - logits[0, -1, john])\n", + "\n", + "\n", + "def patched_logit_diff(projector):\n", + " \"\"\"Logit difference with the head's output replaced by its image under projector.\"\"\"\n", + "\n", + " def hook(result, hook):\n", + " result = result.clone()\n", + " result[:, :, HEAD] = result[:, :, HEAD] @ projector\n", + " return result\n", + "\n", + " return logit_diff(model.run_with_hooks(PROMPT, fwd_hooks=[(hook_name, hook)]))\n", + "\n", + "\n", + "def keep(v):\n", + " return torch.outer(v, v)\n", + "\n", + "\n", + "def ablate(v):\n", + " return identity - torch.outer(v, v)\n", + "\n", + "\n", + "original = logit_diff(model(PROMPT))\n", + "print(f\"original logit difference: {original:.3f}\")\n", + "print(f\"whole-head ablation delta: {patched_logit_diff(0 * identity) - original:+.3f}\")\n", + "\n", + "N_CONTROLS = 32\n", + "rank = sum(not row[\"null\"] for row in report)\n", + "draws = torch.randn(rank, N_CONTROLS, generator=torch.Generator().manual_seed(0)).to(V.device)\n", + "controls = V[:, :rank] @ torch.nn.functional.normalize(draws, dim=0)" ] }, { "cell_type": "code", "execution_count": 7, "id": "8044e885", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T19:03:23.363532Z", + "iopub.status.busy": "2026-10-14T19:03:23.363474Z", + "iopub.status.idle": "2026-10-14T19:03:24.948521Z", + "shell.execute_reply": "2026-10-14T19:03:24.948177Z" + } + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - " dir sigma delta baseline gated\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 0 8.4096 -0.012 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 1 8.2600 -0.015 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 2 8.1019 -0.014 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 23 6.8420 -0.058 0.041 True\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 48 5.5535 -0.001 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 49 5.4845 0.016 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 50 5.3609 -0.005 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 51 5.2535 -0.036 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 55 5.0464 0.040 0.041 False\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " 56 4.9738 0.025 0.041 False\n" + "\n", + "keep: mean |delta| over 32 random in-span directions 0.132\n", + " dir sigma delta share\n", + " 0 8.4096 0.154 0.66\n", + " 1 8.2600 0.141 0.62\n", + " 2 8.1019 0.132 0.53\n", + " 23 6.8420 0.193 0.84\n", + " 48 5.5535 0.131 0.53\n", + " 49 5.4845 0.113 0.31\n", + " 50 5.3609 0.131 0.53\n", + " 51 5.2535 0.163 0.78\n", + " 55 5.0464 0.078 0.16\n", + " 56 4.9738 0.114 0.31\n", + " 59 4.7041 0.131 0.53\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " 59 4.7041 0.002 0.041 False\n" + " 60 4.6103 0.136 0.62\n", + " 61 4.4351 0.172 0.78\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " 60 4.6103 -0.016 0.041 False\n" + "\n", + "ablate: mean |delta| over 32 random in-span directions 0.041\n", + " dir sigma delta share\n", + " 0 8.4096 -0.012 0.31\n", + " 1 8.2600 -0.015 0.34\n", + " 2 8.1019 -0.014 0.34\n", + " 23 6.8420 -0.058 0.72\n", + " 48 5.5535 -0.001 0.03\n", + " 49 5.4845 0.016 0.34\n", + " 50 5.3609 -0.005 0.16\n", + " 51 5.2535 -0.036 0.56\n", + " 55 5.0464 0.040 0.59\n", + " 56 4.9738 0.025 0.44\n", + " 59 4.7041 0.002 0.12\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - " 61 4.4351 -0.032 0.041 False\n" + " 60 4.6103 -0.016 0.34\n", + " 61 4.4351 -0.032 0.53\n" ] } ], "source": [ "# NBVAL_IGNORE_OUTPUT\n", - "# Same reason as the keep-mode table: the deltas are float32 reductions, and a\n", - "# value sitting on a rounding boundary can print one ulp apart across CPU kernels.\n", - "print(f\"{'dir':>4} {'sigma':>9} {'delta':>10} {'baseline':>10} {'gated':>6}\")\n", - "for idx in eligible_ids:\n", - " ov.require_isolated(idx)\n", - " row = ov.rank_report[idx]\n", - " result = patch_along_directions(\n", - " model, ov, PROMPT, metric, ablate=[idx], rng=torch.Generator().manual_seed(0)\n", + "# Same reason as above: the deltas are float32 reductions that can differ in the last printed\n", + "# digit across CPU kernels, which can also move a share by one control.\n", + "for mode, projector in ((\"keep\", keep), (\"ablate\", ablate)):\n", + " control_deltas = torch.tensor(\n", + " [abs(patched_logit_diff(projector(v)) - original) for v in controls.T]\n", " )\n", - " print(\n", - " f\"{row.idx:>4} {row.sigma:>9.4f} {result.delta_metric:>10.3f} \"\n", - " f\"{result.baseline_delta_metric:>10.3f} {str(result.gated):>6}\"\n", - " )\n" + " mean = float(control_deltas.mean())\n", + " print(f\"\\n{mode}: mean |delta| over {N_CONTROLS} random in-span directions {mean:.3f}\")\n", + " print(f\"{'dir':>4} {'sigma':>9} {'delta':>10} {'share':>6}\")\n", + " for idx in eligible_ids:\n", + " delta = patched_logit_diff(projector(V[:, idx])) - original\n", + " share = float((control_deltas < abs(delta)).float().mean())\n", + " print(f\"{idx:>4} {float(S[idx]):>9.4f} {delta:>10.3f} {share:>6.2f}\")" ] }, { @@ -611,14 +549,19 @@ "source": [ "## 5. What this does and does not show\n", "\n", - "This example decomposes one GPT-2-small head and measures prompt-specific effects of retaining or ablating eligible OV directions. The reported gate verdicts are comparisons with an averaged random in-span control. They do not establish named subfunctions, statistical separation from arbitrary directions, or replication of the paper's causal subfunction taxonomy.\n", + "This example decomposes one GPT-2-small head and measures prompt-specific effects of retaining or ablating eligible OV directions, next to random in-span controls. It does not establish named subfunctions, statistical separation from arbitrary directions, or replication of the paper's causal subfunction taxonomy.\n", "\n", "- **Single head, single model, single prompt.** Everything above concerns layer 9 head 9 of `gpt2-small` on one IOI prompt. Nothing here assembles a multi-head circuit.\n", "- **Descriptive projections.** Orthogonal weight-space directions are mathematically distinct; vocab readouts and activation coefficients do not supply semantic labels.\n", - "- **Comparisons, not significance.** The tables report metric changes and mean control magnitudes, not control distributions or confidence bounds. They do not establish that any direction is distinguishable from an arbitrary one.\n", - "- **Downstream responses.** The whole-head ablation delta measures the final logit difference after downstream computation, not the head's direct write contribution. A small final-logit effect need not imply a small direct contribution; these measurements do not identify a specific compensating component.\n", - "- **Repeatability, not confirmation.** Verdicts near the mean control magnitude can change with the seed or draw count. Fixing the seed makes the comparison repeatable, not scientifically conclusive.\n", - "- **No automated labelling.** A passing gate does not name what a direction computes. Named-subfunction claims would need additional prompts, counterfactuals, and suitable control-distribution analyses." + "- **Comparisons, not significance.** The tables report metric changes and how they sit among 32 random controls, not confidence bounds. They do not establish that any direction is distinguishable from an arbitrary one.\n", + "- **Downstream responses.** Every delta measures the final logit difference after downstream computation, not the head's direct write contribution. A small final-logit effect need not imply a small direct contribution, and these measurements do not identify a compensating component.\n", + "- **Repeatability, not confirmation.** Shares near the middle can change with the seed or the number of controls. Fixing the seed makes the comparison repeatable, not scientifically conclusive.\n", + "\n", + "## Using this on other models\n", + "\n", + "- The layer must hold an attention block with `W_Q`, `W_K`, `W_V` and `W_O`. SSM, mixer, linear-attention and AltUp layers, and encoder-decoder bridges without a `blocks` stack, have no such head to decompose.\n", + "- Sections 3 and 4 need per-head outputs: `set_use_attn_result(True)` raises `NotImplementedError` on attention bridges that cannot split their output by head. Check that `hook_result` actually appears in the cache before trusting a patch, since a patch on a hook that never fires changes nothing.\n", + "- Decompose, read out and patch under one weight-processing state. The weights, the final norm and `hook_result` all change when compatibility mode folds and centers them, so a decomposition taken before `enable_compatibility_mode()` does not describe the model afterwards." ] } ], @@ -633,375 +576,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.14" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": { - "0d7d9821c27948d385b373deb184db49": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "HBoxModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "HBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "2.0.0", - "_view_name": "HBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_24f11b35f964473192adb75bfbdbf493", - "IPY_MODEL_b22b885ac6564eea9cc8335688983a09", - "IPY_MODEL_e21e1c9765e94501a539b796224d928e" - ], - "layout": "IPY_MODEL_6f36e52abc6b437abf8702731770d747", - "tabbable": null, - "tooltip": null - } - }, - "12e5daca51bc4f4ab0830e9f3c6d11b6": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "2.0.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "2.0.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border_bottom": null, - "border_left": null, - "border_right": null, - "border_top": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "24f11b35f964473192adb75bfbdbf493": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "2.0.0", - "_view_name": "HTMLView", - "description": "", - "description_allow_html": false, - "layout": "IPY_MODEL_340f81a8ccbc41a2b4eb96ff2d49c5e1", - "placeholder": "​", - "style": "IPY_MODEL_3ad1b21f0cee48caa22019b226fde81a", - "tabbable": null, - "tooltip": null, - "value": "Loading weights: 100%" - } - }, - "340f81a8ccbc41a2b4eb96ff2d49c5e1": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "2.0.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "2.0.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border_bottom": null, - "border_left": null, - "border_right": null, - "border_top": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "3ad1b21f0cee48caa22019b226fde81a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "HTMLStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "HTMLStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "StyleView", - "background": null, - "description_width": "", - "font_size": null, - "text_color": null - } - }, - "6f36e52abc6b437abf8702731770d747": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "2.0.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "2.0.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border_bottom": null, - "border_left": null, - "border_right": null, - "border_top": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "a81ad8e18e5345619986fa93b1de0f4e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "HTMLStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "HTMLStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "StyleView", - "background": null, - "description_width": "", - "font_size": null, - "text_color": null - } - }, - "af26ae76f36443f59561c3359e95da17": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "2.0.0", - "model_name": "LayoutModel", - "state": { - "_model_module": "@jupyter-widgets/base", - "_model_module_version": "2.0.0", - "_model_name": "LayoutModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "LayoutView", - "align_content": null, - "align_items": null, - "align_self": null, - "border_bottom": null, - "border_left": null, - "border_right": null, - "border_top": null, - "bottom": null, - "display": null, - "flex": null, - "flex_flow": null, - "grid_area": null, - "grid_auto_columns": null, - "grid_auto_flow": null, - "grid_auto_rows": null, - "grid_column": null, - "grid_gap": null, - "grid_row": null, - "grid_template_areas": null, - "grid_template_columns": null, - "grid_template_rows": null, - "height": null, - "justify_content": null, - "justify_items": null, - "left": null, - "margin": null, - "max_height": null, - "max_width": null, - "min_height": null, - "min_width": null, - "object_fit": null, - "object_position": null, - "order": null, - "overflow": null, - "padding": null, - "right": null, - "top": null, - "visibility": null, - "width": null - } - }, - "b22b885ac6564eea9cc8335688983a09": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "FloatProgressModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "FloatProgressModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "2.0.0", - "_view_name": "ProgressView", - "bar_style": "success", - "description": "", - "description_allow_html": false, - "layout": "IPY_MODEL_af26ae76f36443f59561c3359e95da17", - "max": 148.0, - "min": 0.0, - "orientation": "horizontal", - "style": "IPY_MODEL_d015b8b53ca44792a760b887c651702c", - "tabbable": null, - "tooltip": null, - "value": 148.0 - } - }, - "d015b8b53ca44792a760b887c651702c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "ProgressStyleModel", - "state": { - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "ProgressStyleModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/base", - "_view_module_version": "2.0.0", - "_view_name": "StyleView", - "bar_color": null, - "description_width": "" - } - }, - "e21e1c9765e94501a539b796224d928e": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "2.0.0", - "model_name": "HTMLModel", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "HTMLModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "2.0.0", - "_view_name": "HTMLView", - "description": "", - "description_allow_html": false, - "layout": "IPY_MODEL_12e5daca51bc4f4ab0830e9f3c6d11b6", - "placeholder": "​", - "style": "IPY_MODEL_a81ad8e18e5345619986fa93b1de0f4e", - "tabbable": null, - "tooltip": null, - "value": " 148/148 [00:00<00:00, 6931.73it/s]" - } - } - }, - "version_major": 2, - "version_minor": 0 - } + "version": "3.12.12" } }, "nbformat": 4, diff --git a/demos/direct_path_patching_ioi.ipynb b/demos/direct_path_patching_ioi.ipynb index 451ff7f618..3cf65998df 100644 --- a/demos/direct_path_patching_ioi.ipynb +++ b/demos/direct_path_patching_ioi.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "markdown", + "id": "d86b771e", "metadata": {}, "source": [ "\n", @@ -11,6 +12,7 @@ }, { "cell_type": "markdown", + "id": "2ee6448a", "metadata": {}, "source": [ "# Direct Path Patching Demo\n", @@ -32,6 +34,7 @@ }, { "cell_type": "markdown", + "id": "23e826af", "metadata": {}, "source": [ "## Setup" @@ -39,9 +42,25 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 1, + "id": "71dc65a3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T20:18:19.289482Z", + "iopub.status.busy": "2026-10-14T20:18:19.289275Z", + "iopub.status.idle": "2026-10-14T20:18:19.296256Z", + "shell.execute_reply": "2026-10-14T20:18:19.295511Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running as a Jupyter notebook\n" + ] + } + ], "source": [ "# NBVAL_IGNORE_OUTPUT\n", "import os\n", @@ -57,17 +76,26 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, + "execution_count": 2, + "id": "09ebc246", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T20:18:19.298051Z", + "iopub.status.busy": "2026-10-14T20:18:19.297866Z", + "iopub.status.idle": "2026-10-14T20:18:21.078634Z", + "shell.execute_reply": "2026-10-14T20:18:21.078157Z" + } + }, "outputs": [], "source": [ "import torch\n", "from transformer_lens.model_bridge import TransformerBridge\n", - "from transformer_lens.tools.analysis import get_act_patch_direct_path" + "from transformer_lens.patching import get_act_patch_direct_path" ] }, { "cell_type": "markdown", + "id": "20a16166", "metadata": {}, "source": [ "## Load Model" @@ -75,9 +103,39 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 3, + "id": "1151565d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-14T20:18:21.079892Z", + "iopub.status.busy": "2026-10-14T20:18:21.079770Z", + "iopub.status.idle": "2026-10-14T20:18:21.631999Z", + "shell.execute_reply": "2026-10-14T20:18:21.631547Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "952b1c61fd714fe48ff1f33bb359b754", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Loading weights: 0%| | 0/148 [00:00 unembed` on the last block (multi-stream AltUp blocks, a head transform such as BERT's `mlm_head`). When DLA runs the prompt, the model and all submodules must be in evaluation mode, or it raises `ValueError` before the forward; a supplied cache is accepted as is. | One cached forward pass, or reuse of a suitable cache. Head decomposition can require additional per-head results. | | Activation Patching | Any bridge whose activation names resolve (`resid_pre`, `attn_out`, `mlp_out`, `z`, `q`/`k`/`v`, `pattern`). The metric returns a scalar by default and a `[batch]` tensor under `reduce="mean"` or `"none"`; several metrics can be passed as a mapping and share one sweep. Run the model in evaluation mode. | One forward of the corrupted batch per swept cell (plus one for `baseline=True`); per-example storage is `cells × batch` floats per metric. Statistics are computed afterwards from the stored values and add no forwards. | | Attribution Patching | Targets TransformerBridge. Clean/corrupt inputs must be `[batch, seq]` token tensors with matching shapes and aligned positions. The required embedding, head, and MLP hook aliases must exist. Edge granularity additionally requires attention bridges that support per-head results and a pre-Q/K/V residual fork. On GQA/MQA models, where any attention layer's K/V head count (`per_layer_num_key_value_heads` where set, else `n_key_value_heads`) differs from `n_heads`, edge granularity and faithfulness raise `ValueError` before any forward; node granularity remains available. Edge granularity and faithfulness are unsupported on post-norm blocks (such as Gemma 2/3, OLMo 2/3 and GLM-4), scaled-residual blocks (such as Granite) and parallel attention/SSM blocks (such as Falcon-H1), where per-head outputs are normalized, scaled or joined by an unmodeled mixer before the residual add; both raise there, and node granularity remains available. Edge sweeps temporarily enable the required writer and reader hooks, then restore the caller's hook flags. Run the model and all nested stochastic modules in evaluation mode. The metric must return a differentiable scalar, and the current backward-cache path requires an active autograd graph; fully frozen models are not yet supported. | Two forwards and one backward **per prompt pair**; activation and gradient caches. For a uniform-head decoder, the number of edge scores grows as `O(seq_len × (n_layers × n_heads)²)`, while the cache also gains per-head `[batch, seq, n_heads, d_model]` tensors. Pair scores are averaged across the batch. | -| Direct Path Patching | Requires a TransformerBridge exposing the expected attention weights, Q/K/V hook aliases, source `hook_z`, and destination LayerNorm scales. Folded LayerNorm parameters improve the approximation. On GQA/MQA models, direct Q-path patching remains available, but `component="k"` and `"v"` require `n_key_value_heads == n_heads` and raise `ValueError` otherwise, since per-query-head K/V semantics are not yet defined. Every block from the source layer on must have an `attn` component; SSM, mixer, linear-attention and AltUp blocks raise `ValueError` before any forward. Run the model and all submodules in evaluation mode; both entry points raise `ValueError` before any forward otherwise. | Clean/corrupt caching, then repeated forwards over destination heads. Sweeping all source heads adds another sweep dimension. | +| Direct Path Patching | Requires a TransformerBridge whose attention layers have the pre-Q/K/V residual fork that `use_split_qkv_input` enables (the joint-QKV and position-embedding attention bridges, e.g. GPT-2, Pythia, Llama, Mistral, Qwen2); attention without it, such as GPT-J, GPT-Neo, OPT, BERT or a bridge built with `TransformerBridge.boot_native`, raises `NotImplementedError`. The supplied caches must hold the source layer's `hook_z`. The function turns the fork flag on for the sweep and restores the caller's `use_split_qkv_input` and `use_attn_in`; LayerNorm need not be folded. On GQA/MQA models, direct Q-path patching remains available, but `component="k"` and `"v"` require `n_key_value_heads == n_heads` and raise `ValueError` otherwise, since K/V inputs are per K/V head rather than per query head. Every block from the source layer on must have an `attn` component; SSM, mixer, linear-attention and AltUp blocks raise `ValueError` before any forward. Source blocks must add the head outputs to the residual unchanged: post-norm blocks (such as Gemma 2/3, OLMo 2/3 and GLM-4), scaled-residual blocks (such as Granite) and parallel attention/SSM blocks (such as Falcon-H1) raise `ValueError` before any forward, since a head's residual write there is not `hook_z @ W_O`. Run the model and all submodules in evaluation mode; both entry points raise `ValueError` before any forward otherwise. | Clean/corrupt caching, then one forward of the corrupted batch per later destination head. While the fork is on, every attention layer normalizes a `[batch, pos, n_heads, d_model]` residual copy for each of Q, K and V, about `3 × n_heads` times the usual attention-input activation memory. Sweeping all source heads adds another sweep dimension. | | Logit Lens | Targets TransformerBridge, raw or compatibility mode. Needs a decoder with an `ln_final` component (or no final norm) and `W_U`; encoder-decoder and encoder-only bridges are rejected, as are quantized unembeddings and bridges whose readout is not `ln_final -> unembed` on the last block (multi-stream AltUp blocks, a head transform such as BERT's `mlm_head`; a prompt run with `apply_ln=True` is also checked against the model's own output, a supplied cache only structurally). Hybrid SSM stacks work with `incl_mid=False`. Run the model in evaluation mode. | One cached forward pass over the residual hooks, or reuse of a cache. Logits are produced in chunks of `chunk_size` rows; full-vocabulary output is materialized only when no `targets`, `top_k` or `vocab` selector is given. | -| Backward Lens | Requires a raw decoder-only TransformerBridge with dense, non-gated MLPs and without compatibility mode, such as GPT-2 or Pythia/GPT-NeoX. Models whose MLP region runs on flattened `[batch * position, width]` tensors (OPT) and models in which no block exposes an `mlp` (Mamba, RWKV) raise `ValueError` at construction; in a hybrid stack, a requested layer without an MLP raises `ValueError`. Use one target token and check the restrictions in the [tool guide](backward_lens.md). Run the model and all submodules in evaluation mode; `analyze` raises `ValueError` before the forward otherwise. | Gradient computation plus vocabulary projections for selected layers and positions; retaining full logits increases memory. | | Jacobian Lens | Requires a fresh, causal decoder-only Bridge with raw HF weights, without compatibility mode or weight processing. Validate the lens against the model. Run the model and all submodules in evaluation mode; fitting and every call that runs the model raise `ValueError` otherwise. | Loading an existing artifact avoids fitting. The ordinary fitting estimator uses one forward and `ceil(d_model / dim_batch)` backwards per prompt; larger batches increase memory. | | Interchange Interventions | Any model with the hook API (TransformerBridge or `HookedRootModule`). Activations at the hook must be `[batch, d_model]`, or `[batch, pos, d_model]` with one position index per example; base and source rows are paired. | One forward pass per `batch_size` sources to collect activations, then one per `batch_size` bases per intervention with `intervened_logits`; both run without gradients, so memory is bounded by `batch_size`. `run_with_intervention` is a single differentiable pass over all the bases it is given. DAS adds a forward and backward pass per batch per epoch; `micro_batch_size` bounds its memory. | | Projection Kernel | The numerical API accepts finite, real floating-point matrices via orthonormal bases. The attention-head wrapper requires Bridge weights with compatible dimensions and ranks. | Basis extraction uses SVD. All-head comparisons allocate basis stacks and a pairwise score grid; they can be large despite requiring no forward pass. | -| SVD head decomposition | Uses Bridge per-block weight accessors; requires accessible, compatible `W_Q`, `W_K`, `W_V`, and `W_O` for the selected head. `decompose_head` needs an attention block (`blocks[layer].attn`) at the requested layer; SSM, mixer, linear-attention and AltUp blocks and encoder-decoder bridges raise `ValueError`. No compatibility mode or activation cache is needed. `project_activations` and `patch_along_directions` read the per-head `hook_result`: bridges that cannot compute it raise `NotImplementedError` from `set_use_attn_result`, and an attention bridge that accepts the flag but never fires `hook_result` raises `ValueError` instead of returning a projection or a gate verdict. | Factored QK/OV SVD, with rank bounded by `d_head`; the dense `d_model × d_model` product is not materialized. | **Compatibility mode is a method-specific choice.** DLA needs it on Bridge, whereas Jacobian Lens requires raw weights. Use separately loaded models when comparing these @@ -206,20 +202,34 @@ class present in the candidate; absent classes reuse aggregate recovery. API: {func}`~transformer_lens.tools.analysis.attribution_patching.faithfulness`. -### Direct Path Patching: state the path and its approximation - -`get_act_patch_direct_path` fixes a source head and sweeps later destination heads, -patching into their Q, K, or V inputs. It reconstructs the source head's output from -cached `hook_z` and `W_O`, so caching `hook_result` is unnecessary. The calculation -projects the source-output change using cached LayerNorm scaling; it does not -recompute the full nonlinear normalization response to that change. Folded -parameters do not remove this fixed-scale assumption. +### Direct Path Patching: patch one edge exactly + +`get_act_patch_direct_path` (in `transformer_lens.patching`, also importable from +`transformer_lens.tools.analysis`) fixes a source head and sweeps every later head, +patching only the path from the source into that head's Q, K, or V input. The source +head's residual write on each run is its cached `hook_z` times `W_O`. For each +destination, the corrupted prompt is rerun with the clean-minus-corrupted write added, +at every position, to the destination head's own copy of the residual, before its +LayerNorm. The norm and projection are recomputed on that copy and every other reader +still sees the corrupted output, so on pre-norm blocks the score is the exact edge +intervention, with LayerNorm folded or not. It equals ablating the same per-position +edges with the edge-ablation hook behind `faithfulness()`. A source block that +normalizes, scales or joins its attention output before the residual add (post-norm, +scaled-residual or parallel-mixer blocks) is refused, since the head's write there is +not `hook_z @ W_O`. + +That per-head copy is the `hook_q_input`/`hook_k_input`/`hook_v_input` fork, which +fires only while `use_split_qkv_input` is on. The function turns the flag on for the +sweep (and `use_attn_in` off, since the two are exclusive) and restores both on +return or error, so you set neither. While it is on, each attention layer normalizes a +separate residual copy per head for each of Q, K and V; budget activation memory for +long prompts or many heads accordingly. Interpret a score relative to the unpatched corrupt metric. Entries at or before the source layer are zero placeholders, not measured interventions. State the source head, destination input, prompt pair, and normalization convention when reporting a path. -API: {func}`~transformer_lens.tools.analysis.direct_path_patching.get_act_patch_direct_path`. +API: {func}`~transformer_lens.patching.get_act_patch_direct_path`. ### Logit Lens: read the stream through the real norm @@ -239,7 +249,11 @@ without materializing the full vocabulary; `top_k=` keeps the leading tokens wit their ids; `vocab=` keeps a subset. Ranks, entropy and log-probabilities are always computed over the full vocabulary, whatever was selected. `logit_readout` is the same kernel for any `d_model` vector: a head output, a steering direction, a -sparse-coder decoder column. +sparse-coder decoder column. The +[SVD Circuits demo](../generated/demos/SVD_Circuits_Demo.ipynb) reads attention-head +singular directions through it, and the +[Backward Lens demo](../generated/demos/Backward_Lens_Demo.ipynb) reads MLP +weight-gradient factors. Intermediate-layer readouts are observations, not interventions. A token ranked first at layer 6 is what the unembedding would say *if the model stopped there*; it @@ -248,17 +262,6 @@ small early residual. Pair a lens trajectory with patching or DLA before making causal claim. For a fitted per-layer map instead of the identity, see [Jacobian Lens](#jacobian-lens-distinguish-readout-reconstruction-and-intervention). -### Backward Lens: preserve the gradient sign - -Vocabulary rankings describe factors of `d(loss) / d(weight)`. Gradient descent -**subtracts** this gradient. The highest raw-gradient token is therefore not -automatically the token favored by an update. Use the documented bottom rankings -and gradient-descent target ranks where appropriate. Align positions using -`result.prompt_token_ids`, which records the actual tokenization. - -See [Backward Lens](backward_lens.md) for the example, sign conventions, and result -structure. - ### Jacobian Lens: distinguish readout, reconstruction, and intervention Start with a matching published artifact when possible and call @@ -282,17 +285,6 @@ behavior on a prompt. In attention-head results, use `valid_mask` when interpret scores: invalid pairs are stored as zeros. See [Projection Kernel](projection_kernel.md) for rank selection, normalization, and all-head memory costs. -For SVD head decomposition, inspect the rank report before assigning meaning to an -individual direction. Near-equal singular values define a subspace whose basis can -rotate; numerically null directions are also unsuitable for individual attribution. -The weight decomposition alone is not a causal validation of a proposed subfunction. -See [SVD Circuits](svd_circuits.md) for the degeneracy guard, the causal gate, and a -worked example, and the [SVD Circuits demo](../generated/demos/SVD_Circuits_Demo.html) -for a runnable walkthrough. - -API: {func}`~transformer_lens.tools.analysis.svd_circuits.decompose_head`, -{func}`~transformer_lens.tools.analysis.svd_circuits.patch_along_directions`. - ## Try a geometry question without downloading a model The following subspaces share exactly one axis. Their raw Projection Kernel score diff --git a/docs/source/content/backward_lens.md b/docs/source/content/backward_lens.md deleted file mode 100644 index a62110945e..0000000000 --- a/docs/source/content/backward_lens.md +++ /dev/null @@ -1,235 +0,0 @@ -# Backward Lens - -Backward Lens projects factors of MLP weight gradients into a language model's -vocabulary space. It is useful for inspecting which token directions are associated -with the forward inputs and backward signals that compose a gradient. A readable -vocabulary projection is a diagnostic, not by itself evidence that a token or neuron -causes a model behavior. - -TransformerLens supports dense-MLP decoder-only implementations through -`TransformerBridge` (for example GPT-2 and Pythia/GPT-NeoX). It follows the method -introduced by [Katz et al. (2024)](https://aclanthology.org/2024.emnlp-main.142/). - -## Gradient factorization - -For one linear projection and one prompt, let $x_i \in \mathbb{R}^{d_{in}}$ be -the input at token position $i$, and let -$\delta_i = \partial L / \partial y_i \in \mathbb{R}^{d_{out}}$ be the loss -gradient at its output. A dense MLP projection stores its weight either as -`[in, out]` (`Conv1D`, e.g. GPT-2) or as `[out, in]` (`torch.nn.Linear`, e.g. -Pythia/GPT-NeoX). `BackwardLens` reads which layout applies from the Bridge -projection component itself, not from the model class, then computes - -$$ -\nabla_W L = \sum_i x_i \delta_i^\mathsf{T} = X^\mathsf{T}\Delta, -$$ - -reporting the result in that projection's own storage layout: directly for -`[in, out]` storage, transposed for `[out, in]` storage. - -`BackwardLens` captures both factors and independently computes the weight gradient. -Each matrix result includes the reconstructed gradient and maximum absolute and -scale-aware relative reconstruction errors. - -### The two dense MLP matrices - -The two projections expose different residual-width factors. The weight shapes -below use `Conv1D` (`[in, out]`) storage, as GPT-2 uses; `torch.nn.Linear` storage -(e.g. Pythia/GPT-NeoX) reports each weight transposed. - -| Result | Weight shape | Projected factor | Shape before vocabulary projection | -|---|---:|---|---:| -| `input_projection` (FF1 / `c_fc`) | `[d_model, d_mlp]` | Forward input $x_i$ | `[position, d_model]` | -| `output_projection` (FF2 / `c_proj`) | `[d_mlp, d_model]` | Backward signal $\delta_i$ | `[position, d_model]` | - -The FF1 readout therefore describes the layer-normalized residual state entering the -MLP (post-`ln_2`, including its gain and bias). -The FF2 readout describes raw loss gradients at the MLP output. These are different -quantities and should not be interpreted interchangeably. - -## Vocabulary projection - -For each residual-width row $v$, the lens computes a fresh readout - -$$ -P(v) = \operatorname{Unembed}(\operatorname{LN}_{final}(v)). -$$ - -Final-normalization statistics are recomputed independently for every factor. The -implementation does not reuse normalization scales cached during the model's forward -pass. The retained rankings contain signed, pre-softmax values; they do not contain -probabilities. By default, each matrix keeps only the 10 largest and 10 smallest -values and token ids per position. Set `top_k` to change that bound or -`return_full_logits=True` to also retain the full vocabulary tensors. - -When `normalized=True`, the analysis also computes the Normalized Logit Lens: - -$$ -P_{norm}(v) = P\left(\frac{v}{\lVert v\rVert_2}\right). -$$ - -Exact-zero rows remain zero before projection and are identified by `zero_norm_mask`. -The original float32 norms are retained in `factor_norms`. Normalization is most useful -when comparing directions whose norms differ greatly, especially very small backward -signals. Because final LayerNorm has an epsilon and may have a bias, raw and normalized -projections need not be identical. - -## Sign convention - -All backward signals and weight gradients preserve the raw `d(loss) / d(tensor)` -sign. Gradient descent subtracts them: - -$$ -W_{new} = W - \eta \nabla_W L. -$$ - -For FF2 backward signals, `bottom(...)` and -`gradient_descent_target_ranks(...)` inspect the smallest raw-gradient logits, which -are often the most relevant ordering for the subtracted update. Do not simply negate -projected logits: final LayerNorm bias and epsilon mean that projection is not exactly -sign-symmetric. - -## Minimal example - -```python -import torch - -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.tools.analysis import BackwardLens - -model = TransformerBridge.boot_transformers( - "openai-community/gpt2", - device="cuda" if torch.cuda.is_available() else "cpu", - dtype=torch.float32, -) - -result = BackwardLens(model).analyze( - prompt="The capital of France is", - target_token=" Paris", - layers=[0, 6, 11], - normalized=True, - top_k=10, -) - -last_layer = result.layer(11) -ff1 = last_layer.input_projection -ff2 = last_layer.output_projection - -ff1_tokens = ff1.top_tokens(model.tokenizer, k=5) -ff2_update_tokens = ff2.bottom_tokens(model.tokenizer, k=5) -target_ranks = ff2.gradient_descent_target_ranks( - result.target_token_id, - normalized=True, -) -``` - -`target_token` must encode to exactly one token without a beginning-of-sequence token. -For GPT-2 tokenization, a leading space is often significant. The prompt is tokenized -according to the model and tokenizer configuration, so it includes a prepended BOS -only when that configuration requests one. Treat `result.prompt_token_ids` as the -source of truth for aligning all position-indexed factors and readouts. - -The same call works unchanged against a Pythia/GPT-NeoX Bridge, whose `torch.nn.Linear` -MLP projections resolve to the `"out_in"` weight layout instead of GPT-2's `"in_out"`: - -```python -pythia = TransformerBridge.boot_transformers( - "EleutherAI/pythia-70m", device="cpu", dtype=torch.float32 -) - -pythia_result = BackwardLens(pythia).analyze( - prompt="The capital of France is", - target_token=" Paris", - layers=[0], -) - -pythia_result.layer(0).input_projection.factors.weight_layout # "out_in" -``` - -## Result structure - -`BackwardLens.analyze(...)` returns a detached `BackwardLensResult`: - -- `prompt`, `prompt_token_ids`, `target_token`, and `target_token_id` record the inputs. -- `loss` is final-position cross-entropy against the one-token target. -- `layers` preserves the requested layer order; `result.layer(index)` retrieves one. -- Each layer has `input_projection` and `output_projection` matrix results. -- Each matrix exposes `factors`, `factor_norms`, `zero_norm_mask`, `vocabulary_size`, - and retained raw `top_ranking` and `bottom_ranking` values and token ids. -- `top(...)` and `bottom(...)` return prefixes of the retained signed rankings, so - their `k` cannot exceed the `top_k` passed to `analyze(...)`. -- `top_tokens(...)` and `bottom_tokens(...)` decode ids with a caller-provided - tokenizer. Results deliberately retain no model or tokenizer reference. -- The analyzed target's exact ranks are always retained as zero-based competition - ranks, so tied logits receive the same rank. Ranking another token requires full - logits. -- `vocabulary_logits` is present only with `return_full_logits=True`. - `normalized_top_ranking`, `normalized_bottom_ranking`, and normalized target ranks - are present with `normalized=True`; full `normalized_vocabulary_logits` requires - both options. -- `includes_normalized_logits` and `includes_full_logits` record the requested modes. -- Maximum reconstruction errors summarize both MLP matrices over all requested layers. - -Returned tensors are detached, owned CPU copies. Factors, reconstructed gradients, -norms, retained values, and optional vocabulary logits use float32; token ids and -ranks use int64. The bounded default avoids retaining a -`[layer, matrix, position, d_vocab]` collection of full tensors. - -## Requirements and non-goals - -The current implementation requires: - -- A freshly booted, raw `TransformerBridge` whose dense MLP projections have a - Bridge-orientable weight layout (`Conv1D`, e.g. GPT-2, or `torch.nn.Linear`, - e.g. Pythia/GPT-NeoX). -- Original, trainable weights and a dense, non-gated MLP. -- MLP projections whose input and output tensors keep the `[batch, position, width]` - layout. The lens reads those tensors directly, so a model whose MLP region runs on - flattened `[batch * position, width]` tensors (OPT) is refused at construction, as is - a model in which no block exposes an `mlp` (Mamba, RWKV). In a - hybrid stack, requesting a layer whose block has no MLP raises `ValueError`. -- Compatibility mode and weight processing to remain disabled. -- The model and all submodules in evaluation mode (`model.eval()`). -- One non-empty prompt, one single-token target, and unique valid layer indices. - -It does not currently support batched prompts, multi-token target losses, gated MLPs, -models without MLPs, architecture families whose MLP projections have an unknown weight -layout or a flattened token layout, compatibility-mode weights, model editing, or causal -claims about the displayed vocabulary rankings. - -## Model-state safety - -An analysis uses one gradient-enabled forward pass and one `torch.autograd.grad` call. -Dropout in training mode would perturb those gradients, so once its inputs validate, -`analyze(...)` refuses a model with any module in training mode instead of switching -it to evaluation mode. It does not call `backward()` or modify parameter `.grad` -buffers. It preserves model weights, `requires_grad` flags, train/eval state, existing -hooks, and CPU/CUDA/MPS RNG state, and it removes only its own temporary hooks on -success or failure. Existing activation-editing hooks still affect the analyzed -computation. - -## Troubleshooting - -| Symptom | Cause and resolution | -|---|---| -| Raw-Bridge or processed-weight error | Reboot with `TransformerBridge.boot_transformers(...)`; do not enable compatibility mode or process weights. | -| Evaluation-mode error | A module, often dropout, is in training mode; call `model.eval()` before analyzing. | -| Construction error naming a `hook_conversion` | The model's MLP region runs on flattened `[batch * position, width]` tensors (OPT), which the lens reads before the Bridge converts them; such models are not supported. | -| Construction error saying no block has an `mlp` | No block exposes an `mlp` to analyze (Mamba, RWKV); such models are not supported. | -| `layer N has no MLP` error | In a hybrid stack, that layer's block has no MLP; request layers whose blocks have one. | -| Target encodes to zero or multiple tokens | Choose text that maps to one token under the model's tokenizer without BOS; check leading whitespace. | -| Duplicate or out-of-range layer error | Pass a non-empty sequence of unique indices in `[0, model.cfg.n_layers)`. | -| Normalized logits were not requested | Call `analyze(..., normalized=True)` before using `logits(normalized=True)` or normalized ranks. | -| Full logits were not retained | Call `analyze(..., return_full_logits=True)` before using `logits(...)` or ranking a token other than the analyzed target. | -| Requested `k` exceeds retained `top_k` | Increase `top_k` in `analyze(...)`; accessor methods cannot recover discarded rankings. | -| FF2 ranking appears sign-reversed | Remember that results are raw loss gradients and gradient descent subtracts them; inspect bottom tokens or ascending target ranks. | -| Results change when custom hooks are installed | Existing hooks are intentionally respected; remove them to analyze the unmodified model computation. | - -## References - -- [TransformerLens Backward Lens demonstration](https://github.com/TransformerLensOrg/TransformerLens/blob/dev/demos/Backward_Lens_Demo.ipynb). -- Shahar Katz, Yonatan Belinkov, Mor Geva, and Lior Wolf. 2024. - [Backward Lens: Projecting Language Model Gradients into the Vocabulary Space](https://aclanthology.org/2024.emnlp-main.142/). - *Proceedings of EMNLP 2024*, pages 2390–2422. -- [Authors' research demonstration](https://github.com/shacharKZ/BackwardLens). - TransformerLens does not import, vendor, or depend on that repository's code. \ No newline at end of file diff --git a/docs/source/content/jacobian_lens_fitting.md b/docs/source/content/jacobian_lens_fitting.md index d86bdcc974..3f617ca0ff 100644 --- a/docs/source/content/jacobian_lens_fitting.md +++ b/docs/source/content/jacobian_lens_fitting.md @@ -10,6 +10,10 @@ from transformer_lens.tools.analysis import JacobianLens lens = JacobianLens.from_pretrained("gemma-2-2b") ``` +Registry names load the artifact at the Hub commit recorded under `revision` in +`jacobian_lens_registry.json`, so a TransformerLens release always loads the same lens +for a name; pass `revision=` to load a different commit. + Fit a new lens when your model is not in the artifact registry or when you need a different prompt distribution. Fitting is deterministic for a fixed model, prompt manifest, and set of estimator options. @@ -225,185 +229,19 @@ Consumers can then load and validate it directly: lens = JacobianLens.from_pretrained( "your-org/your-jacobian-lenses", filename="gpt2_jacobian_lens.pt", + revision="YOUR_LENS_COMMIT_SHA", model=model, ) ``` +An explicit repo id follows the repository's default branch unless `revision` pins a +commit. + To propose a short-name entry in TransformerLens, open a pull request that adds the published file to `transformer_lens/tools/analysis/jacobian_lens_registry.json` and -include the fitting provenance and validation results. - -## Relevance lens (R-lens) - -The relevance lens is a second estimator for the same transport matrices. It keeps -the fitting loop, readout, vocabulary vectors, sparse decomposition, and -interventions above unchanged, and changes only the backward pass: each per-layer -matrix is estimated with a scoped set of Layer-wise Relevance Propagation rules -instead of the ordinary vector-Jacobian product. The forward pass is bit-identical -to the model's native forward, so the two estimators differ solely in the backward -semantics they select. - -The relevance lens requires a raw `TransformerBridge` in evaluation mode. -`boot_transformers` uses eager attention; explicitly select float32 for fitting -and do not enable compatibility mode or process the weights. - -```python -import torch - -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.tools.analysis import RelevanceLens - -model = TransformerBridge.boot_transformers( - "Qwen/Qwen2-0.5B", device="cpu", dtype=torch.float32 -) -model.eval() -prompts = [ - "The capital of France is Paris, and the capital of Germany is Berlin." -] -lens = RelevanceLens.fit( - model, - prompts, - corpus="example:capital-cities", - source_layers=[0], - dim_batch=8, - max_seq_len=32, - skip_first_positions=2, - show_progress=False, -) -assert lens.rule_coverage is not None -assert not lens.rule_coverage.skipped -readout = lens.readout(model, "The capital of France is", layers=[0], top_k=10) -``` - -This is a bounded walkthrough using one source layer and one short prompt, not a -research-quality corpus. Even this fit performs multiple backward passes; use -`device="cuda"` when available. Larger corpora and earlier-to-final transport -across large models are substantially more expensive. Coverage depends on the -model's components and dispatch paths; the Qwen2 example does not imply that -every gated architecture installs all three rules. - -Everything after the fit is the Jacobian lens surface: `transport`, `readout`, -`lens_vectors`, `lens_vector_dictionary`, `decompose`, `occupancy`, -`fraction_of_variance`, and the steering, ablation, and swap interventions all -behave identically. `RelevanceLens` is a subclass of `JacobianLens`, so code that -accepts a fitted lens accepts either estimator. - -### The three rules - -The estimator installs three rules, matching the RelP reference: - -| Rule | Applies to | Backward semantics | -|---|---|---| -| LN-rule | Residual-stream norms (`ln1`, `ln2`, and the sandwich post-norms `ln1_post`, `ln2_post`) | Preserves the normalization forward value while treating its denominator as a constant | -| Identity-rule | GELU and SiLU activations in gated MLPs | Expresses `f(x) = x * phi(x)` and detaches `phi(x)`, so the local VJP is `grad_out * phi(x)` | -| Half-rule | Multiplicative MLP gates | Preserves the forward product `u * v` while halving each branch's ordinary product-rule gradient | - -Linear layers and attention keep ordinary autograd. The attention-specific AH-rule -is not implemented. - -### Supported architectures - -Rule installation is positional: a component is targeted by its canonical mount -name, never by class, so a same-class component mounted elsewhere (an -attention-internal q/k norm, for example) is left untouched. Coverage is reported -per fit, and a requested rule that cannot be installed on a component that is -expected to honor it raises rather than silently falling back to ordinary -gradients. - -A fit that installs **no** rule at all also raises. Without that guard the -estimator would return ordinary Jacobian matrices labelled as a relevance lens, -which is indistinguishable from a real relevance fit in the artifact. This is the -common case for a dense-MLP model such as GPT-2, whose norms do not dispatch -through the native-autograd branch the LN-rule wraps and which has no gated MLP; -use `JacobianLens` for those models. - -A fit that installs **some** rules but skips others warns, naming the installed -rules and the skipped mounts. The transport matrices then mix rule-modified and -ordinary gradients, which is a weaker estimator than a fully covered fit and -should be reported alongside any result. OPT is the common case: its norms take -the LN-rule but its dense ReLU MLP takes neither MLP rule. - -| Component | LN-rule | Identity-rule | Half-rule | -|---|---|---|---| -| Residual-stream norms on the native-autograd path | yes | n/a | n/a | -| Gated MLPs with GELU or SiLU activations | n/a | yes | yes | -| Gated MLPs with relu-family activations | n/a | refused | yes | -| Attention-internal q/k norms | not targeted | n/a | n/a | -| Attention | ordinary autograd | ordinary autograd | ordinary autograd | - -The Identity-rule is refused for the relu family because its backward multiplier -`f(x) / x` reduces to `relu(x)` for relu-squared rather than the true derivative -`2 * relu(x)`, and plain relu has no smooth two-sided derivative for the ratio to -represent. Requesting it there raises instead of scoring the model incorrectly. - -### Artifact provenance and safety - -A relevance lens records `estimator = "relevance_lens"`, the rule version, the -enabled rules, and kind-aware per-mount coverage, and it restores that configuration -on load. `enabled_rules` names the rules that actually shaped the matrices, not the -rules that were requested: a model can honor some rules and not others, so a fit -whose MLP mounts were all skipped records only `normalization`. - -`rule_coverage` has its own `schema_version = 1`, independent of the numerical -rule version. Both `installed` and `skipped` contain records with a rule `kind` -and canonical component `path`. The record shape is illustrated below; a real -fit records every applicable mount: - -```json -{ - "schema_version": 1, - "installed": [ - {"kind": "normalization", "path": "blocks.0.ln1"}, - {"kind": "activation", "path": "blocks.0.mlp"}, - {"kind": "multiplicative_gate", "path": "blocks.0.mlp"} - ], - "skipped": [] -} -``` - -Activation and multiplicative-gate rules share an MLP path but have independent -records. Installing one does not claim that the other contributed. Partial -coverage warnings identify both the skipped kind and its path. - -The guards are strict: - -- `RelevanceLens.merge()` requires relevance-lens shards with positive relevance - estimator metadata, including for a one-shard merge. All provenance other than - `n_prompts` must match, including rule version, enabled rules, and coverage. -- `RelevanceLens.load()` requires the original artifact metadata to record the - exact string `estimator = "relevance_lens"`. Missing, malformed, or foreign - estimator records are refused before construction. Unlabelled reference and - Jacobian artifacts are not implicitly converted. -- Only the `J` artifact schema is accepted by the relevance loader. Reference - running-sum `jacobian_sum` checkpoints are refused, even if labelled as - relevance; their conversion path is Jacobian-specific. -- Explicitly marked relevance artifacts may omit optional coverage. A present - coverage record must use the kind-aware schema. Legacy path-only coverage is - ambiguous and requires refitting, not guessing activation/gate kinds from - shared paths. -- `from_pretrained()` does not resolve short model names through the Jacobian lens - registry, since that registry holds only Jacobian artifacts. Pass an explicit - artifact path or a Hub repo id containing a relevance artifact. - -Save and reload a supported relevance artifact with the inherited persistence -API: - -```python -lens.save("qwen2_relevance_lens.pt") -loaded = RelevanceLens.load("qwen2_relevance_lens.pt").validate_model(model) -``` - -### Relationship to RelP and the published artifacts - -Three things are easy to conflate: - -- **RelP** is generic relevance patching: clean-versus-corrupted component scores - from a scalar metric. It is a separate feature and is not implemented here. -- **R-lens** is this estimator: a Jacobian lens fitted with relevance-rule - backward. It produces transport matrices, not component scores. -- **Published paired artifacts** (`camilablank/workspace-lenses` on the Hugging - Face Hub) are not accepted by the current loader; converting them is separate - work. +include the fitting provenance and validation results. Every entry is downloaded at the +registry's single `revision`, so a file published after that commit also needs +`revision` moved to a commit that still contains every other entry. ## Sparse decomposition (J-space coordinates) @@ -578,41 +416,38 @@ per pair, exactly as they would from an offline `coordinate_patch` call on that ### Causal-swap benchmark -`transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark` measures whether -`coordinate_patch_hooks` causes a directional change in model output, under baseline-capability -filtering, a displacement-matched random-atom control, and exact Clopper-Pearson confidence -intervals on every reported rate. The control matches the real edit's magnitude: the -perturbation is `c_src * (a_target - a_source)`, so a candidate atom qualifies when its -distance from `a_source` matches the target's within a fixed relative tolerance. Each trial -draws the control arm under several seeds and records every draw, because a single draw leaves -the control arm's own variance unmeasured. Each trial installs the hook at exactly one layer -and the final prompt position; a trial whose source concept is not active in that layer's -support, or whose layer admits no displacement-matched control, is recorded as skipped, not -silently dropped. - -The interval is exact rather than bootstrapped: a percentile bootstrap cannot express -uncertainty about an all-failure sample, collapsing to `[0, 0]` whether the run had one trial -or a thousand. The artifact also records how many independent prompts the pooled rate rests on, -since several trials can share one prompt. - -The artifact separates the layers that were swept from the layers that actually executed, and -carries a per-status trial count, so a sweep where most layers contribute nothing is visible -rather than implied. It also fingerprints the result blocks (trials, excluded baselines, and -both interval blocks) alongside the protocol manifest, and embeds the full corpus definition -with the repo/path/revision it was taken from, so the prompts and answers that decide -capability and scoring enter the fingerprint instead of a bare corpus name. - -A generation script (`python -m -transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark_cli`, no `HF_TOKEN` -required) produces a versioned, fingerprinted JSON artifact against the published GPT-2-small -lens. The +`scripts/jacobian_lens_causal_swap_benchmark.py` measures whether `coordinate_patch_hooks` +causes a directional change in model output on GPT-2-small. It is a standalone script, not a +package API: + +```bash +uv run python scripts/jacobian_lens_causal_swap_benchmark.py +``` + +It needs no `HF_TOKEN` and pins the published lens and the prompt corpus by revision, so a run +reproduces the frozen artifact +`demos/data/jacobian_lens_causal_swap_benchmark_gpt2.json`. + +- **Baseline filtering.** Only prompts the unperturbed model already answers correctly enter + the trials. +- **Displacement-matched control.** The real edit is `c_src * (a_target - a_source)`, so a + control atom qualifies when its distance from `a_source` matches the target's within a fixed + relative tolerance. The control arm is drawn under several seeds and every draw is recorded. +- **Exact intervals.** Every rate carries a Clopper-Pearson interval from + `transformer_lens.utilities.statistics.success_rate_ci`; a percentile bootstrap collapses to + `[0, 0]` on an all-failure sample whether the run had one trial or a thousand. The artifact + also records how many independent prompts the pooled rate rests on. +- **Skips are visible.** Each trial patches one layer at the final prompt position. A trial whose + source concept is inactive in that layer's support, or whose layer admits no matched control, + is recorded as skipped, and the artifact separates swept layers from executed layers. + +The [coordinate-patch benchmark demo](../generated/demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo) -loads that frozen artifact and renders it; it never calls the model itself. Because the -artifact lives under `demos/data/`, which the published wheel does not ship, the demo requires -a repository checkout and fails with that requirement spelled out when the artifact is absent. -A successful swap in that artifact shows a directional causal effect under the stated controls -on GPT-2-small, not proof of unique causal mediation, exhaustive concept coverage, or a result -that transfers to closed-weight models. +loads the frozen artifact and renders it without calling the model. The artifact lives under +`demos/data/`, which the published wheel does not ship, so the demo requires a repository +checkout. A successful swap there shows a directional causal effect under the stated controls on +GPT-2-small, not proof of unique causal mediation, exhaustive concept coverage, or a result that +transfers to closed-weight models. ### Interpreting the numbers honestly @@ -648,7 +483,12 @@ Occupancy records the per-step captured variance `||Pi_S x||^2 / ||x||^2` and co against the same occupancy recurrence on `num_control_dictionaries` random unit-norm dictionaries. The occupancy is the step of **maximum separation** between the real and averaged-control *cumulative* captured variance — the point past which further vectors add no more than random -directions would. It is deterministic given `seed` and needs no threshold. +directions would. It is deterministic given `seed`, on any device, and needs no threshold. +That cross-device determinism comes from drawing every control dictionary on the CPU, and each +draw is the size of the full-vocabulary dictionary, so on an accelerator the draws dominate the +run time. On an Apple-silicon laptop one draw takes about 0.25 s at GPT-2 size (50257 x 768) and +about 4 s at Gemma-2-2B size (256000 x 2304), against under 0.1 s on its GPU, so the default 32 +controls cost roughly 8 s and 2 minutes. Lower `num_control_dictionaries` when that matters. ```python occ = lens.occupancy(model, "The Eiffel Tower is in the city of", layer=6, position=-1) diff --git a/docs/source/content/svd_circuits.md b/docs/source/content/svd_circuits.md deleted file mode 100644 index aabfe4e7da..0000000000 --- a/docs/source/content/svd_circuits.md +++ /dev/null @@ -1,161 +0,0 @@ -# SVD Circuits - -SVD Circuits decomposes a single attention head's QK and OV weight maps into orthogonal -singular directions. Vocab readouts and activation projections describe those directions; -patching measures prompt-specific changes in a caller-selected output metric. - -Mathematically distinct directions need not be semantically or causally distinct -subfunctions. A weight-space decomposition or a plausible token projection does not -establish a direction's role. Interventions provide additional measurements, but the -reported gate comparison is not by itself a validation of a named mechanism. - -## Definition - -For a head at layer $\ell$ with head index $h$, the two maps are - -$$ -\mathrm{QK} = W_Q W_K^\top, \qquad \mathrm{OV} = W_V W_O, -$$ - -each of shape $d_{\text{model}} \times d_{\text{model}}$ but of rank at most -$d_{\text{head}}$. Their SVDs are - -$$ -W_V W_O = U \Sigma V^\top, \qquad \operatorname{rank} \le d_{\text{head}}. -$$ - -The convention matters, because both factors are $d_{\text{model}}$-wide and a swap -raises no shape error: - -- For OV, the columns of $V$ are the residual-stream **output/write** directions, the - ones projected through $W_U$ for a vocab readout. The columns of $U$ span the - value-computation **input** space. -- For QK, the columns of $V$ are the **source/key-read** directions and the columns of - $U$ the **destination/query-read** directions. QK produces no write direction, so it - has no vocab readout. - -`FactoredMatrix` computes both SVDs without materialising the $d_{\text{model}}^2$ -product. - -## The degeneracy guard - -Singular directions are unique only when the singular values are distinct. Equal or -near-equal consecutive singular values leave the corresponding subspace defined only up -to an arbitrary rotation, so any statement of the form "direction 3 is the surname -subfunction" is a statement about a basis choice rather than about the model. - -`decompose_head` returns a rank report marking degenerate and numerically null -directions. Null directions are arbitrary vectors from the map's null space and are -also unsuitable for individual attribution. The consumers have different guards: - -- `logit_signature` requires an isolated, non-null direction and refuses individual - directions inside a degenerate block. -- `patch_along_directions` refuses selections that split a degenerate block. Complete - blocks can be retained or removed as subspaces; empty or full-span retained sets - require an explicit `threshold` because their random controls coincide with them. -- `vocab_readout` and `project_activations` return raw numerical projections, including - columns inside degenerate blocks. They neither refuse these columns nor replace - them with block summaries. Consult the rank report and exclude both `is_degenerate` - and `is_null` before interpreting individual directions. - -## The intervention comparison - -`patch_along_directions` reconstructs the head's output onto a chosen singular subspace -and reports the resulting change in a caller-supplied metric: - -- `delta_metric`: patched metric minus original metric. -- `baseline_delta_metric`: the mean magnitude over random same-width subspaces drawn - inside the head's own OV span. Drawing the control in-span rather than from the full - residual stream makes it the effect of an arbitrary subspace of *this head's* output, - which is the comparison the gate needs. -- `gated`: whether the metric change passed the mode-specific threshold comparison. - -The comparison depends on the mode the caller expressed, because `keep=S` and -`ablate=complement(S)` resolve to the same retained set: - -| Mode | Meaning | `gated` is | -|---|---|---| -| `keep=S` | retain only $S$ | `abs(delta_metric) < threshold` | -| `ablate=S` | zero $S$, retain the rest | `abs(delta_metric) > threshold` | - -With `keep`, a passing comparison means retaining the selected subspace changes the -metric less than the threshold. With `ablate`, it means removing the selected subspace -changes the metric more than the threshold. These describe effects on the chosen metric, -not reconstruction of the head's entire behaviour or identification of its function. - -The threshold defaults to `baseline_delta_metric`, a sampled mean magnitude rather than -a confidence bound or p-value. It does not establish statistical separation from -arbitrary directions. The sampled mean can vary with the seed and draw count, so verdicts -near it can change. Pass an explicit `rng` for repeatability and increase `n_baseline` to -sample the mean more thoroughly; neither makes a verdict scientifically conclusive. - -## Architecture requirements - -`decompose_head` reads the head's weights from `model.blocks[layer].attn`, so the -requested layer must hold an attention block. SSM, mixer, linear-attention and AltUp -blocks, and encoder-decoder bridges with no `blocks` stack, raise `ValueError`; on a -hybrid stack the message lists the layers that carry attention. - -`project_activations` and `patch_along_directions` enable `use_attn_result` for the call -and read the head's per-head output from `hook_result`. Bridges whose attention cannot -compute per-head outputs raise `NotImplementedError` up front. An attention bridge that -accepts the flag but never fires `hook_result` raises `ValueError`; -`patch_along_directions` checks right after the patched forward, before any control, so -it never reports a gate verdict for a patch that changed nothing. - -## Compatibility mode - -The vocab readout projects through the final LayerNorm folded into $W_U$, which requires -`enable_compatibility_mode()` on an adapter that supports folding. A decomposition -records the folded-LayerNorm state it was taken under, and the readout and patch -consumers refuse a decomposition whose state no longer matches the model. See -[Compatibility Mode](compatibility_mode.md). - -## Worked example - -```python -import torch - -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.tools.analysis.svd_circuits import ( - decompose_head, - patch_along_directions, - vocab_readout, -) - -model = TransformerBridge.boot_transformers("gpt2", dtype=torch.float32, device="cpu") -model.enable_compatibility_mode() -model.eval() - -prompt = "When Mary and John went to the store, John gave a drink to" -ov = decompose_head(model, layer=9, head=9, which=("OV",)).OV - -readout = vocab_readout(model, ov, k=10) - -mary, john = model.to_single_token(" Mary"), model.to_single_token(" John") -metric = lambda logits: float(logits[0, -1, mary] - logits[0, -1, john]) - -result = patch_along_directions( - model, ov, prompt, metric, keep=[0], rng=torch.Generator().manual_seed(0) -) -print(result.delta_metric, result.baseline_delta_metric, result.gated) -``` - -## What this does not establish - -- A passing gate reports a metric comparison for one intervention on one prompt. Vocab - readouts and activation coefficients are descriptive projections, not semantic labels. -- Results are single-head and prompt-specific. Nothing here assembles a multi-head circuit. -- The worked example and demo do not establish named subfunctions, statistical separation - from arbitrary directions, or replication of the paper's causal subfunction taxonomy. -- Output-metric changes include downstream responses. A small final-logit effect does not - imply a small direct write contribution, nor identify which downstream components alter - the effect. -- Verdicts near the mean control magnitude can change with the seed or draw count. Fixing - a seed makes the comparison repeatable, not scientifically conclusive. - -## Links - -- [SVD Circuits demo](../generated/demos/SVD_Circuits_Demo.ipynb) -- Areeb Ahmad, Abhinav Joshi, Ashutosh Modi, "Beyond Components: Singular Vector-Based - Interpretability of Transformer Circuits", [arXiv 2511.20273](https://arxiv.org/abs/2511.20273) diff --git a/docs/source/index.md b/docs/source/index.md index 30b9c97b40..e8bacbf27c 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -64,12 +64,11 @@ content/projection_kernel content/jacobian_lens_fitting generated/demos/Jacobian_Lens_Decomposition_Demo generated/demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo -content/backward_lens content/debugging_numerical_divergence content/sparse_probing content/interchange_interventions -content/svd_circuits generated/demos/SVD_Circuits_Demo +generated/demos/Backward_Lens_Demo generated/demos/Main_Demo generated/demos/Exploratory_Analysis_Demo content/special_cases diff --git a/scripts/jacobian_lens_causal_swap_benchmark.py b/scripts/jacobian_lens_causal_swap_benchmark.py new file mode 100644 index 0000000000..ad26f7100d --- /dev/null +++ b/scripts/jacobian_lens_causal_swap_benchmark.py @@ -0,0 +1,671 @@ +"""Causal-swap benchmark for ``JacobianLens.coordinate_patch_hooks`` on GPT-2-small. + +Reproduces the frozen artifact ``demos/data/jacobian_lens_causal_swap_benchmark_gpt2.json``, +which ``demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb`` loads without running a +model. Each trial installs an anchored J-space coordinate edit toward a target concept at one +layer and checks that the target's answer becomes the top-1 prediction, under three controls: +only prompts the model already answers are intervened on, each real edit is paired with +displacement-matched random-atom controls drawn under several seeds, and every rate carries an +exact Clopper-Pearson interval. The lens and corpus revisions below are pinned because this +script is the reproduction recipe. + +Run: uv run python scripts/jacobian_lens_causal_swap_benchmark.py + (no HF_TOKEN needed, GPT-2 is not gated; ``--output`` redirects the artifact) +""" + +from __future__ import annotations + +import argparse +import hashlib +import itertools +import json +from collections import Counter +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import ( + Any, + Container, + Dict, + Iterator, + List, + Literal, + Mapping, + MutableMapping, + Optional, + Sequence, + Tuple, +) + +import torch + +from transformer_lens.model_bridge import TransformerBridge +from transformer_lens.tools.analysis._model_state import require_eval_mode +from transformer_lens.tools.analysis.jacobian_lens import DEFAULT_K, JacobianLens +from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( + JSpaceDecomposition, +) +from transformer_lens.utilities.statistics import SuccessRateInterval, success_rate_ci + +SCHEMA_VERSION = 2 + +_GPT2_LENS_REPO = "neuronpedia/jacobian-lens" +_GPT2_LENS_FILE = "gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt" +_GPT2_LENS_REVISION = "a4114d7752d11eb546e6cf372213d7e75526d3a1" +# Same repo/path/revision triple Jacobian_Lens_Demo.ipynb pins for its country config, so the +# corpus has one provenance scheme rather than two. +_CORPUS_REPO = "anthropics/jacobian-lens" +_CORPUS_PATH = "data/experiments/flexible-generalization.json" +_CORPUS_REVISION = "581d398613e5602a5af361e1c34d3a92ea82ba8e" +_DEFAULT_CONTROL_SEEDS = (0, 1, 2, 3, 4) +_DEFAULT_ARTIFACT_PATH = ( + Path(__file__).resolve().parents[1] + / "demos" + / "data" + / "jacobian_lens_causal_swap_benchmark_gpt2.json" +) + + +@dataclass(frozen=True) +class FunctionSpec: + """A prompt template with one ``{arg}`` placeholder and each concept's answer word.""" + + name: str + template: str + answers: Dict[str, str] + + +@dataclass(frozen=True) +class BenchmarkCorpus: + """A named set of concepts and the prompt functions evaluated over them.""" + + name: str + concepts: Sequence[str] + functions: Sequence[FunctionSpec] + + +@dataclass(frozen=True) +class PromptTrialSpec: + """One (function, ordered source/target concept pair) prompt instance.""" + + function: str + source: str + target: str + prompt: str + source_answer: str + target_answer: str + + +_COUNTRY_CORPUS = BenchmarkCorpus( + name="countries", + concepts=("France", "Canada", "China", "Egypt"), + functions=( + FunctionSpec( + name="capital", + template="The capital of {arg} is the city of", + answers={"France": "Paris", "Canada": "Ottawa", "China": "Beijing", "Egypt": "Cairo"}, + ), + FunctionSpec( + name="language", + template="Most people in {arg} speak", + answers={ + "France": "French", + "Canada": "English", + "China": "Chinese", + "Egypt": "Arabic", + }, + ), + FunctionSpec( + name="continent", + template="{arg} is a country on the continent of", + answers={"France": "Europe", "Canada": "North", "China": "Asia", "Egypt": "Africa"}, + ), + FunctionSpec( + name="currency", + template="The single-word name for the currency now used in {arg} is the", + answers={"France": "Euro", "Canada": "Dollar", "China": "Yuan", "Egypt": "Pound"}, + ), + ), +) + + +def iter_prompt_trials(corpus: BenchmarkCorpus) -> Iterator[PromptTrialSpec]: + """Yields one spec per (function, ordered source/target concept pair).""" + for function in corpus.functions: + for source, target in itertools.permutations(corpus.concepts, 2): + yield PromptTrialSpec( + function=function.name, + source=source, + target=target, + prompt=function.template.format(arg=source), + source_answer=function.answers[source], + target_answer=function.answers[target], + ) + + +def corpus_definition(corpus: BenchmarkCorpus) -> Dict[str, Any]: + """Serializes a corpus for the manifest, so prompts and answers enter the fingerprint.""" + return { + "name": corpus.name, + "concepts": list(corpus.concepts), + "functions": [ + { + "name": function.name, + "template": function.template, + "answers": dict(function.answers), + } + for function in corpus.functions + ], + } + + +@dataclass(frozen=True) +class AnswerMetrics: + """Rank/margin/tie metrics for one target token against one next-token logit vector.""" + + top1_token_id: int + target_rank: int + target_is_top1: bool + target_tied_for_top: bool + target_logit_margin: float + + +def compute_answer_metrics(logits: torch.Tensor, target_token_id: int) -> AnswerMetrics: + """Computes rank/margin/tie metrics for ``target_token_id`` against 1-D ``logits``.""" + if logits.ndim != 1 or logits.numel() < 2: + raise ValueError("expected one-dimensional next-token logits") + if not 0 <= target_token_id < logits.shape[0]: + raise ValueError("target token id is outside the vocabulary") + if not torch.isfinite(logits).all(): + raise ValueError("logits must be finite") + + target_logit = logits[target_token_id] + top_logit = logits.max() + top1_token_id = int(logits.argmax().item()) + top_logit_tie_count = int((logits == top_logit).sum().item()) + competitors = torch.cat((logits[:target_token_id], logits[target_token_id + 1 :])) + return AnswerMetrics( + top1_token_id=top1_token_id, + target_rank=int((logits > target_logit).sum().item()) + 1, + target_is_top1=top1_token_id == target_token_id, + target_tied_for_top=bool(target_logit == top_logit) and top_logit_tie_count > 1, + target_logit_margin=float((target_logit - competitors.max()).item()), + ) + + +@dataclass(frozen=True) +class BaselineRecord: + """A source prompt's own-answer metrics under the unperturbed forward pass.""" + + function: str + source: str + prompt: str + metrics: AnswerMetrics + + +def filter_baseline_capable( + baselines: Sequence[BaselineRecord], +) -> Tuple[List[BaselineRecord], List[BaselineRecord]]: + """Splits baseline records into (capable, excluded), preserving order.""" + # A tied-for-top answer is excluded: argmax returns the lowest index on an exact tie, so + # admitting ties would let token-id order decide whether a prompt enters the trial set. + capable = [ + record + for record in baselines + if record.metrics.target_is_top1 and not record.metrics.target_tied_for_top + ] + excluded = [ + record + for record in baselines + if not record.metrics.target_is_top1 or record.metrics.target_tied_for_top + ] + return capable, excluded + + +def _control_generator(seed: int) -> torch.Generator: + """Builds a CPU generator so the same seed picks the same control token on every device.""" + # torch.randint allocates on CPU, so a device-local generator both changes the draw per + # device and raises on accelerators. + return torch.Generator(device="cpu").manual_seed(seed) + + +def select_displacement_matched_control_token( + dictionary: torch.Tensor, + source_token_id: int, + target_token_id: int, + excluded_ids: Container[int], + active_support: Container[int], + *, + tolerance: float = 0.1, + seed: int = 0, +) -> Optional[int]: + """Picks a seeded control token whose atom sits as far from the source as the target's. + + Returns ``None`` when no candidate qualifies; the tolerance is never widened. + """ + # Matching ``||a_t - a_source||`` rather than ``||a_t||``: the real edit is + # ``c_src * (a_target - a_source)``, so equal-norm atoms at a different distance from the + # source would run the control at a different magnitude. + if dictionary.ndim != 2: + raise ValueError( + f"dictionary must be 2-D [num_atoms, d_model], got shape {tuple(dictionary.shape)}" + ) + atoms = dictionary.float() + source_atom = atoms[source_token_id] + target_displacement = float((atoms[target_token_id] - source_atom).norm()) + displacements = (atoms - source_atom).norm(dim=1) + within_tolerance = (displacements - target_displacement).abs() <= ( + tolerance * target_displacement + ) + # Atoms already in the active support carry the source coordinate, so they are not clean + # controls. + candidates = [ + token_id + for token_id in range(dictionary.shape[0]) + if token_id != source_token_id + and token_id != target_token_id + and token_id not in excluded_ids + and token_id not in active_support + and bool(within_tolerance[token_id]) + ] + if not candidates: + return None + generator = _control_generator(seed) + pick = int(torch.randint(len(candidates), (1,), generator=generator).item()) + return candidates[pick] + + +def _resolve_answer_token_id(model: Any, word: str) -> int: + """Resolves a word to its token id as a continuation (leading space).""" + return int(model.to_single_token(f" {word}")) + + +@dataclass(frozen=True) +class ControlDraw: + """One seeded control-arm draw: the token it selected and how that condition scored.""" + + seed: int + token_id: int + metrics: AnswerMetrics + + +@dataclass(frozen=True) +class TrialResult: + """One ``(function, source, target, layer)`` trial. + + ``control_token_id`` and ``control_target_metrics`` mirror the first entry of + ``control_draws``; the full list carries the control arm's spread across seeds. + """ + + function: str + source: str + target: str + layer: int + status: Literal["ok", "skipped_source_inactive", "skipped_no_control_token"] + baseline: AnswerMetrics + real_target_metrics: Optional[AnswerMetrics] + control_token_id: Optional[int] + control_target_metrics: Optional[AnswerMetrics] + control_draws: List[ControlDraw] + error: Optional[str] + + +def run_causal_swap_trial( + lens: JacobianLens, + model: Any, + trial_spec: PromptTrialSpec, + layer: int, + *, + decomposition_cache: Optional[MutableMapping[Tuple[int, int, int], JSpaceDecomposition]] = None, + control_tolerance: float = 0.1, + control_seeds: Sequence[int] = (0,), + alpha: float = 1.0, + k: int = DEFAULT_K, +) -> TrialResult: + """Runs one trial: baseline, then the real and per-seed control coordinate patches.""" + if decomposition_cache is None: + decomposition_cache = {} + if not control_seeds: + raise ValueError("control_seeds must contain at least one seed") + tokens = model.to_tokens(trial_spec.prompt) + source_id = _resolve_answer_token_id(model, trial_spec.source) + target_id = _resolve_answer_token_id(model, trial_spec.target) + source_answer_id = _resolve_answer_token_id(model, trial_spec.source_answer) + target_answer_id = _resolve_answer_token_id(model, trial_spec.target_answer) + if source_id == target_id: + raise ValueError( + f"source and target resolve to the same token id {source_id}; a coordinate " + "patch would be a silent no-op" + ) + require_eval_mode(model, operation="run_causal_swap_trial()") + + with torch.no_grad(): + baseline_logits = model(tokens)[0, -1].float() + baseline_metrics = compute_answer_metrics(baseline_logits, source_answer_id) + + dictionary = lens.lens_vector_dictionary(model, layer) + decomposition = lens.decompose(model, trial_spec.prompt, layer=layer, position=-1, k=k) + # Seed the shared cache under the hook's own key so its first firing is a cache hit and the + # vocabulary-scale solve is not repeated. The hook normalizes -1 against the sequence + # length, which is the tokenized prompt length for a single-example pass. + decomposition_cache[(layer, 0, tokens.shape[1] - 1)] = decomposition + # Checked up front rather than inferred from a caught ValueError: + # ``coordinate_patch_hooks`` raises plain ValueError for unrelated protocol faults too, and + # filing those as skips would drop them from both denominators. + if source_id not in decomposition.support.tolist(): + return TrialResult( + function=trial_spec.function, + source=trial_spec.source, + target=trial_spec.target, + layer=layer, + status="skipped_source_inactive", + baseline=baseline_metrics, + real_target_metrics=None, + control_token_id=None, + control_target_metrics=None, + control_draws=[], + error=( + f"source token id {source_id} is not in layer {layer}'s active support " + f"for this prompt" + ), + ) + control_token_ids: List[int] = [] + for seed in control_seeds: + token_id = select_displacement_matched_control_token( + dictionary, + source_id, + target_id, + excluded_ids={source_answer_id, target_answer_id}, + active_support=decomposition.support.tolist(), + tolerance=control_tolerance, + seed=seed, + ) + if token_id is None: + return TrialResult( + function=trial_spec.function, + source=trial_spec.source, + target=trial_spec.target, + layer=layer, + status="skipped_no_control_token", + baseline=baseline_metrics, + real_target_metrics=None, + control_token_id=None, + control_target_metrics=None, + control_draws=[], + error=( + "no displacement-matched control token within relative tolerance " + f"{control_tolerance} of ||a_target - a_source|| for seed {seed}" + ), + ) + control_token_ids.append(token_id) + + def _condition_metrics(condition_target_id: int) -> AnswerMetrics: + hooks = lens.coordinate_patch_hooks( + model, + source_id, + condition_target_id, + layers=[layer], + positions=[-1], + decomposition_cache=decomposition_cache, + k=k, + alpha=alpha, + ) + with model.hooks(fwd_hooks=hooks), torch.no_grad(): + condition_logits = model(tokens)[0, -1].float() + return compute_answer_metrics(condition_logits, target_answer_id) + + real_metrics = _condition_metrics(target_id) + control_draws = [ + ControlDraw( + seed=seed, + token_id=token_id, + metrics=_condition_metrics(token_id), + ) + for seed, token_id in zip(control_seeds, control_token_ids) + ] + + return TrialResult( + function=trial_spec.function, + source=trial_spec.source, + target=trial_spec.target, + layer=layer, + status="ok", + baseline=baseline_metrics, + real_target_metrics=real_metrics, + control_token_id=control_draws[0].token_id, + control_target_metrics=control_draws[0].metrics, + control_draws=control_draws, + error=None, + ) + + +def run_causal_swap_benchmark( + lens: JacobianLens, + model: Any, + corpus: BenchmarkCorpus, + layers: Sequence[int], + **trial_kwargs: Any, +) -> Tuple[List[TrialResult], List[BaselineRecord]]: + """Filters prompts by baseline capability, then runs every surviving trial per layer.""" + all_specs = list(iter_prompt_trials(corpus)) + require_eval_mode(model, operation="run_causal_swap_benchmark()") + specs_by_prompt: Dict[Tuple[str, str], List[PromptTrialSpec]] = {} + baselines: List[BaselineRecord] = [] + # A prompt's baseline does not depend on the layer, so it is computed once per prompt. + for spec in all_specs: + prompt_key = (spec.function, spec.source) + if prompt_key not in specs_by_prompt: + specs_by_prompt[prompt_key] = [] + tokens = model.to_tokens(spec.prompt) + with torch.no_grad(): + baseline_logits = model(tokens)[0, -1].float() + source_answer_id = _resolve_answer_token_id(model, spec.source_answer) + baselines.append( + BaselineRecord( + function=spec.function, + source=spec.source, + prompt=spec.prompt, + metrics=compute_answer_metrics(baseline_logits, source_answer_id), + ) + ) + specs_by_prompt[prompt_key].append(spec) + + capable, excluded = filter_baseline_capable(baselines) + capable_prompt_keys = {(record.function, record.source) for record in capable} + + trials: List[TrialResult] = [] + for layer in layers: + for prompt_key, specs in specs_by_prompt.items(): + if prompt_key not in capable_prompt_keys: + continue + for spec in specs: + # Fresh cache per trial: its key is (layer, batch_idx, position), which + # collides across different prompts run as single-example passes. + trials.append(run_causal_swap_trial(lens, model, spec, layer, **trial_kwargs)) + return trials, excluded + + +_REQUIRED_MANIFEST_FIELDS = ( + "model_id", + "model_revision", + "lens_repo", + "lens_file", + "lens_revision", + "corpus_name", + "corpus", + "corpus_repo", + "corpus_path", + "corpus_revision", + "layers_swept", + "alpha", + "k", + "control_tolerance", + "control_seeds", + "success_definition", + "baseline_definition", + "rank_definition", +) + + +def build_protocol_manifest(**fields: Any) -> Dict[str, Any]: + """Assembles a protocol manifest, requiring the benchmark's fixed field set.""" + missing = [name for name in _REQUIRED_MANIFEST_FIELDS if name not in fields] + if missing: + raise ValueError(f"protocol manifest is missing required field(s): {', '.join(missing)}") + return dict(fields) + + +def fingerprint_manifest(manifest: Dict[str, Any]) -> str: + """SHA-256 over the canonical JSON of a protocol manifest.""" + return hashlib.sha256( + json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def results_fingerprint(artifact: Mapping[str, Any]) -> str: + """SHA-256 over the result blocks, which ``protocol_fingerprint`` does not cover.""" + return hashlib.sha256( + json.dumps( + { + "trials": artifact["trials"], + "excluded_baselines": artifact["excluded_baselines"], + "real_success_ci": artifact["real_success_ci"], + "control_success_ci": artifact["control_success_ci"], + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + + +def serialize_artifact( + manifest: Dict[str, Any], + trials: Sequence[TrialResult], + excluded_baselines: Sequence[BaselineRecord], + real_ci: SuccessRateInterval, + control_ci: SuccessRateInterval, +) -> Dict[str, Any]: + """Assembles the JSON-serializable artifact from a benchmark run's results.""" + # ``n_independent_prompts`` travels with the pooled rate because trials sharing a prompt + # are not independent draws. + artifact: Dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "protocol_manifest": manifest, + "protocol_fingerprint": fingerprint_manifest(manifest), + "trials": [asdict(trial) for trial in trials], + "excluded_baselines": [asdict(record) for record in excluded_baselines], + "real_success_ci": asdict(real_ci), + "control_success_ci": asdict(control_ci), + "n_independent_prompts": len( + {(trial.function, trial.source) for trial in trials if trial.status == "ok"} + ), + "layers_executed": sorted({trial.layer for trial in trials if trial.status == "ok"}), + "trial_status_counts": dict(Counter(trial.status for trial in trials)), + } + artifact["results_fingerprint"] = results_fingerprint(artifact) + return artifact + + +def _parse_seed_list(value: str) -> Tuple[int, ...]: + """Parses a comma-separated seed list for ``--control-seeds``.""" + seeds = tuple(int(part) for part in value.split(",") if part.strip()) + if not seeds: + raise ValueError("at least one control seed is required") + return seeds + + +def _build_parser() -> argparse.ArgumentParser: + """Builds the argument parser.""" + parser = argparse.ArgumentParser( + description="Generates the frozen GPT-2 causal-swap benchmark artifact." + ) + parser.add_argument("--output", type=Path, default=_DEFAULT_ARTIFACT_PATH) + parser.add_argument("--control-tolerance", type=float, default=0.1) + parser.add_argument( + "--control-seeds", + type=_parse_seed_list, + default=_DEFAULT_CONTROL_SEEDS, + help="comma-separated seeds to draw the control arm under (default: %(default)s)", + ) + parser.add_argument("--alpha", type=float, default=1.0) + parser.add_argument("--k", type=int, default=DEFAULT_K) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> None: + """Sweeps the country corpus over every fitted lens layer and writes the artifact.""" + args = _build_parser().parse_args(argv) + + device = "cuda" if torch.cuda.is_available() else "cpu" + model = TransformerBridge.boot_transformers("gpt2", dtype=torch.float32, device=device) + lens = JacobianLens.from_pretrained( + _GPT2_LENS_REPO, + filename=_GPT2_LENS_FILE, + revision=_GPT2_LENS_REVISION, + model=model, + ) + layers = list(lens.source_layers) + + trials, excluded = run_causal_swap_benchmark( + lens, + model, + _COUNTRY_CORPUS, + layers, + control_tolerance=args.control_tolerance, + control_seeds=args.control_seeds, + alpha=args.alpha, + k=args.k, + ) + ok_trials = [trial for trial in trials if trial.status == "ok"] + if not ok_trials: + raise RuntimeError( + "no trial survived baseline filtering, the active-support check, and " + "control-token selection" + ) + + real_successes: List[bool] = [] + control_successes: List[bool] = [] + for trial in ok_trials: + assert trial.real_target_metrics is not None + assert trial.control_target_metrics is not None + real_successes.append(trial.real_target_metrics.target_is_top1) + control_successes.append(trial.control_target_metrics.target_is_top1) + + manifest = build_protocol_manifest( + model_id="gpt2", + model_revision="n/a", + lens_repo=_GPT2_LENS_REPO, + lens_file=_GPT2_LENS_FILE, + lens_revision=_GPT2_LENS_REVISION, + corpus_name=_COUNTRY_CORPUS.name, + corpus=corpus_definition(_COUNTRY_CORPUS), + corpus_repo=_CORPUS_REPO, + corpus_path=_CORPUS_PATH, + corpus_revision=_CORPUS_REVISION, + layers_swept=layers, + alpha=args.alpha, + k=args.k, + control_tolerance=args.control_tolerance, + control_seeds=list(args.control_seeds), + success_definition="target token id equals deterministic argmax token id", + baseline_definition="source answer token id equals deterministic argmax token id", + rank_definition="1 + count(logits strictly greater than target logit)", + ) + real_ci = success_rate_ci(real_successes) + control_ci = success_rate_ci(control_successes) + artifact = serialize_artifact(manifest, trials, excluded, real_ci, control_ci) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") + status_counts = artifact["trial_status_counts"] + n_prompts = artifact["n_independent_prompts"] + print( + f"wrote {len(trials)} trials ({len(ok_trials)} ok over {n_prompts} independent " + f"prompt(s) at layers {artifact['layers_executed']}, " + f"{status_counts.get('skipped_source_inactive', 0)} source-inactive, " + f"{status_counts.get('skipped_no_control_token', 0)} no-control-token, " + f"{len(excluded)} excluded prompts) to {args.output}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/model_bridge/test_jais2_adapter.py b/tests/integration/model_bridge/test_jais2_adapter.py index 6f1cecbc32..9ca52d4193 100644 --- a/tests/integration/model_bridge/test_jais2_adapter.py +++ b/tests/integration/model_bridge/test_jais2_adapter.py @@ -10,10 +10,6 @@ import torch from tests.integration.model_bridge.helpers import make_tiny_pair -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRules, - use_relevance_rules, -) def _tiny_jais2_pair(): @@ -96,17 +92,3 @@ def test_identity_hooks_preserve_logits(self, norm_name, hook_name, direction) - with pytest.warns(UserWarning, match=message): hooked = bridge(ids) torch.testing.assert_close(hooked.detach(), clean, atol=1e-6, rtol=1e-5) - - def test_ln_rule_vjp_uses_plain_weight(self) -> None: - bridge = _perturbed_jais2_bridge() - ln = bridge.blocks[0].ln1 - component = ln.original_component - x = torch.randn(1, 5, bridge.cfg.d_model, requires_grad=True) - probe = torch.randn_like(x) - with use_relevance_rules(bridge, RelevanceRules(normalization=True)): - ln(x).backward(probe) - x_oracle = x.detach().clone().requires_grad_(True) - centered = x_oracle - x_oracle.mean(-1, keepdim=True) - denom = (centered.pow(2).mean(-1, keepdim=True) + component.eps).sqrt().detach() - (centered / denom * component.weight.detach()).backward(probe) - torch.testing.assert_close(x.grad, x_oracle.grad) diff --git a/tests/integration/test_backward_lens.py b/tests/integration/test_backward_lens.py deleted file mode 100644 index f3f2c6af18..0000000000 --- a/tests/integration/test_backward_lens.py +++ /dev/null @@ -1,779 +0,0 @@ -"""Integration tests for Backward Lens capture on a raw GPT-2 Bridge.""" - -import warnings -from collections.abc import Sequence -from typing import Any, cast - -import pytest -import torch -import torch.nn.functional as F -from beartype.roar import BeartypeCallHintParamViolation - -PROMPT = "The capital of France is" -TARGET = " Paris" -LAYERS = (0, 11) -PYTHIA_LAYERS = (0, 3, 5) -OVERLONG_PROMPT = " token" * 1024 - -DEVICE_DTYPE_CASES = [pytest.param("cpu", torch.bfloat16, id="cpu-bfloat16")] -if torch.cuda.is_available(): - DEVICE_DTYPE_CASES.append(pytest.param("cuda", torch.float16, id="cuda-float16")) -if torch.backends.mps.is_available(): - DEVICE_DTYPE_CASES.append(pytest.param("mps", torch.float32, id="mps-float32")) - - -@pytest.fixture(scope="module") -def gpt2_bridge(): - from transformer_lens.model_bridge import TransformerBridge - - return TransformerBridge.boot_transformers("gpt2", device="cpu", dtype=torch.float32) - - -@pytest.fixture(scope="module") -def pythia_bridge(): - from transformer_lens.model_bridge import TransformerBridge - - return TransformerBridge.boot_transformers( - "EleutherAI/pythia-70m", device="cpu", dtype=torch.float32 - ) - - -@pytest.fixture(scope="module") -def gradient_capture(gpt2_bridge): - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - return _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, LAYERS) - - -@pytest.fixture(scope="module") -def backward_result(gpt2_bridge): - from transformer_lens.tools.analysis import BackwardLens - - return BackwardLens(gpt2_bridge).analyze( - PROMPT, - TARGET, - LAYERS, - normalized=True, - top_k=3, - return_full_logits=True, - ) - - -def _projection_hook_snapshots(model, layer: int) -> list[tuple[int, ...]]: - mlp = model.blocks[layer].mlp - projections = (getattr(mlp, "in"), mlp.out) - return [ - tuple(hook_point._forward_hooks) - for projection in projections - for hook_point in (projection.hook_in, projection.hook_out) - ] - - -@pytest.mark.parametrize( - ("model_fixture", "layers", "d_model", "d_mlp", "expected_weight_layout", "asserts_bos"), - [ - pytest.param("gpt2_bridge", LAYERS, 768, 3072, "in_out", True, id="gpt2"), - pytest.param("pythia_bridge", PYTHIA_LAYERS, 512, 2048, "out_in", False, id="pythia-70m"), - ], -) -def test_real_dense_mlp_factors_reconstruct_both_mlp_weight_gradients( - request, - model_fixture: str, - layers: tuple[int, ...], - d_model: int, - d_mlp: int, - expected_weight_layout: str, - asserts_bos: bool, -) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - model = request.getfixturevalue(model_fixture) - gradient_capture = _capture_dense_mlp_gradient_factors(model, PROMPT, TARGET, layers) - expected_target_tokens = model.to_tokens(TARGET, prepend_bos=False) - assert gradient_capture.target_token_id == int(expected_target_tokens.item()) - assert gradient_capture.loss > 0 - assert gradient_capture.prompt_token_ids.device.type == "cpu" - expected_tokens = model.to_tokens(PROMPT) - assert torch.equal(gradient_capture.prompt_token_ids, expected_tokens) - prompt_length = int(expected_tokens.shape[1]) - assert gradient_capture.prompt_token_ids.shape == (1, prompt_length) - if asserts_bos: - assert model.tokenizer is not None - assert gradient_capture.prompt_token_ids[0, 0].item() == model.tokenizer.bos_token_id - with torch.no_grad(): - logits = model(expected_tokens) - expected_loss = F.cross_entropy( - logits[:, -1, :], torch.tensor([gradient_capture.target_token_id]) - ) - assert gradient_capture.loss == pytest.approx(float(expected_loss), abs=1e-6, rel=1e-6) - assert [result.layer for result in gradient_capture.layers] == list(layers) - - input_shape = (d_model, d_mlp) if expected_weight_layout == "in_out" else (d_mlp, d_model) - output_shape = (d_mlp, d_model) if expected_weight_layout == "in_out" else (d_model, d_mlp) - for result in gradient_capture.layers: - first = result.input_projection - second = result.output_projection - assert first.forward_inputs.shape == (prompt_length, d_model) - assert first.output_gradients.shape == (prompt_length, d_mlp) - assert first.weight_gradient.shape == input_shape - assert second.forward_inputs.shape == (prompt_length, d_mlp) - assert second.output_gradients.shape == (prompt_length, d_model) - assert second.weight_gradient.shape == output_shape - for factors in (first, second): - assert factors.weight_layout == expected_weight_layout - assert factors.reconstructed_gradient.shape == factors.weight_gradient.shape - for tensor in ( - factors.forward_inputs, - factors.output_gradients, - factors.weight_gradient, - factors.reconstructed_gradient, - ): - assert tensor.dtype == torch.float32 - assert tensor.device.type == "cpu" - assert tensor.grad_fn is None - assert torch.isfinite(tensor).all() - torch.testing.assert_close( - factors.reconstructed_gradient, - factors.weight_gradient, - atol=2e-6, - rtol=2e-5, - ) - assert factors.absolute_reconstruction_error <= 2e-6 - assert factors.relative_reconstruction_error <= 2e-5 - - -def test_gradient_descent_weight_update_increases_target_logit( - gradient_capture, gpt2_bridge -) -> None: - layer_result = gradient_capture.layers[-1] - weight = gpt2_bridge.blocks[layer_result.layer].mlp.out.original_component.weight - original_weight = weight.detach().clone() - weight_gradient = layer_result.output_projection.weight_gradient.to( - device=weight.device, dtype=weight.dtype - ) - updated_weight = original_weight - 1e-4 * weight_gradient - prompt_token_ids = gradient_capture.prompt_token_ids.to(weight.device) - - with torch.no_grad(): - baseline_output = gpt2_bridge.original_model(prompt_token_ids) - weight.copy_(updated_weight) - try: - updated_output = gpt2_bridge.original_model(prompt_token_ids) - finally: - weight.copy_(original_weight) - - baseline_target_logit = baseline_output.logits[0, -1, gradient_capture.target_token_id] - updated_target_logit = updated_output.logits[0, -1, gradient_capture.target_token_id] - assert float(updated_target_logit - baseline_target_logit) > 1e-4 - torch.testing.assert_close(weight, original_weight, rtol=0, atol=0) - - -@pytest.mark.parametrize("projection", ["input_projection", "output_projection"]) -def test_final_layer_has_the_expected_rank_one_position_structure( - gradient_capture, projection: str -) -> None: - last_layer = gradient_capture.layers[-1] - factors = getattr(last_layer, projection) - row_norms = factors.output_gradients.norm(dim=-1) - relative_earlier_norm = row_norms[:-1].max() / row_norms[-1].clamp_min( - torch.finfo(row_norms.dtype).eps - ) - - assert relative_earlier_norm <= 1e-6 - assert int(torch.linalg.matrix_rank(factors.output_gradients)) == 1 - - -def test_public_result_metadata_and_error_summaries(backward_result) -> None: - from transformer_lens.tools.analysis import BackwardLensResult - - assert isinstance(backward_result, BackwardLensResult) - assert backward_result.prompt == PROMPT - assert backward_result.target_token == TARGET - assert backward_result.target_token_id == 6342 - assert backward_result.prompt_token_ids.shape == (6,) - assert [layer.layer for layer in backward_result.layers] == list(LAYERS) - assert backward_result.includes_normalized_logits is True - assert backward_result.includes_full_logits is True - assert backward_result.max_absolute_reconstruction_error <= 2e-6 - assert backward_result.max_relative_reconstruction_error <= 2e-5 - assert backward_result.layer(11) is backward_result.layers[-1] - assert not hasattr(backward_result, "model") - with pytest.raises(KeyError, match="was not analyzed"): - backward_result.layer(5) - - -def test_public_projections_match_fresh_model_readout(backward_result, gpt2_bridge) -> None: - unembed_weight = gpt2_bridge.W_U - for layer in backward_result.layers: - matrices = ( - (layer.input_projection, layer.input_projection.factors.forward_inputs), - (layer.output_projection, layer.output_projection.factors.output_gradients), - ) - for matrix, rows in matrices: - rows_on_model = rows.to( - device=unembed_weight.device, dtype=unembed_weight.dtype - ).unsqueeze(0) - with torch.no_grad(): - direct = gpt2_bridge.unembed(gpt2_bridge.ln_final(rows_on_model)).squeeze(0) - assert matrix.vocabulary_logits is not None - torch.testing.assert_close(matrix.vocabulary_logits, direct.float().cpu()) - - norms = rows.norm(dim=-1) - zero_mask = norms == 0 - normalized_rows = rows / torch.where( - zero_mask, torch.ones_like(norms), norms - ).unsqueeze(-1) - with torch.no_grad(): - direct_normalized = gpt2_bridge.unembed( - gpt2_bridge.ln_final( - normalized_rows.to( - device=unembed_weight.device, - dtype=unembed_weight.dtype, - ).unsqueeze(0) - ) - ).squeeze(0) - assert matrix.normalized_vocabulary_logits is not None - torch.testing.assert_close( - matrix.normalized_vocabulary_logits, - direct_normalized.float().cpu(), - ) - torch.testing.assert_close(matrix.factor_norms, norms) - assert torch.equal(matrix.zero_norm_mask, zero_mask) - for logits in (matrix.vocabulary_logits, matrix.normalized_vocabulary_logits): - assert logits.shape == (6, gpt2_bridge.cfg.d_vocab) - assert logits.dtype == torch.float32 - assert logits.device.type == "cpu" - assert logits.grad_fn is None - assert torch.isfinite(logits).all() - - -def test_public_rankings_decoding_and_target_ranks(backward_result, gpt2_bridge) -> None: - output = backward_result.layer(11).output_projection - assert output.vocabulary_logits is not None - assert output.top_ranking.indices.shape == (6, 3) - assert output.bottom_ranking.indices.shape == (6, 3) - direct_top = torch.topk(output.vocabulary_logits, k=3, dim=-1, largest=True) - direct_bottom = torch.topk(output.vocabulary_logits, k=3, dim=-1, largest=False) - - assert torch.equal(output.top(k=3).indices, direct_top.indices) - assert torch.equal(output.bottom(k=3).indices, direct_bottom.indices) - assert output.top_tokens(gpt2_bridge.tokenizer, k=3)[0] == [ - gpt2_bridge.tokenizer.decode([token_id]) for token_id in direct_top.indices[0].tolist() - ] - assert output.bottom_tokens(gpt2_bridge.tokenizer, k=3)[0] == [ - gpt2_bridge.tokenizer.decode([token_id]) for token_id in direct_bottom.indices[0].tolist() - ] - target_ranks = output.gradient_descent_target_ranks(backward_result.target_token_id) - normalized_target_ranks = output.gradient_descent_target_ranks( - backward_result.target_token_id, normalized=True - ) - assert target_ranks.shape == (6,) - assert normalized_target_ranks.shape == (6,) - assert target_ranks.dtype == torch.int64 - - -def test_public_api_defaults_to_raw_projection_only(gpt2_bridge) -> None: - from transformer_lens.tools.analysis import BackwardLens - - result = BackwardLens(gpt2_bridge).analyze(PROMPT, TARGET, [0]) - assert result.includes_normalized_logits is False - assert result.includes_full_logits is False - for matrix in ( - result.layers[0].input_projection, - result.layers[0].output_projection, - ): - assert matrix.vocabulary_logits is None - assert matrix.normalized_vocabulary_logits is None - assert matrix.top_ranking.indices.shape == (6, 10) - assert matrix.bottom_ranking.indices.shape == (6, 10) - assert matrix.normalized_top_ranking is None - assert matrix.normalized_bottom_ranking is None - assert matrix.gradient_descent_target_ranks(result.target_token_id).shape == (6,) - with pytest.raises(ValueError, match="return_full_logits=True"): - matrix.logits() - with pytest.raises(ValueError, match="were not requested"): - matrix.logits(normalized=True) - with pytest.raises((TypeError, BeartypeCallHintParamViolation), match="normalized"): - BackwardLens(gpt2_bridge).analyze(PROMPT, TARGET, [0], normalized=cast(bool, 1)) - - -def test_public_api_validates_retention_options_before_capture(gpt2_bridge) -> None: - from transformer_lens.tools.analysis import BackwardLens - - lens = BackwardLens(gpt2_bridge) - with pytest.raises(ValueError, match="top_k must be in"): - lens.analyze(PROMPT, TARGET, [0], top_k=0) - with pytest.raises(ValueError, match="top_k must be in"): - lens.analyze(PROMPT, TARGET, [0], top_k=gpt2_bridge.cfg.d_vocab + 1) - with pytest.raises(TypeError, match="top_k must be an integer"): - lens.analyze(PROMPT, TARGET, [0], top_k=True) - with pytest.raises((TypeError, BeartypeCallHintParamViolation), match="top_k"): - lens.analyze(PROMPT, TARGET, [0], top_k=cast(int, 1.5)) - with pytest.raises((TypeError, BeartypeCallHintParamViolation), match="return_full_logits"): - lens.analyze(PROMPT, TARGET, [0], return_full_logits=cast(bool, 1)) - - -@pytest.mark.parametrize( - ("prompt", "target", "layers", "error", "match"), - [ - ("", TARGET, [0], ValueError, "prompt must not be empty"), - (PROMPT, "", [0], ValueError, "exactly one token"), - (PROMPT, " New York", [0], ValueError, "got 2 tokens"), - (PROMPT, TARGET, [], ValueError, "at least one"), - (PROMPT, TARGET, [0, 0], ValueError, "duplicate"), - (PROMPT, TARGET, [-1], ValueError, "must be in"), - (PROMPT, TARGET, [12], ValueError, "must be in"), - (PROMPT, TARGET, [True], TypeError, "layer"), - pytest.param( - OVERLONG_PROMPT, - TARGET, - [0], - ValueError, - r"token count 1025.*n_ctx=1024", - id="over-n-ctx", - ), - ], -) -def test_capture_rejects_invalid_analysis_inputs( - gpt2_bridge, - prompt: str, - target: str, - layers: Sequence[int], - error: type[Exception], - match: str, -) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - with pytest.raises(error, match=match): - _capture_dense_mlp_gradient_factors(gpt2_bridge, prompt, target, layers) - - -def test_capture_rejects_non_bridge_and_non_raw_states(gpt2_bridge, monkeypatch) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - with pytest.raises(TypeError, match="TransformerBridge only"): - _capture_dense_mlp_gradient_factors(object(), PROMPT, TARGET, [0]) - monkeypatch.setattr(gpt2_bridge, "compatibility_mode", True) - with pytest.raises(ValueError, match="compatibility mode"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - monkeypatch.setattr(gpt2_bridge, "compatibility_mode", False) - monkeypatch.setattr(gpt2_bridge, "_weights_processed", True) - with pytest.raises(ValueError, match="processed"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - monkeypatch.setattr(gpt2_bridge, "_weights_processed", False) - monkeypatch.setattr(gpt2_bridge.cfg, "gated_mlp", True) - with pytest.raises(NotImplementedError, match="non-gated"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - monkeypatch.setattr(gpt2_bridge.cfg, "gated_mlp", False) - monkeypatch.setattr(gpt2_bridge, "tokenizer", None) - with pytest.raises(ValueError, match="tokenizer"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - - -@pytest.mark.parametrize( - ("model_name", "match"), - [ - pytest.param( - "hf-internal-testing/tiny-random-OPTForCausalLM", - r"layer 0 input projection hook_in carries a _UnflattenTokens hook_conversion" - r".*bypass.*flattened MLP regions", - id="opt-flattened-mlp-region", - ), - pytest.param( - "tiiuae/falcon-mamba-tiny-dev", - r"no block has an mlp submodule \(block types: SSMBlockBridge\)", - id="falcon-mamba-without-mlp", - ), - ], -) -def test_constructor_refuses_unsupported_mlp_structure_without_a_forward( - model_name: str, match: str -) -> None: - from transformer_lens.model_bridge import TransformerBridge - from transformer_lens.tools.analysis import BackwardLens - - model = TransformerBridge.boot_transformers(model_name, device="cpu", dtype=torch.float32) - forwards: list[None] = [] - model.original_model.register_forward_pre_hook(lambda *_: forwards.append(None)) - - with pytest.raises(ValueError, match=match): - BackwardLens(model) - assert forwards == [] - with torch.no_grad(): - model(model.to_tokens("Hello")) - assert len(forwards) == 1 - - -def test_capture_rejects_inference_mode(gpt2_bridge) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - with torch.inference_mode(): - with pytest.raises(ValueError, match="inference_mode"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - - -def test_capture_rejects_multi_device_before_projection_validation( - gpt2_bridge, monkeypatch -) -> None: - from transformer_lens.tools.analysis import backward_lens - - def projection_validation_must_not_run(*_args, **_kwargs) -> None: - raise AssertionError("projection validation ran for a multi-device Bridge") - - monkeypatch.setattr(gpt2_bridge.cfg, "n_devices", 2) - monkeypatch.setattr( - backward_lens, - "_get_dense_mlp_projections", - projection_validation_must_not_run, - ) - with pytest.raises(ValueError, match=r"single-device.*co-located.*n_devices=2"): - backward_lens._capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - - -def test_capture_rejects_a_per_layer_gate(gpt2_bridge, monkeypatch) -> None: - from transformer_lens.tools.analysis.backward_lens import _get_dense_mlp_projections - - mlp = gpt2_bridge.blocks[0].mlp - monkeypatch.setattr(mlp, "gate", torch.nn.Identity(), raising=False) - with pytest.raises(ValueError, match="dense, non-gated MLPBridge"): - _get_dense_mlp_projections(gpt2_bridge, (0,)) - - -def test_capture_rejects_an_unorientable_component(gpt2_bridge, monkeypatch) -> None: - from transformer_lens.tools.analysis.backward_lens import _get_dense_mlp_projections - - projection = getattr(gpt2_bridge.blocks[0].mlp, "in") - monkeypatch.setitem(projection._modules, "_original_component", torch.nn.Identity()) - with pytest.raises(ValueError, match="unknown weight layout"): - _get_dense_mlp_projections(gpt2_bridge, (0,)) - - -@pytest.mark.parametrize( - ("invalidity", "match"), - [ - ("shape", r"weight must have shape \(768, 3072\)"), - ("dtype", r"floating dtype.*torch.int64"), - ], - ids=["wrong-shape", "non-floating-dtype"], -) -def test_capture_rejects_an_invalid_weight( - gpt2_bridge, - monkeypatch, - invalidity: str, - match: str, -) -> None: - from transformer_lens.tools.analysis.backward_lens import _get_dense_mlp_projections - - component = getattr(gpt2_bridge.blocks[0].mlp, "in").original_component - if invalidity == "shape": - replacement_weight = torch.nn.Parameter(torch.empty(1, 1)) - else: - replacement_weight = torch.nn.Parameter( - torch.empty_like(component.weight, dtype=torch.int64), requires_grad=False - ) - monkeypatch.setattr(component, "weight", replacement_weight) - with pytest.raises(ValueError, match=match): - _get_dense_mlp_projections(gpt2_bridge, (0,)) - - -def test_capture_rejects_a_frozen_original_weight(gpt2_bridge) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - weight = getattr(gpt2_bridge.blocks[0].mlp, "in").original_component.weight - original_requires_grad = weight.requires_grad - weight.requires_grad_(False) - try: - with pytest.raises(ValueError, match="trainable Parameter"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - finally: - weight.requires_grad_(original_requires_grad) - - -@pytest.mark.parametrize( - "model_fixture", ["gpt2_bridge", "pythia_bridge"], ids=["gpt2", "pythia-70m"] -) -def test_capture_preserves_model_state_hooks_and_uses_one_autograd_call( - request, model_fixture: str, monkeypatch -) -> None: - from transformer_lens.model_bridge.generalized_components.normalization import ( - NATIVE_PATH_BWD_FALLBACK_WARNING, - NATIVE_PATH_EDIT_FALLBACK_WARNING, - ) - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - model = request.getfixturevalue(model_fixture) - mlp = model.blocks[0].mlp - projections = (getattr(mlp, "in"), mlp.out) - weights = [projection.original_component.weight for projection in projections] - saved_grads = [weight.grad for weight in weights] - weight_copies = [weight.detach().clone() for weight in weights] - training = model.training - outer_rng = torch.random.get_rng_state() - hook_calls = 0 - autograd_calls = 0 - original_grad = torch.autograd.grad - - def existing_hook(_tensor, hook=None) -> None: - nonlocal hook_calls - hook_calls += 1 - - def counting_grad(*args, **kwargs): - nonlocal autograd_calls - autograd_calls += 1 - return original_grad(*args, **kwargs) - - hook_point = mlp.out.hook_out - hook_point.add_hook(existing_hook) - existing_handle = hook_point.fwd_hooks[-1] - try: - model.train(True) - torch.manual_seed(1234) - rng_before = torch.random.get_rng_state() - for index, weight in enumerate(weights): - weight.grad = torch.full_like(weight, index + 1.0) - grad_copies = [weight.grad.clone() for weight in weights] - requires_grad = [weight.requires_grad for weight in weights] - hooks_before = _projection_hook_snapshots(model, 0) - monkeypatch.setattr(torch.autograd, "grad", counting_grad) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - result = _capture_dense_mlp_gradient_factors(model, PROMPT, TARGET, [0]) - - assert len(result.layers) == 1 - assert autograd_calls == 1 - assert hook_calls == 1 - assert not any( - str(warning.message) - in (NATIVE_PATH_BWD_FALLBACK_WARNING, NATIVE_PATH_EDIT_FALLBACK_WARNING) - for warning in caught - ) - assert torch.equal(torch.random.get_rng_state(), rng_before) - assert model.training is True - assert _projection_hook_snapshots(model, 0) == hooks_before - for weight, saved_weight, saved_grad, expected_requires_grad in zip( - weights, weight_copies, grad_copies, requires_grad, strict=True - ): - assert torch.equal(weight, saved_weight) - assert torch.equal(weight.grad, saved_grad) - assert weight.requires_grad is expected_requires_grad - finally: - existing_handle.hook.remove() - hook_point.fwd_hooks.remove(existing_handle) - - for weight, saved_grad in zip(weights, saved_grads, strict=True): - weight.grad = saved_grad - model.train(training) - torch.random.set_rng_state(outer_rng) - - -def test_capture_reconstructs_with_existing_activation_edits(gpt2_bridge) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - mlp = gpt2_bridge.blocks[0].mlp - input_hook_point = getattr(mlp, "in").hook_in - output_hook_point = mlp.out.hook_out - - def scale_input(tensor, hook=None): - return tensor * 0.5 - - def scale_output(tensor, hook=None): - return tensor * 3.0 - - input_hook_point.add_hook(scale_input) - input_handle = input_hook_point.fwd_hooks[-1] - output_hook_point.add_hook(scale_output) - output_handle = output_hook_point.fwd_hooks[-1] - try: - result = _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - assert input_handle in input_hook_point.fwd_hooks - assert output_handle in output_hook_point.fwd_hooks - finally: - input_handle.hook.remove() - input_hook_point.fwd_hooks.remove(input_handle) - output_handle.hook.remove() - output_hook_point.fwd_hooks.remove(output_handle) - - for factors in ( - result.layers[0].input_projection, - result.layers[0].output_projection, - ): - torch.testing.assert_close( - factors.reconstructed_gradient, - factors.weight_gradient, - atol=2e-6, - rtol=2e-5, - ) - - -def test_capture_cleans_owned_hooks_when_autograd_raises(gpt2_bridge, monkeypatch) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - hook_point = gpt2_bridge.blocks[0].mlp.out.hook_out - hook_calls = 0 - - def existing_hook(tensor, hook=None) -> None: - nonlocal hook_calls - hook_calls += 1 - - def fail_autograd(*args, **kwargs): - raise RuntimeError("forced autograd failure") - - hook_point.add_hook(existing_hook) - existing_handle = hook_point.fwd_hooks[-1] - hooks_before = _projection_hook_snapshots(gpt2_bridge, 0) - monkeypatch.setattr(torch.autograd, "grad", fail_autograd) - try: - with pytest.raises(RuntimeError, match="forced autograd failure"): - _capture_dense_mlp_gradient_factors(gpt2_bridge, PROMPT, TARGET, [0]) - assert hook_calls == 1 - assert existing_handle in hook_point.fwd_hooks - assert _projection_hook_snapshots(gpt2_bridge, 0) == hooks_before - finally: - existing_handle.hook.remove() - hook_point.fwd_hooks.remove(existing_handle) - - -@pytest.mark.parametrize( - "model_fixture", ["gpt2_bridge", "pythia_bridge"], ids=["gpt2", "pythia-70m"] -) -def test_capture_removes_only_owned_hooks_when_forward_fails(request, model_fixture: str) -> None: - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - model = request.getfixturevalue(model_fixture) - mlp = model.blocks[0].mlp - projections = (getattr(mlp, "in"), mlp.out) - weights = [projection.original_component.weight for projection in projections] - saved_grads = [weight.grad for weight in weights] - weight_copies = [weight.detach().clone() for weight in weights] - training = model.training - outer_rng = torch.random.get_rng_state() - hook_point = mlp.out.hook_out - - def fail(_module, _inputs, _output) -> None: - raise RuntimeError("forced existing-hook failure") - - existing_handle = hook_point.register_forward_hook(fail) - try: - model.train(True) - torch.manual_seed(5678) - rng_before = torch.random.get_rng_state() - for index, weight in enumerate(weights): - weight.grad = torch.full_like(weight, index + 3.0) - grad_copies = [weight.grad.clone() for weight in weights] - requires_grad = [weight.requires_grad for weight in weights] - hooks_before = _projection_hook_snapshots(model, 0) - with pytest.raises(RuntimeError, match="forced existing-hook failure"): - _capture_dense_mlp_gradient_factors(model, PROMPT, TARGET, [0]) - assert _projection_hook_snapshots(model, 0) == hooks_before - assert torch.equal(torch.random.get_rng_state(), rng_before) - assert model.training is True - for weight, saved_weight, saved_grad, expected_requires_grad in zip( - weights, weight_copies, grad_copies, requires_grad, strict=True - ): - assert torch.equal(weight, saved_weight) - assert torch.equal(weight.grad, saved_grad) - assert weight.requires_grad is expected_requires_grad - finally: - existing_handle.remove() - for weight, saved_grad in zip(weights, saved_grads, strict=True): - weight.grad = saved_grad - model.train(training) - torch.random.set_rng_state(outer_rng) - - -@pytest.mark.parametrize(("device", "dtype"), DEVICE_DTYPE_CASES) -def test_tiny_gpt2_capture_supports_available_devices_and_reduced_precision( - gpt2_bridge, device: str, dtype: torch.dtype -) -> None: - from transformers import GPT2Config, GPT2LMHeadModel - - from transformer_lens.model_bridge import TransformerBridge - from transformer_lens.tools.analysis import BackwardLens - from transformer_lens.tools.analysis.backward_lens import ( - _capture_dense_mlp_gradient_factors, - ) - - config = GPT2Config( - n_layer=2, - n_head=4, - n_embd=32, - n_inner=64, - n_positions=32, - vocab_size=gpt2_bridge.cfg.d_vocab, - resid_pdrop=0.1, - embd_pdrop=0.1, - attn_pdrop=0.1, - ) - hf_model = cast(Any, GPT2LMHeadModel)(config).to(device=device, dtype=dtype).eval() - bridge = TransformerBridge.boot_transformers( - "gpt2", - hf_model=hf_model, - tokenizer=gpt2_bridge.tokenizer, - dtype=dtype, - ) - bridge.train(True) - if device == "cuda": - rng_before = torch.cuda.get_rng_state() - elif device == "mps": - rng_before = torch.mps.get_rng_state() - else: - rng_before = torch.random.get_rng_state() - - # Dropout draws from the RNG only in training mode, which analyze() refuses, so the - # RNG contract is checked on the capture analyze() delegates to. - _capture_dense_mlp_gradient_factors(bridge, "Small test", " token", [0, 1]) - - if device == "cuda": - rng_after = torch.cuda.get_rng_state() - elif device == "mps": - rng_after = torch.mps.get_rng_state() - else: - rng_after = torch.random.get_rng_state() - assert torch.equal(rng_after, rng_before) - with pytest.raises(ValueError, match=r"BackwardLens\.analyze\(\) .*model\.eval\(\)"): - BackwardLens(bridge).analyze("Small test", " token", [0, 1]) - - bridge.eval() - result = BackwardLens(bridge).analyze("Small test", " token", [0, 1], normalized=True) - for layer in result.layers: - for matrix in (layer.input_projection, layer.output_projection): - factors = matrix.factors - assert torch.isfinite(factors.weight_gradient).all() - assert factors.relative_reconstruction_error <= 5e-2 - assert matrix.vocabulary_logits is None - assert matrix.normalized_vocabulary_logits is None - assert torch.isfinite(matrix.top_ranking.values).all() - assert torch.isfinite(matrix.bottom_ranking.values).all() - assert matrix.normalized_top_ranking is not None - assert matrix.normalized_bottom_ranking is not None - assert torch.isfinite(matrix.normalized_top_ranking.values).all() - assert torch.isfinite(matrix.normalized_bottom_ranking.values).all() - for ranking in ( - matrix.top_ranking, - matrix.bottom_ranking, - matrix.normalized_top_ranking, - matrix.normalized_bottom_ranking, - ): - assert ranking.values.device.type == "cpu" - assert ranking.indices.device.type == "cpu" diff --git a/tests/integration/test_jacobian_lens_causal_swap_benchmark.py b/tests/integration/test_jacobian_lens_causal_swap_benchmark.py index 57959f3df4..df9e35c727 100644 --- a/tests/integration/test_jacobian_lens_causal_swap_benchmark.py +++ b/tests/integration/test_jacobian_lens_causal_swap_benchmark.py @@ -9,7 +9,7 @@ import pytest import torch -from transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark import ( +from scripts.jacobian_lens_causal_swap_benchmark import ( SCHEMA_VERSION, BenchmarkCorpus, FunctionSpec, @@ -17,8 +17,8 @@ corpus_definition, run_causal_swap_benchmark, serialize_artifact, - success_rate_ci, ) +from transformer_lens.utilities import success_rate_ci LENS_REPO = "neuronpedia/jacobian-lens" LENS_REVISION = "a4114d7752d11eb546e6cf372213d7e75526d3a1" diff --git a/tests/integration/test_relevance_lens.py b/tests/integration/test_relevance_lens.py deleted file mode 100644 index cbfe60d6d6..0000000000 --- a/tests/integration/test_relevance_lens.py +++ /dev/null @@ -1,389 +0,0 @@ -"""End-to-end relevance-rule backend on tiny, offline HF fixtures. - -Per-component unit tests wrap one bridge component directly in a hand-built -single-block harness, which only ever registers a canonical mount name -(``ln1``, ``mlp``) once. That harness cannot see what happens on a real, -fully assembled ``TransformerBridge``, where the same component is *also* -reachable through the raw HF module tree under its own HF attribute name -(for example ``blocks.0._original_component.input_layernorm``). These tests -build a tiny random Qwen2 (the opaque gated-MLP native-forward path) and a tiny -random Phi-3 (``JointGateUpMLPBridge``'s already-reconstructed forward) -fully offline -- random weights from a programmatic HF config, no network -access, no checkpoint download -- and exercise all three rules together on -the real block stack: forward stays bit-identical, canonical mounts are -actually found and installed, and the gradient each rule-active node passes -upstream matches its closed-form VJP given the gradient it actually -received downstream in the real graph. -""" - -from typing import Any - -import pytest -import torch -from tokenizers import Tokenizer -from tokenizers.models import WordLevel -from tokenizers.pre_tokenizers import Whitespace -from transformers import AutoConfig, AutoModelForCausalLM, PreTrainedTokenizerFast - -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRules, - _RelevanceRuleCoverageEntry, - half_rule, - identity_rule, - ln_rule_grad, - use_relevance_rules, -) -from transformer_lens.model_bridge.bridge import TransformerBridge -from transformer_lens.model_bridge.generalized_components.gated_mlp import ( - resolve_activation_fn, -) -from transformer_lens.model_bridge.sources import build_bridge_config_from_hf -from transformer_lens.model_bridge.supported_architectures.phi3 import ( - Phi3ArchitectureAdapter, -) -from transformer_lens.model_bridge.supported_architectures.qwen2 import ( - Qwen2ArchitectureAdapter, -) -from transformer_lens.tools.analysis import RelevanceLens -from transformer_lens.tools.analysis.relevance_lens import RELEVANCE_RULES - -TOKENS = torch.tensor([[1, 5, 7, 42, 9]]) -N_LAYERS = 2 -TINY_DIMS = dict( - vocab_size=97, - hidden_size=32, - intermediate_size=64, - num_hidden_layers=N_LAYERS, - num_attention_heads=4, - num_key_value_heads=2, - max_position_embeddings=64, - pad_token_id=0, - bos_token_id=1, - eos_token_id=2, -) - -# Fitting needs prompts long enough to contain a valid source position, so these -# are word lists rather than short phrases. -FIT_CORPUS = "integration-fit-corpus" -FIT_SKIP_FIRST = 2 -FIT_PROMPTS = [ - "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi " - "omicron pi rho sigma tau upsilon phi chi psi omega", - "one two three four five six seven eight nine ten eleven twelve thirteen " - "fourteen fifteen sixteen seventeen eighteen nineteen twenty", - "red orange yellow green blue indigo violet cyan magenta amber teal olive " - "maroon navy silver gold bronze copper crimson scarlet azure", - "north south east west up down left right forward backward inside outside " - "above below near far early late always never", -] - - -class _MockTokenizer: - """Stand-in to satisfy TransformerBridge(tokenizer=...).""" - - -def _offline_tokenizer(prompts: list[str]) -> PreTrainedTokenizerFast: - """Word-level tokenizer over the prompts' own vocabulary, built offline.""" - words = sorted({word for prompt in prompts for word in prompt.split()}) - vocabulary = {"": 0, "": 1, "": 2, "": 3} - vocabulary.update({word: index + 4 for index, word in enumerate(words)}) - backend = Tokenizer(WordLevel(vocabulary, unk_token="")) - backend.pre_tokenizer = Whitespace() - return PreTrainedTokenizerFast( - tokenizer_object=backend, - eos_token="", - pad_token="", - unk_token="", - ) - - -def _build_qwen2_bridge() -> TransformerBridge: - hf_config = AutoConfig.for_model("qwen2", **TINY_DIMS) - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "Qwen2ForCausalLM", "qwen2-tiny", torch.float32 - ) - adapter = Qwen2ArchitectureAdapter(bridge_config) - return TransformerBridge(model=hf_model, adapter=adapter, tokenizer=_MockTokenizer()) - - -def _build_phi3_bridge() -> TransformerBridge: - hf_config = AutoConfig.for_model("phi3", **TINY_DIMS) - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "Phi3ForCausalLM", "phi3-tiny", torch.float32 - ) - adapter = Phi3ArchitectureAdapter(bridge_config) - return TransformerBridge(model=hf_model, adapter=adapter, tokenizer=_MockTokenizer()) - - -# Qwen2 exercises GatedMLPBridge's opaque native-forward path (the primary path -# per real usage), where the rules attach to the live HF activation and down -# projection; Phi-3 exercises JointGateUpMLPBridge's already-reconstructed -# forward, isolating rule bugs from Bridge-integration bugs on the -# fused-projection family. -FIXTURE_BUILDERS = { - "qwen2": _build_qwen2_bridge, - "phi3": _build_phi3_bridge, -} - - -@pytest.fixture(scope="module", params=sorted(FIXTURE_BUILDERS), ids=sorted(FIXTURE_BUILDERS)) -def bridge(request: pytest.FixtureRequest) -> TransformerBridge: - return FIXTURE_BUILDERS[request.param]() - - -def _expected_canonical_mounts() -> tuple[_RelevanceRuleCoverageEntry, ...]: - return tuple( - _RelevanceRuleCoverageEntry(kind=kind, path=f"blocks.{layer}.{mount}") - for kind, mounts in ( - ("normalization", ("ln1", "ln2")), - ("activation", ("mlp",)), - ("multiplicative_gate", ("mlp",)), - ) - for layer in range(N_LAYERS) - for mount in mounts - ) - - -class TestForwardIdentity: - def test_active_forward_matches_baseline_under_all_three_rules( - self, bridge: TransformerBridge - ) -> None: - with torch.no_grad(): - baseline = bridge(TOKENS) - with use_relevance_rules( - bridge, RelevanceRules(normalization=True, activation=True, multiplicative_gate=True) - ): - with torch.no_grad(): - active = bridge(TOKENS) - assert torch.equal(active, baseline) - - -class TestCoverage: - def test_every_canonical_mount_is_installed_and_none_skipped( - self, bridge: TransformerBridge - ) -> None: - with use_relevance_rules( - bridge, RelevanceRules(normalization=True, activation=True, multiplicative_gate=True) - ) as coverage: - pass - assert coverage.installed == _expected_canonical_mounts() - assert coverage.skipped == () - - -class TestGradientMatchesClosedFormOracle: - """Each rule-active node's local VJP, checked against the gradient it - actually receives in the real graph -- not a hand-rederived whole-model - oracle. The rule Functions compute a purely local closed-form VJP (already - proven against analytic oracles in the primitive and single-component - tests), so the only thing a real multi-block graph can newly break is the - wiring: the wrong node's weights, a disconnected graph path, or a mount - that silently never resolves so its rule never activates. Tapping - hook_in/hook_out -- outside the LN-rule's fail-closed - hook_scale/hook_normalized guard -- exposes exactly the gradient each node - passes upstream and the gradient it receives from downstream, with no - need to reconstruct attention or the rest of the stack by hand. - """ - - def test_ln_rule_and_gated_mlp_rule_match_local_oracles( - self, bridge: TransformerBridge - ) -> None: - ln1 = bridge.blocks[0].ln1 - mlp = bridge.blocks[0].mlp - captured: dict[str, torch.Tensor] = {} - - def _capture(key: str): - def _hook(tensor: torch.Tensor, hook=None) -> None: - captured[key] = tensor.detach().clone() - - return _hook - - ln1.hook_in.add_hook(_capture("ln_x")) - ln1.hook_in.add_hook(_capture("ln_grad_in"), dir="bwd") - ln1.hook_out.add_hook(_capture("ln_grad_out"), dir="bwd") - mlp.hook_in.add_hook(_capture("mlp_x")) - mlp.hook_in.add_hook(_capture("mlp_grad_in"), dir="bwd") - mlp.hook_out.add_hook(_capture("mlp_grad_out"), dir="bwd") - ln1_weight_grad = None - try: - with use_relevance_rules( - bridge, - RelevanceRules(normalization=True, activation=True, multiplicative_gate=True), - ): - logits = bridge(TOKENS) - logits.sum().backward() - ln1_weight_grad = ln1.weight.grad.clone() - finally: - ln1.hook_in.remove_hooks(dir="both") - ln1.hook_out.remove_hooks(dir="both") - mlp.hook_in.remove_hooks(dir="both") - mlp.hook_out.remove_hooks(dir="both") - for parameter in bridge.parameters(): - parameter.grad = None - - eps = getattr(ln1.original_component, "variance_epsilon", 1e-6) - weight = ln1.weight.detach() - denom = (captured["ln_x"].pow(2).mean(-1, keepdim=True) + eps).sqrt() - expected_ln_grad_in = ln_rule_grad(captured["ln_grad_out"] * weight, denom) - torch.testing.assert_close( - captured["ln_grad_in"], expected_ln_grad_in, atol=1e-5, rtol=1e-4 - ) - # weight keeps its ordinary gradient: the rule only redefines the x-path - # VJP, not d(output)/d(weight), given the same grad_out the rule-active - # forward actually produced. - expected_weight_grad = (captured["ln_grad_out"] * (captured["ln_x"] / denom)).sum( - dim=(0, 1) - ) - torch.testing.assert_close(ln1_weight_grad, expected_weight_grad, atol=1e-5, rtol=1e-4) - - x = captured["mlp_x"].detach().requires_grad_(True) - w_gate, w_in, w_out = mlp.W_gate.detach(), mlp.W_in.detach(), mlp.W_out.detach() - b_gate = getattr(mlp.gate, "bias", None) - b_in = getattr(getattr(mlp, "in"), "bias", None) - b_out = getattr(mlp.out, "bias", None) - act_fn = resolve_activation_fn(mlp.config) - with torch.enable_grad(): - gate_output = x @ w_gate - if b_gate is not None: - gate_output = gate_output + b_gate - up_output = x @ w_in - if b_in is not None: - up_output = up_output + b_in - activated = identity_rule(gate_output, act_fn) - gated = half_rule(activated, up_output) - down = gated @ w_out - if b_out is not None: - down = down + b_out - (expected_mlp_grad_in,) = torch.autograd.grad( - down, x, grad_outputs=captured["mlp_grad_out"] - ) - torch.testing.assert_close( - captured["mlp_grad_in"], expected_mlp_grad_in, atol=1e-5, rtol=1e-4 - ) - - -def _fit_bridge() -> TransformerBridge: - """A tiny Qwen2 with a real offline tokenizer, so fit() can tokenize.""" - hf_config = AutoConfig.for_model("qwen2", **TINY_DIMS) - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "Qwen2ForCausalLM", "qwen2-tiny", torch.float32 - ) - adapter = Qwen2ArchitectureAdapter(bridge_config) - return TransformerBridge( - model=hf_model, - adapter=adapter, - tokenizer=_offline_tokenizer(FIT_PROMPTS), - ) - - -def _fit_relevance(bridge: TransformerBridge, prompts: list[str], *, corpus: str) -> RelevanceLens: - return RelevanceLens.fit( - bridge, - prompts, - corpus=corpus, - source_layers=[0], - dim_batch=8, - skip_first_positions=FIT_SKIP_FIRST, - show_progress=False, - ) - - -def _residual_gradient(bridge: TransformerBridge) -> torch.Tensor: - """Probe a rule-sensitive residual VJP on a fresh graph.""" - captured: dict[str, torch.Tensor] = {} - - def capture(activation: torch.Tensor, hook: Any) -> torch.Tensor: - captured["residual"] = activation.detach().requires_grad_(True) - return captured["residual"] - - with torch.enable_grad(), bridge.hooks(fwd_hooks=[("blocks.0.hook_out", capture)]): - logits = bridge(TOKENS) - (gradient,) = torch.autograd.grad(logits[0, -1, 0], captured["residual"]) - assert torch.isfinite(gradient).all() - assert gradient.count_nonzero() > 0 - return gradient.detach().clone() - - -class TestRelevanceLensFitOnRealBridge: - def test_full_requested_rule_coverage_is_installed(self) -> None: - bridge = _fit_bridge() - - lens = _fit_relevance(bridge, FIT_PROMPTS, corpus=FIT_CORPUS) - - assert lens.rule_coverage.installed == _expected_canonical_mounts() - assert lens.rule_coverage.skipped == () - - def test_fit_leaves_the_forward_pass_bit_identical(self) -> None: - bridge = _fit_bridge() - with torch.no_grad(): - before = bridge(TOKENS) - - _fit_relevance(bridge, FIT_PROMPTS, corpus=FIT_CORPUS) - - with torch.no_grad(): - after = bridge(TOKENS) - assert torch.equal(after, before) - - def test_fit_restores_ordinary_gradients(self) -> None: - bridge = _fit_bridge() - before = _residual_gradient(bridge) - torch.testing.assert_close(_residual_gradient(bridge), before, atol=0, rtol=0) - parameter_flags = [parameter.requires_grad for parameter in bridge.parameters()] - - _fit_relevance(bridge, FIT_PROMPTS, corpus=FIT_CORPUS) - - after_fit = _residual_gradient(bridge) - with use_relevance_rules(bridge, RELEVANCE_RULES): - active_gradient = _residual_gradient(bridge) - assert not torch.allclose(active_gradient, before, atol=1e-6, rtol=1e-4) - torch.testing.assert_close(after_fit, before, atol=0, rtol=0) - assert [parameter.requires_grad for parameter in bridge.parameters()] == parameter_flags - assert all(parameter.grad is None for parameter in bridge.parameters()) - - def test_fit_records_rule_provenance(self) -> None: - bridge = _fit_bridge() - - lens = _fit_relevance(bridge, FIT_PROMPTS, corpus=FIT_CORPUS) - - assert lens.estimator == "relevance_lens" - assert lens.metadata["estimator"] == "relevance_lens" - assert lens.metadata["corpus"] == FIT_CORPUS - assert lens.metadata["enabled_rules"] == [ - "normalization", - "activation", - "multiplicative_gate", - ] - assert lens.metadata["relevance_rule_version"] == lens.relevance_rule_version - - -class TestJointFitEqualsWeightedMerge: - def test_joint_fit_matches_prompt_count_weighted_merge(self) -> None: - bridge = _fit_bridge() - # Deliberately unequal shards: an unweighted mean of the two shard - # matrices would differ from the joint fit, so this only passes when the - # merge really weights by prompt count. - split = 1 - first, second = FIT_PROMPTS[:split], FIT_PROMPTS[split:] - - joint = _fit_relevance(bridge, FIT_PROMPTS, corpus=FIT_CORPUS) - shard_a = _fit_relevance(bridge, first, corpus=FIT_CORPUS) - shard_b = _fit_relevance(bridge, second, corpus=FIT_CORPUS) - merged = RelevanceLens.merge([shard_a, shard_b]) - - assert shard_a.n_prompts != shard_b.n_prompts - assert merged.n_prompts == joint.n_prompts - assert merged.estimator == joint.estimator - assert merged.relevance_rule_version == joint.relevance_rule_version - assert merged.enabled_rules == joint.enabled_rules - for layer in joint.source_layers: - torch.testing.assert_close( - merged.jacobians[layer], - joint.jacobians[layer], - atol=1e-6, - rtol=1e-5, - ) diff --git a/tests/integration/test_svd_circuits.py b/tests/integration/test_svd_circuits.py deleted file mode 100644 index b68bbd7c72..0000000000 --- a/tests/integration/test_svd_circuits.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Integration test: OV vocab readout and the causal patch gate on a real GPT-2 head. - -Drives the whole read-then-patch path against layer 9 head 9 (the paper's canonical -name-mover head) on a minimal IOI-style prompt, to check that the readout and the causal gate -agree on a real Bridge rather than only on the unit suite's synthetic and tiny-model fixtures. -Tiny MPT, Bamba and T5 checkpoints pin the architecture refusals on real bridges. -""" - -from __future__ import annotations - -import math - -import pytest -import torch - -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.SVDInterpreter import SVDInterpreter -from transformer_lens.tools.analysis.svd_circuits import ( - decompose_head, - patch_along_directions, - project_activations, - vocab_readout, -) - -CLEAN_PROMPT = "When Mary and John went to the store, John gave a drink to" -LAYER, HEAD = 9, 9 - - -@pytest.fixture(scope="module") -def gpt2_bridge(): - model = TransformerBridge.boot_transformers("gpt2", device="cpu", dtype=torch.float32) - model.enable_compatibility_mode() - return model - - -def _logit_diff_metric(model): - mary_token = model.to_single_token(" Mary") - john_token = model.to_single_token(" John") - - def metric(logits: torch.Tensor) -> float: - return float(logits[0, -1, mary_token] - logits[0, -1, john_token]) - - return metric - - -def test_svd_circuits_readout_and_patch_gate_on_name_mover_head(gpt2_bridge) -> None: - decomposition = decompose_head(gpt2_bridge, layer=LAYER, head=HEAD, which=("OV",)) - ov = decomposition.OV - assert ov is not None - - readout = vocab_readout(gpt2_bridge, ov, k=10) - assert readout.shape == (gpt2_bridge.cfg.d_vocab, 10) - assert torch.isfinite(readout).all() - - # Pick the first non-degenerate direction rather than assuming index 0 is isolated. - top_direction = next(row.idx for row in ov.rank_report if not row.is_degenerate) - - # readout reproduces the shipped SVDInterpreter reference up to sign, so a U-for-V swap - # or an all-zeros return in vocab_readout fails here rather than passing a finiteness check. - reference = SVDInterpreter(gpt2_bridge).get_singular_vectors( - "OV", LAYER, head_index=HEAD, num_vectors=top_direction + 1 - )[:, 0, top_direction] - assert torch.allclose(readout[:, top_direction], reference, atol=1e-4) or torch.allclose( - readout[:, top_direction], -reference, atol=1e-4 - ) - - prompt = gpt2_bridge.to_tokens(CLEAN_PROMPT) - metric = _logit_diff_metric(gpt2_bridge) - - result = patch_along_directions(gpt2_bridge, ov, prompt, metric, keep=[top_direction]) - - assert result.retained == [top_direction] - assert math.isfinite(result.original_metric) - assert math.isfinite(result.patched_metric) - assert math.isfinite(result.delta_metric) - assert math.isfinite(result.baseline_delta_metric) - assert isinstance(result.gated, bool) - - rank = ov.V.shape[1] - - # Keeping every direction reconstructs the head onto its own full OV span: a no-op, so the - # metric barely moves. A width-rank in-span control also spans span(V), so it ties this - # kept projector by construction and the gate needs an explicit threshold. A hook never - # installed, or one projecting the wrong basis, breaks the delta assertion. - full_keep = patch_along_directions( - gpt2_bridge, ov, prompt, metric, keep=list(range(rank)), threshold=1e-4 - ) - assert abs(full_keep.delta_metric) < 1e-4 - - # Ablating every direction zeroes the head's whole output, which must move the logit diff. - # An empty retained set ties the baseline by construction, so gate it with an explicit - # threshold; only delta_metric's magnitude is asserted here. - full_ablate = patch_along_directions( - gpt2_bridge, ov, prompt, metric, ablate=list(range(rank)), threshold=0.0 - ) - assert abs(full_ablate.delta_metric) > 1e-2 - - -TINY_MPT = "hf-internal-testing/tiny-random-MptForCausalLM" -TINY_BAMBA = "hmellor/tiny-random-BambaForCausalLM" -TINY_T5 = "hf-internal-testing/tiny-random-t5" - - -@pytest.fixture(scope="module") -def tiny_mpt(): - model = TransformerBridge.boot_transformers(TINY_MPT, device="cpu", dtype=torch.float32) - model.eval() - return model - - -@pytest.fixture(scope="module") -def tiny_bamba(): - return TransformerBridge.boot_transformers(TINY_BAMBA, device="cpu", dtype=torch.float32) - - -@pytest.fixture(scope="module") -def tiny_t5(): - return TransformerBridge.boot_transformers(TINY_T5, device="cpu", dtype=torch.float32) - - -def _hook_result_fires(model, layer: int, prompt: torch.Tensor) -> bool: - fired = [] - model.set_use_attn_result(True) - try: - model.run_with_hooks( - prompt, - fwd_hooks=[(f"blocks.{layer}.attn.hook_result", lambda act, hook: fired.append(act))], - ) - finally: - model.set_use_attn_result(False) - return bool(fired) - - -def test_mpt_activation_tools_refuse_unless_hook_result_fires(tiny_mpt) -> None: - """MPT's ALiBi attention accepts use_attn_result; the activation tools either read a fired - hook_result or refuse, never a KeyError or a gate verdict from a no-op patch. - - Which branch runs follows the bridge: an MPT attention that skips hook_result gets the - refusal, one that fires it gets real coefficients and a patch that moves the metric. - """ - layer, head = 0, 0 - ov = decompose_head(tiny_mpt, layer, head, which=("OV",)).OV - assert ov is not None - assert torch.isfinite(ov.S).all() - prompt = torch.tensor([[1, 5, 7, 9, 11]]) - metric = lambda logits: float(logits[0, -1, 0] - logits[0, -1, 1]) - - if _hook_result_fires(tiny_mpt, layer, prompt): - projection = project_activations(tiny_mpt, ov, prompt) - assert projection.coefficients.shape == (prompt.shape[1], ov.V.shape[1]) - assert torch.isfinite(projection.coefficients).all() - rank = ov.V.shape[1] - ablated = patch_along_directions( - tiny_mpt, ov, prompt, metric, ablate=list(range(rank)), threshold=0.0 - ) - assert ablated.delta_metric != 0.0 - else: - message = ( - r"MPTALiBiAttentionBridge\) accepted use_attn_result but never fired " - rf"blocks\.{layer}\.attn\.hook_result" - ) - with pytest.raises(ValueError, match=message): - project_activations(tiny_mpt, ov, prompt) - with pytest.raises(ValueError, match=message): - patch_along_directions(tiny_mpt, ov, prompt, metric, keep=[0]) - assert tiny_mpt.cfg.use_attn_result is False - - -def test_decompose_head_refuses_ssm_layer_on_hybrid(tiny_bamba) -> None: - """Bamba's mamba layers carry no attn submodule; decompose_head names the layer and the - attention layers instead of the bridge's bare AttributeError, and those still decompose.""" - blocks = tiny_bamba.blocks - ssm_layers = [i for i, block in enumerate(blocks) if "attn" not in block._modules] - attn_layers = [i for i, block in enumerate(blocks) if "attn" in block._modules] - assert ssm_layers and attn_layers - - with pytest.raises(ValueError, match=f"attention block at layer {ssm_layers[0]}") as excinfo: - decompose_head(tiny_bamba, ssm_layers[0], 0) - assert f"Layers with an `attn` submodule: {attn_layers}" in str(excinfo.value) - - decomposition = decompose_head(tiny_bamba, attn_layers[0], 0) - assert decomposition.QK is not None and decomposition.OV is not None - assert torch.isfinite(decomposition.OV.S).all() - - -def test_decompose_head_refuses_encoder_decoder_bridge(tiny_t5) -> None: - """T5 keeps separate encoder and decoder stacks and no `blocks`; the refusal names the layer.""" - with pytest.raises(ValueError, match="layer 0's head weights.*no `blocks` stack"): - decompose_head(tiny_t5, 0, 0) diff --git a/tests/integration/test_svd_circuits_mpt.py b/tests/integration/test_svd_circuits_mpt.py deleted file mode 100644 index 6beb8ca559..0000000000 --- a/tests/integration/test_svd_circuits_mpt.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Integration test: SVD Circuits' activation tools on MPT's per-head attention output. - -MPT's ALiBi attention reimplements the forward, so these pin that ``project_activations`` -reads a real ``hook_result`` and that ``patch_along_directions`` edits it, rather than the -patch silently leaving the forward untouched. -""" - -from __future__ import annotations - -import pytest -import torch - -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.tools.analysis.svd_circuits import ( - decompose_head, - patch_along_directions, - project_activations, -) - -MODEL = "hf-internal-testing/tiny-random-MptForCausalLM" -LAYER = 0 -PROMPT = torch.tensor([[1, 5, 7, 9, 11]]) - - -@pytest.fixture(scope="module") -def mpt_bridge(): - model = TransformerBridge.boot_transformers(MODEL, device="cpu", dtype=torch.float32) - model.eval() - return model - - -@pytest.mark.parametrize("head", [0, 1]) -def test_project_activations_reconstructs_mpt_head_output(mpt_bridge, head) -> None: - ov = decompose_head(mpt_bridge, LAYER, head, which=("OV",)).OV - assert ov is not None - projection = project_activations(mpt_bridge, ov, PROMPT) - - mpt_bridge.set_use_attn_result(True) - try: - _, cache = mpt_bridge.run_with_cache(PROMPT) - finally: - mpt_bridge.set_use_attn_result(False) - expected = cache[f"blocks.{LAYER}.attn.hook_result"][0, :, head, :] - - assert projection.coefficients.shape == (PROMPT.shape[1], ov.V.shape[1]) - reconstructed = projection.coefficients @ ov.V.transpose(-2, -1) - torch.testing.assert_close(reconstructed, expected, atol=1e-5, rtol=1e-5) - assert mpt_bridge.cfg.use_attn_result is False - - -def test_patch_along_directions_edits_mpt_head_output(mpt_bridge) -> None: - """Ablating the head's whole OV span must move the metric; a patch hook that never ran - would leave it exactly unchanged.""" - ov = decompose_head(mpt_bridge, LAYER, 0, which=("OV",)).OV - assert ov is not None - metric = lambda logits: float(logits[0, -1, 0] - logits[0, -1, 1]) - - ablated = patch_along_directions( - mpt_bridge, ov, PROMPT, metric, ablate=list(range(ov.V.shape[1])), threshold=0.0 - ) - assert abs(ablated.delta_metric) > 1e-6 - assert mpt_bridge.cfg.use_attn_result is False diff --git a/tests/integration/test_svd_circuits_oracle_parity.py b/tests/integration/test_svd_circuits_oracle_parity.py deleted file mode 100644 index 9a9bba3dc2..0000000000 --- a/tests/integration/test_svd_circuits_oracle_parity.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Slow qualitative OV sweep on GPT-2-small layer 9 head 9. - -Reports prompt-specific gate counts without requiring a minimum or claiming a causal -subfunction split. There is no pinned external numerical oracle: the Beyond Components -paper publishes no numeric table for this head. The repository's ``SVDInterpreter`` -cross-check is covered separately in ``test_svd_circuits.py``. - -Keep-mode gates compare the metric change with a sampled mean random in-span control -magnitude, not a statistical significance threshold. Counts may vary with seed and draw -count, including zero. The checks cover eligibility, finite results, gate polarity, and -fixed-seed reproducibility. Each sweep costs ``k * (n_baseline + 2)`` forward passes, -so the model tests are marked ``slow`` and excluded from the default tiers. -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass -from typing import Callable, List, Sequence, Tuple - -import pytest -import torch - -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.tools.analysis.svd_circuits import ( - HeadSVD, - decompose_head, - patch_along_directions, -) - -CLEAN_PROMPT = "When Mary and John went to the store, John gave a drink to" - -# Wang et al. (2022), arXiv 2211.00593, identifies L9H9 as a name mover in the IOI circuit. -LAYER, HEAD = 9, 9 - -TOP_K_DIRECTIONS = 8 -BASELINE_DRAWS = 32 -BASELINE_SEED = 0 - - -@pytest.fixture(scope="module") -def gpt2_bridge(): - model = TransformerBridge.boot_transformers("gpt2", device="cpu", dtype=torch.float32) - model.enable_compatibility_mode() - # Forward-pass tools require evaluation mode. - model.eval() - return model - - -def _logit_diff_metric(model) -> Callable[[torch.Tensor], float]: - mary_token = model.to_single_token(" Mary") - john_token = model.to_single_token(" John") - - def metric(logits: torch.Tensor) -> float: - return float(logits[0, -1, mary_token] - logits[0, -1, john_token]) - - return metric - - -@dataclass(frozen=True) -class _GateRow: - """One swept direction's gate verdict.""" - - idx: int - sigma: float - delta_metric: float - baseline_delta_metric: float - gated: bool - - -def _eligible_directions(head_svd: HeadSVD, k: int) -> List[int]: - """The first ``k`` directions that are attributable on their own. - - Fewer than ``k`` may qualify: the degeneracy guard is a feature, so a short sweep - is used as-is rather than treated as an error. - """ - eligible = [ - row.idx for row in head_svd.rank_report if not row.is_degenerate and not row.is_null - ] - return eligible[:k] - - -def _gated_directions( - model, - head_svd: HeadSVD, - prompt: torch.Tensor, - metric: Callable[[torch.Tensor], float], - *, - k: int, - n_baseline: int, - seed: int, -) -> List[_GateRow]: - """Report keep-mode changes against mean same-width random in-span control magnitudes.""" - rows: List[_GateRow] = [] - for idx in _eligible_directions(head_svd, k): - result = patch_along_directions( - model, - head_svd, - prompt, - metric, - keep=[idx], - rng=torch.Generator().manual_seed(seed), - n_baseline=n_baseline, - ) - rows.append( - _GateRow( - idx=idx, - sigma=head_svd.rank_report[idx].sigma, - delta_metric=result.delta_metric, - baseline_delta_metric=result.baseline_delta_metric, - gated=result.gated, - ) - ) - return rows - - -def _format_rows(rows: Sequence[_GateRow], *, seed: int, n_baseline: int) -> str: - lines = [ - f"seed={seed}, draws={n_baseline}, swept={len(rows)}, " - f"gated={sum(row.gated for row in rows)} (descriptive count)", - f"{'dir':>4} {'sigma':>10} {'delta':>12} {'baseline':>12} {'gated':>6}", - ] - for row in rows: - lines.append( - f"{row.idx:>4} {row.sigma:>10.4f} {row.delta_metric:>12.6f} " - f"{row.baseline_delta_metric:>12.6f} {str(row.gated):>6}" - ) - return "\n".join(lines) - - -def _sweep( - gpt2_bridge, *, seed: int = BASELINE_SEED, n_baseline: int = BASELINE_DRAWS -) -> Tuple[_GateRow, ...]: - decomposition = decompose_head(gpt2_bridge, layer=LAYER, head=HEAD, which=("OV",)) - ov = decomposition.OV - assert ov is not None - return tuple( - _gated_directions( - gpt2_bridge, - ov, - gpt2_bridge.to_tokens(CLEAN_PROMPT), - _logit_diff_metric(gpt2_bridge), - k=TOP_K_DIRECTIONS, - n_baseline=n_baseline, - seed=seed, - ) - ) - - -@pytest.fixture(scope="module") -def default_sweep(gpt2_bridge) -> Tuple[_GateRow, ...]: - """Share immutable default rows without replacing the fresh reproducibility sweep.""" - return _sweep(gpt2_bridge) - - -@pytest.mark.slow -@pytest.mark.parametrize("seed", [0, 1, 2]) -@pytest.mark.parametrize("n_baseline", [16, BASELINE_DRAWS]) -def test_qualitative_sweep_has_finite_results_and_consistent_gates( - gpt2_bridge, default_sweep, seed: int, n_baseline: int -) -> None: - rows = ( - default_sweep - if (seed, n_baseline) == (BASELINE_SEED, BASELINE_DRAWS) - else _sweep(gpt2_bridge, seed=seed, n_baseline=n_baseline) - ) - report = _format_rows(rows, seed=seed, n_baseline=n_baseline) - print(report) - - ov = decompose_head(gpt2_bridge, layer=LAYER, head=HEAD, which=("OV",)).OV - assert ov is not None - expected_ids = [row.idx for row in ov.rank_report if not row.is_degenerate and not row.is_null][ - :TOP_K_DIRECTIONS - ] - actual_ids = [row.idx for row in rows] - assert rows, "no attributable OV directions to sweep" - assert actual_ids == expected_ids, report - assert len(set(actual_ids)) == len(actual_ids), report - for row in rows: - assert math.isfinite(row.delta_metric), report - assert math.isfinite(row.baseline_delta_metric), report - assert row.baseline_delta_metric >= 0, report - assert row.gated == (abs(row.delta_metric) < row.baseline_delta_metric), report - - -@pytest.mark.slow -def test_sweep_is_reproducible_under_a_fixed_seed(gpt2_bridge, default_sweep) -> None: - second = _sweep(gpt2_bridge) - - assert default_sweep == second diff --git a/tests/unit/model_bridge/generalized_components/test_gated_mlp_relevance_rule.py b/tests/unit/model_bridge/generalized_components/test_gated_mlp_relevance_rule.py deleted file mode 100644 index db20ac6bdf..0000000000 --- a/tests/unit/model_bridge/generalized_components/test_gated_mlp_relevance_rule.py +++ /dev/null @@ -1,450 +0,0 @@ -"""Identity-/Half-rule integration on GatedMLPBridge's opaque native-forward path. - -The raw (non-fused) gated-MLP path keeps the HF module's own forward intact and -installs the rules on its live submodules for a ``use_relevance_rules`` scope: the -Identity-rule by swapping the module's activation callable, and the Half-rule by a -forward-pre-hook that halves the gradient entering the down projection (the gate*up -product). Because the real forward runs unchanged, anything it does beyond the core -``down(act(gate) * up)`` shape -- a post-product multiplier, activation sparsity, the -module's own hooks -- survives into the backward graph, and the rule VJP is taken -through that real forward rather than a reconstruction of the core shape. The rules -attach regardless of the projection weight class, so an opaque backing that is neither -``nn.Linear`` nor ``Conv1D`` is still covered. Only the relu-family Identity-rule -exclusion and the missing-activation-callable case remain unsupported. -""" - -import pytest -import torch -import torch.nn as nn -import torch.nn.functional as F -from transformers.pytorch_utils import Conv1D - -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRules, - RelevanceRuleUnsupportedError, - _RelevanceRuleCoverageEntry, - half_rule, - identity_rule, - use_relevance_rules, -) -from transformer_lens.model_bridge.generalized_components.base import ( - GeneralizedComponent, -) -from transformer_lens.model_bridge.generalized_components.gated_mlp import ( - GatedMLPBridge, -) -from transformer_lens.model_bridge.generalized_components.linear import LinearBridge - - -class _Cfg: - def __init__(self, hidden_act: str = "silu"): - self.hidden_act = hidden_act - - -class _ReluSquared(nn.Module): - def forward(self, x: torch.Tensor) -> torch.Tensor: - return torch.relu(x).square() - - -_ACTIVATIONS = {"silu": nn.SiLU, "relu2": _ReluSquared} - - -class _OpaqueProj(nn.Module): - """Weight-backed projection that is neither nn.Linear nor Conv1D. - - Stands in for a backing class the retired weight-orientation allowlist would - have refused; the rules now attach without reading its weights at all. - """ - - def __init__(self, d_in: int, d_out: int, bias: bool = True): - super().__init__() - self.weight = nn.Parameter(torch.randn(d_out, d_in)) - self.bias = nn.Parameter(torch.randn(d_out)) if bias else None - - def forward(self, x: torch.Tensor) -> torch.Tensor: - out = x @ self.weight.T - return out if self.bias is None else out + self.bias - - -class _TinyGatedMLP(nn.Module): - """Mirrors the Qwen2/Llama/Gemma gated-MLP: one opaque call over its own submodules. - - The activation is an ``nn.Module`` attribute the forward calls, matching the - ``ACT2FN`` shape the Identity-rule wrap targets. - """ - - def __init__(self, gate_proj, up_proj, down_proj, hidden_act: str = "silu"): - super().__init__() - self.gate_proj = gate_proj - self.up_proj = up_proj - self.down_proj = down_proj - self.act_fn = _ACTIVATIONS[hidden_act]() - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)) - - -class _MultiplierGatedMLP(_TinyGatedMLP): - """A gated MLP whose forward does strictly more than ``down(act(gate) * up)``. - - The constant post-product multiplier stands in for the Falcon-H1 / Gemma3n - families whose native forward applies extra scaling. A rule VJP taken through a - reconstruction of only the core gated shape would omit the multiplier and - disagree with the VJP through this real forward. - """ - - def __init__(self, gate_proj, up_proj, down_proj, multiplier: float = 1.7): - super().__init__(gate_proj, up_proj, down_proj) - self.multiplier = multiplier - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return super().forward(x) * self.multiplier - - -class _Block(nn.Module): - """Mounts a gated-MLP bridge at the canonical mlp position.""" - - def __init__(self, mlp: GeneralizedComponent): - super().__init__() - self.mlp = mlp - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.mlp(x) - - -def _make_projections(backing_class: str, d_model: int = 4, d_mlp: int = 8, bias: bool = True): - torch.manual_seed(0) - if backing_class == "nn.Linear": - return ( - nn.Linear(d_model, d_mlp, bias=bias), - nn.Linear(d_model, d_mlp, bias=bias), - nn.Linear(d_mlp, d_model, bias=bias), - ) - if backing_class == "Conv1D": - return ( - Conv1D(d_mlp, d_model), - Conv1D(d_mlp, d_model), - Conv1D(d_model, d_mlp), - ) - return ( - _OpaqueProj(d_model, d_mlp, bias=bias), - _OpaqueProj(d_model, d_mlp, bias=bias), - _OpaqueProj(d_mlp, d_model, bias=bias), - ) - - -def _wire_bridge(hf_mlp: nn.Module, config: _Cfg) -> GatedMLPBridge: - bridge = GatedMLPBridge(name="mlp", config=config) - gate_bridge = LinearBridge(name="gate_proj") - in_bridge = LinearBridge(name="up_proj") - out_bridge = LinearBridge(name="down_proj") - bridge.add_module("gate", gate_bridge) - bridge.add_module("in", in_bridge) - bridge.add_module("out", out_bridge) - bridge.set_original_component(hf_mlp) - gate_bridge.set_original_component(hf_mlp.gate_proj) - in_bridge.set_original_component(hf_mlp.up_proj) - out_bridge.set_original_component(hf_mlp.down_proj) - return bridge - - -def _make_bridge( - backing_class: str, bias: bool = True, hidden_act: str = "silu" -) -> tuple[_Block, _TinyGatedMLP]: - gate_proj, up_proj, down_proj = _make_projections(backing_class, bias=bias) - hf_mlp = _TinyGatedMLP(gate_proj, up_proj, down_proj, hidden_act=hidden_act) - bridge = _wire_bridge(hf_mlp, _Cfg(hidden_act)) - return _Block(bridge), hf_mlp - - -def _make_multiplier_bridge(multiplier: float = 1.7) -> tuple[_Block, _MultiplierGatedMLP]: - gate_proj, up_proj, down_proj = _make_projections("nn.Linear", bias=True) - hf_mlp = _MultiplierGatedMLP(gate_proj, up_proj, down_proj, multiplier=multiplier) - bridge = _wire_bridge(hf_mlp, _Cfg("silu")) - return _Block(bridge), hf_mlp - - -def _oracle_grads(hf_mlp, x, activation_active, gate_active, multiplier: float = 1.0): - # hf_mlp's parameters are shared with the bridge under test, so a prior backward - # already left gradients on them; reset first or this second backward would - # accumulate on top instead of producing an independently comparable oracle. The - # oracle runs the module's real submodules -- including the multiplier -- so it is - # the VJP through the actual forward, not through the core gated shape alone. - for p in hf_mlp.parameters(): - p.grad = None - x_oracle = x.detach().clone().requires_grad_(True) - gate_output = hf_mlp.gate_proj(x_oracle) - up_output = hf_mlp.up_proj(x_oracle) - activated = ( - identity_rule(gate_output, hf_mlp.act_fn) - if activation_active - else hf_mlp.act_fn(gate_output) - ) - gated = half_rule(activated, up_output) if gate_active else activated * up_output - down = hf_mlp.down_proj(gated) * multiplier - down.sum().backward() - grads = {n: p.grad.clone() for n, p in hf_mlp.named_parameters()} - return x_oracle.grad.clone(), grads - - -BACKING_CLASSES = ["nn.Linear", "Conv1D", "opaque"] - - -class TestGatedMLPRelevanceRuleCapability: - @pytest.mark.parametrize("backing_class", BACKING_CLASSES) - def test_capable_of_both_kinds_regardless_of_weight_backing(self, backing_class): - block, _ = _make_bridge(backing_class) - assert set(block.mlp._relevance_rule_kinds) == {"activation", "multiplicative_gate"} - - def test_identity_rule_unsupported_for_relu_squared_activation(self): - block, _ = _make_bridge("nn.Linear", hidden_act="relu2") - assert block.mlp._relevance_rule_kinds == ("multiplicative_gate",) - - with pytest.raises(RelevanceRuleUnsupportedError, match="mlp"): - with use_relevance_rules(block, RelevanceRules(activation=True)): - pass - - def test_activation_unsupported_when_no_activation_callable_is_exposed(self): - # A gated MLP whose forward uses a bare function has no callable activation - # attribute for the Identity-rule to wrap, so requesting it must raise rather - # than install a silent no-op. The Half-rule needs no activation access and - # stays available. - class _FunctionalActMLP(nn.Module): - def __init__(self): - super().__init__() - self.gate_proj = nn.Linear(4, 8) - self.up_proj = nn.Linear(4, 8) - self.down_proj = nn.Linear(8, 4) - - def forward(self, x): - return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x)) - - torch.manual_seed(0) - bridge = _wire_bridge(_FunctionalActMLP(), _Cfg("silu")) - block = _Block(bridge) - - assert bridge._relevance_rule_kinds == ("multiplicative_gate",) - with pytest.raises(RelevanceRuleUnsupportedError, match="mlp"): - with use_relevance_rules(block, RelevanceRules(activation=True)): - pass - - def test_half_rule_remains_available_under_relu_squared_activation(self): - block, _ = _make_bridge("nn.Linear", hidden_act="relu2") - x = torch.randn(2, 4) - baseline = block(x) - with use_relevance_rules(block, RelevanceRules(multiplicative_gate=True)) as coverage: - assert coverage.installed == ( - _RelevanceRuleCoverageEntry("multiplicative_gate", "mlp"), - ) - assert torch.equal(block(x), baseline) - - -@pytest.mark.parametrize("component_training", [False, True]) -@pytest.mark.parametrize("activation_training", [False, True]) -def test_activation_wrapper_preserves_existing_module_modes( - component_training: bool, activation_training: bool -) -> None: - block, hf_mlp = _make_bridge("nn.Linear") - block.train(component_training) - original_activation = hf_mlp.act_fn - original_activation.train(activation_training) - original_modes = {module: module.training for module in block.modules()} - - with use_relevance_rules(block, RelevanceRules(activation=True)): - assert hf_mlp.act_fn is not original_activation - assert hf_mlp.act_fn.training is component_training - assert all(module.training is mode for module, mode in original_modes.items()) - - assert hf_mlp.act_fn is original_activation - assert all(module.training is mode for module, mode in original_modes.items()) - - -class TestGatedMLPRelevanceRuleForwardIdentity: - @pytest.mark.parametrize("backing_class", BACKING_CLASSES) - def test_forward_identical_while_rule_active(self, backing_class): - block, _ = _make_bridge(backing_class) - x = torch.randn(3, 4) - baseline = block(x) - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - active = block(x) - assert torch.equal(active, baseline) - - def test_forward_identical_while_rule_active_with_extra_forward_ops(self): - block, _ = _make_multiplier_bridge() - x = torch.randn(3, 4) - baseline = block(x) - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - active = block(x) - assert torch.equal(active, baseline) - - @pytest.mark.parametrize("backing_class", BACKING_CLASSES) - def test_forward_unchanged_when_rule_inactive(self, backing_class): - block, _ = _make_bridge(backing_class) - x = torch.randn(3, 4) - assert torch.equal(block(x), block(x)) - - def test_activation_callable_restored_after_scope(self): - block, hf_mlp = _make_bridge("nn.Linear") - original_act = hf_mlp._modules["act_fn"] - with use_relevance_rules(block, RelevanceRules(activation=True)): - assert hf_mlp._modules["act_fn"] is not original_act - assert hf_mlp._modules["act_fn"] is original_act - - -class TestGatedMLPRelevanceRuleVJP: - @pytest.mark.parametrize("backing_class", BACKING_CLASSES) - def test_both_rules_active_matches_real_forward_oracle(self, backing_class): - block, hf_mlp = _make_bridge(backing_class) - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - grads = {n: p.grad.clone() for n, p in hf_mlp.named_parameters()} - - expected_grad_x, expected_grads = self._expected(hf_mlp, x, True, True) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) - for name, grad in grads.items(): - # The down projection's own weight gradient stays ordinary: the Half-rule - # only halves the gradient reaching the product, not the parameter grads. - torch.testing.assert_close(grad, expected_grads[name], atol=1e-5, rtol=1e-5) - - def test_both_rules_match_vjp_through_extra_forward_ops(self): - block, hf_mlp = _make_multiplier_bridge() - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - grads = {n: p.grad.clone() for n, p in hf_mlp.named_parameters()} - - expected_grad_x, expected_grads = _oracle_grads( - hf_mlp, x, activation_active=True, gate_active=True, multiplier=hf_mlp.multiplier - ) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) - for name, grad in grads.items(): - torch.testing.assert_close(grad, expected_grads[name], atol=1e-5, rtol=1e-5) - - @pytest.mark.parametrize("backing_class", BACKING_CLASSES) - def test_activation_only_leaves_gate_split_ordinary(self, backing_class): - block, hf_mlp = _make_bridge(backing_class) - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(activation=True)): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - expected_grad_x, _ = self._expected(hf_mlp, x, True, False) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) - - @pytest.mark.parametrize("backing_class", BACKING_CLASSES) - def test_multiplicative_gate_only_leaves_activation_ordinary(self, backing_class): - block, hf_mlp = _make_bridge(backing_class) - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(multiplicative_gate=True)): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - expected_grad_x, _ = self._expected(hf_mlp, x, False, True) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) - - def test_rule_inactive_gradients_are_ordinary(self): - block, hf_mlp = _make_bridge("nn.Linear") - x = torch.randn(3, 4, requires_grad=True) - - out = block(x) - out.sum().backward() - grad_x = x.grad.clone() - grads = {n: p.grad.clone() for n, p in hf_mlp.named_parameters()} - - for p in hf_mlp.parameters(): - p.grad = None - x_plain = x.detach().clone().requires_grad_(True) - hf_mlp(x_plain).sum().backward() - - torch.testing.assert_close(grad_x, x_plain.grad) - for name, param in hf_mlp.named_parameters(): - torch.testing.assert_close(grads[name], param.grad) - - def test_bias_free_projections_are_handled(self): - block, hf_mlp = _make_bridge("nn.Linear", bias=False) - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - expected_grad_x, expected_grads = self._expected(hf_mlp, x, True, True) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) - for name, param in hf_mlp.named_parameters(): - torch.testing.assert_close(param.grad, expected_grads[name], atol=1e-5, rtol=1e-5) - - @staticmethod - def _expected(hf_mlp, x, activation_active, gate_active): - return _oracle_grads(hf_mlp, x, activation_active, gate_active, multiplier=1.0) - - -class TestGatedMLPRelevanceRulePreservesRealForward: - def test_modules_own_backward_hook_fires_during_rule_active_backward(self): - # The rules attach to the live module and leave its native forward in the - # graph, so a backward hook registered on an HF submodule still fires during - # a rule-active backward rather than being bypassed by a separate graph. - block, hf_mlp = _make_bridge("nn.Linear") - fired = {"count": 0} - - def _record(module, grad_input, grad_output): - fired["count"] += 1 - - handle = hf_mlp.down_proj.register_full_backward_hook(_record) - x = torch.randn(3, 4, requires_grad=True) - try: - with use_relevance_rules( - block, RelevanceRules(activation=True, multiplicative_gate=True) - ): - block(x).sum().backward() - finally: - handle.remove() - - assert fired["count"] > 0 - - -class TestGatedMLPRelevanceRuleCompatibilityMode: - """The processed-weights (compatibility) path reconstructs the forward from folded - weights, so it applies the rules inline; before this it skipped them entirely.""" - - def _make_compat_bridge(self, bias: bool = True): - block, hf_mlp = _make_bridge("nn.Linear", bias=bias) - bridge = block.mlp - bridge._use_processed_weights = True - # Plain (non-Parameter) tensors so the bridge's custom __getattr__ resolves - # them; nn.Linear stores weight as [out, in], the layout functional linear - # expects, so the folded-weight forward reproduces the native projections. - bridge._processed_W_gate = hf_mlp.gate_proj.weight.detach() - bridge._processed_b_gate = None if not bias else hf_mlp.gate_proj.bias.detach() - bridge._processed_W_in = hf_mlp.up_proj.weight.detach() - bridge._processed_b_in = None if not bias else hf_mlp.up_proj.bias.detach() - bridge._processed_W_out = hf_mlp.down_proj.weight.detach() - bridge._processed_b_out = None if not bias else hf_mlp.down_proj.bias.detach() - return block, hf_mlp - - def test_compat_forward_identical_and_rule_vjp_matches_oracle(self): - block, hf_mlp = self._make_compat_bridge() - x = torch.randn(3, 4, requires_grad=True) - - baseline = block(x.detach()) - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - active = block(x) - assert torch.equal(active.detach(), baseline) - - active.sum().backward() - grad_x = x.grad.clone() - expected_grad_x, _ = _oracle_grads(hf_mlp, x, True, True) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) diff --git a/tests/unit/model_bridge/generalized_components/test_joint_gate_up_relevance_rule.py b/tests/unit/model_bridge/generalized_components/test_joint_gate_up_relevance_rule.py deleted file mode 100644 index f8bee5e693..0000000000 --- a/tests/unit/model_bridge/generalized_components/test_joint_gate_up_relevance_rule.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Identity-/Half-rule integration on JointGateUpMLPBridge's reconstructed forward. - -Unlike the raw GatedMLPBridge path (opaque native forward, tested separately), -JointGateUpMLPBridge already reconstructs its forward in Python as -``act_fn(gate_output) * up_output`` through separate gate/up LinearBridge -submodules, so the rules attach directly at that multiplication inline, off the -same boolean flags -- no live-submodule hook is installed here. This covers that -the reconstructed -forward is unaffected by an inactive rule, that both rules apply correctly -together and independently, and that gradients match the oracle produced by the -already-tested Identity-/Half-rule primitives directly. -""" - -import pytest -import torch -import torch.nn as nn -import torch.nn.functional as F - -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRules, - half_rule, - identity_rule, - use_relevance_rules, -) -from transformer_lens.model_bridge.generalized_components.joint_gate_up_mlp import ( - JointGateUpMLPBridge, -) -from transformer_lens.model_bridge.generalized_components.linear import LinearBridge - - -class _Cfg: - hidden_act = "silu" - - -class _TinyPhi3MLP(nn.Module): - """Mirrors Phi-3/GLM's fused gate_up_proj structure.""" - - def __init__(self, d_model: int = 4, d_mlp: int = 8, bias: bool = False): - super().__init__() - self.gate_up_proj = nn.Linear(d_model, 2 * d_mlp, bias=bias) - self.down_proj = nn.Linear(d_mlp, d_model, bias=bias) - self.activation_fn = nn.SiLU() - - def forward(self, x: torch.Tensor) -> torch.Tensor: - gate_up = self.gate_up_proj(x) - gate, up = gate_up.chunk(2, dim=-1) - return self.down_proj(self.activation_fn(gate) * up) - - -class _Block(nn.Module): - """Mounts a joint gate-up bridge at the canonical mlp position.""" - - def __init__(self, mlp: JointGateUpMLPBridge): - super().__init__() - self.mlp = mlp - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.mlp(x) - - -def _make_bridge(bias: bool = False) -> tuple[_Block, _TinyPhi3MLP]: - torch.manual_seed(0) - hf_mlp = _TinyPhi3MLP(bias=bias) - bridge = JointGateUpMLPBridge(name="mlp", config=_Cfg(), submodules={}) - out_bridge = LinearBridge(name="down_proj") - bridge.add_module("out", out_bridge) - bridge.set_original_component(hf_mlp) - out_bridge.set_original_component(hf_mlp.down_proj) - return _Block(bridge), hf_mlp - - -def _oracle_grads(hf_mlp, gate_proj, up_proj, x, activation_active: bool, gate_active: bool): - for p in hf_mlp.parameters(): - p.grad = None - x_oracle = x.detach().clone().requires_grad_(True) - gate_output = F.linear(x_oracle, gate_proj.weight, gate_proj.bias) - up_output = F.linear(x_oracle, up_proj.weight, up_proj.bias) - activated = identity_rule(gate_output, F.silu) if activation_active else F.silu(gate_output) - gated = half_rule(activated, up_output) if gate_active else activated * up_output - down = hf_mlp.down_proj(gated) - down.sum().backward() - return x_oracle.grad.clone() - - -class TestJointGateUpRelevanceRuleCapability: - def test_capable_of_both_kinds(self): - block, _ = _make_bridge() - assert set(block.mlp._relevance_rule_kinds) == {"activation", "multiplicative_gate"} - - -class TestJointGateUpRelevanceRuleForwardIdentity: - def test_forward_identical_while_rule_active(self): - block, _ = _make_bridge() - x = torch.randn(3, 4) - baseline = block(x) - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - active = block(x) - assert torch.equal(active, baseline) - - def test_forward_unchanged_when_rule_inactive(self): - block, _ = _make_bridge() - x = torch.randn(3, 4) - before = block(x) - after = block(x) - assert torch.equal(before, after) - - -class TestJointGateUpRelevanceRuleVJP: - @pytest.mark.parametrize( - ("activation_active", "gate_active"), - [(True, True), (True, False), (False, True)], - ) - def test_matches_manually_composed_oracle(self, activation_active, gate_active): - block, hf_mlp = _make_bridge() - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules( - block, - RelevanceRules( - activation=activation_active, - multiplicative_gate=gate_active, - ), - ): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - - gate_proj = block.mlp.gate.original_component - up_proj = getattr(block.mlp, "in").original_component - expected_grad_x = _oracle_grads( - hf_mlp, gate_proj, up_proj, x, activation_active, gate_active - ) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) - - def test_rule_inactive_gradients_are_ordinary(self): - block, hf_mlp = _make_bridge() - x = torch.randn(3, 4, requires_grad=True) - - out = block(x) - out.sum().backward() - grad_x = x.grad.clone() - - for p in hf_mlp.parameters(): - p.grad = None - x_plain = x.detach().clone().requires_grad_(True) - plain = hf_mlp(x_plain) - plain.sum().backward() - - torch.testing.assert_close(grad_x, x_plain.grad) - - def test_bias_free_and_biased_projections_are_both_handled(self): - block, hf_mlp = _make_bridge(bias=True) - x = torch.randn(3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(activation=True, multiplicative_gate=True)): - out = block(x) - out.sum().backward() - - grad_x = x.grad.clone() - gate_proj = block.mlp.gate.original_component - up_proj = getattr(block.mlp, "in").original_component - expected_grad_x = _oracle_grads(hf_mlp, gate_proj, up_proj, x, True, True) - torch.testing.assert_close(grad_x, expected_grad_x, atol=1e-5, rtol=1e-5) diff --git a/tests/unit/model_bridge/generalized_components/test_layernorm_weight_offset.py b/tests/unit/model_bridge/generalized_components/test_layernorm_weight_offset.py index 186d75553d..e6c2e77895 100644 --- a/tests/unit/model_bridge/generalized_components/test_layernorm_weight_offset.py +++ b/tests/unit/model_bridge/generalized_components/test_layernorm_weight_offset.py @@ -6,10 +6,6 @@ import torch from transformers.models.nemotron.modeling_nemotron import NemotronLayerNorm1P -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRules, - use_relevance_rules, -) from transformer_lens.model_bridge.generalized_components.normalization import ( NormalizationBridge, ) @@ -64,24 +60,3 @@ def test_python_layernorm1p_preserves_offset(): bridge, component = _make_norm(native=False) x = torch.randn(2, 4, 16) torch.testing.assert_close(bridge(x), component(x), atol=1e-6, rtol=1e-5) - - -def test_layernorm1p_relevance_vjp_uses_offset_and_preserves_parameter_gradients(): - bridge, component = _make_norm() - block = torch.nn.Module() - block.add_module("ln1", bridge) - x = torch.randn(2, 4, 16, requires_grad=True) - probe = torch.randn_like(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - actual = bridge(x) - actual_grads = torch.autograd.grad(actual, (x, component.weight, component.bias), probe) - assert torch.equal(actual, component(x)) - x_oracle = x.detach().clone().requires_grad_(True) - centered = x_oracle - x_oracle.mean(-1, keepdim=True) - denom = (centered.square().mean(-1, keepdim=True) + component.eps).sqrt().detach() - expected = centered / denom * (1 + component.weight) + component.bias - expected_grads = torch.autograd.grad( - expected, (x_oracle, component.weight, component.bias), probe - ) - for actual_grad, expected_grad in zip(actual_grads, expected_grads): - torch.testing.assert_close(actual_grad, expected_grad) diff --git a/tests/unit/model_bridge/generalized_components/test_normalization_relevance_rule.py b/tests/unit/model_bridge/generalized_components/test_normalization_relevance_rule.py deleted file mode 100644 index 9a3d394c31..0000000000 --- a/tests/unit/model_bridge/generalized_components/test_normalization_relevance_rule.py +++ /dev/null @@ -1,418 +0,0 @@ -"""LN-rule integration on NormalizationBridge's native-autograd path. - -The rule-wrapped native forward is bit-identical to -today's native forward by construction (the wrapping calls ``original_component(x)`` -itself rather than reproducing its numerics), the backward follows the LN-rule -(denominator treated as constant) while weight/bias keep their ordinary gradient, -targeting is positional so an ln1/ln2 mount that never reaches the native-autograd -branch reports as skipped rather than silently leaving ordinary gradients in place, -and a hook that would otherwise silently fall back instead raises while the rule is -active. -""" - - -import pytest -import torch -import torch.nn as nn -import torch.nn.functional as F - -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRuleConflictError, - RelevanceRules, - _RelevanceRuleCoverageEntry, - use_relevance_rules, -) -from transformer_lens.model_bridge.generalized_components.normalization import ( - LayerNormPreBridge, - NormalizationBridge, - RMSNormPreBridge, -) - - -class _Cfg: - def __init__( - self, - uses_rms_norm: bool = False, - eps: float = 1e-5, - rmsnorm_uses_offset: bool = False, - layer_norm_folding: bool = False, - ): - self.uses_rms_norm = uses_rms_norm - self.eps = eps - self.rmsnorm_uses_offset = rmsnorm_uses_offset - self.layer_norm_folding = layer_norm_folding - - -class _TinyRMSNorm(nn.Module): - """Minimal RMSNorm mirroring LlamaRMSNorm's forward.""" - - def __init__(self, d: int, eps: float = 1e-5): - super().__init__() - self.weight = nn.Parameter(torch.randn(d) * 0.1 + 1.0) - self.variance_epsilon = eps - - def forward(self, x: torch.Tensor) -> torch.Tensor: - variance = x.pow(2).mean(-1, keepdim=True) - return self.weight * x * torch.rsqrt(variance + self.variance_epsilon) - - -class _TinyGemmaRMSNorm(nn.Module): - """Minimal Gemma-style RMSNorm: weight is stored as an offset from 1.""" - - def __init__(self, d: int, eps: float = 1e-5): - super().__init__() - self.weight = nn.Parameter(torch.randn(d) * 0.1) - self.variance_epsilon = eps - - def forward(self, x: torch.Tensor) -> torch.Tensor: - variance = x.pow(2).mean(-1, keepdim=True) - x_normed = x * torch.rsqrt(variance + self.variance_epsilon) - return x_normed * (1.0 + self.weight) - - -class _TinyOlmoLayerNorm(nn.Module): - """Param-free centered LayerNorm mirroring OLMo's OlmoLayerNorm: no weight, no bias.""" - - def __init__(self, d: int, eps: float = 1e-5): - super().__init__() - self.normalized_shape = (d,) - self.eps = eps - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return F.layer_norm(x, self.normalized_shape, None, None, self.eps) - - -def _layernorm(d: int) -> nn.LayerNorm: - layer = nn.LayerNorm(d, eps=1e-5) - nn.init.normal_(layer.weight, std=0.1) - nn.init.normal_(layer.bias, std=0.1) - return layer - - -class _Block(nn.Module): - """Mounts a normalization bridge at the canonical ln1 position.""" - - def __init__(self, norm: NormalizationBridge): - super().__init__() - self.ln1 = norm - - -def _make_bridge( - native: bool, - rms: bool = False, - offset: bool = False, - d: int = 16, - layer_norm_folding: bool = False, -) -> NormalizationBridge: - layer: nn.Module - if offset: - layer = _TinyGemmaRMSNorm(d) - elif rms: - layer = _TinyRMSNorm(d) - else: - layer = _layernorm(d) - bridge = NormalizationBridge( - name="ln1", - config=_Cfg( - uses_rms_norm=rms or offset, - rmsnorm_uses_offset=offset, - layer_norm_folding=layer_norm_folding, - ), - use_native_layernorm_autograd=native, - ) - bridge.set_original_component(layer) - return bridge - - -def _denom_detached_oracle( - x: torch.Tensor, - weight: torch.Tensor, - bias: torch.Tensor | None, - uses_rms: bool, - offset: bool, - eps: float, -) -> torch.Tensor: - """Manual recompute with the denominator detached: the LN-rule's target VJP.""" - x_centered = x if uses_rms else x - x.mean(-1, keepdim=True) - denom = (x_centered.pow(2).mean(-1, keepdim=True) + eps).sqrt().detach() - w_eff = (1.0 + weight) if offset else weight - out = (x_centered / denom) * w_eff - if bias is not None: - out = out + bias - return out - - -class TestForwardIdentity: - @pytest.mark.parametrize("rms", [False, True], ids=["layernorm", "rmsnorm"]) - def test_active_forward_matches_inactive_forward(self, rms): - bridge = _make_bridge(native=True, rms=rms) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - baseline = bridge(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - active = bridge(x) - assert torch.equal(active, baseline) - - def test_active_forward_matches_original_component_directly(self): - bridge = _make_bridge(native=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - active = bridge(x) - assert torch.equal(active, bridge.original_component(x)) - - -class TestVJPMatchesDetachedDenomOracle: - @pytest.mark.parametrize("shared_offset", [False, True]) - def test_layernorm(self, shared_offset): - bridge = _make_bridge(native=True) - bridge.config.uses_rms_norm = shared_offset - bridge.config.rmsnorm_uses_offset = shared_offset - block = _Block(bridge) - x = torch.randn(2, 5, 16, requires_grad=True) - probe = torch.randn_like(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - y = bridge(x) - y.backward(probe) - grad_rule = x.grad.clone() - - x_oracle = x.detach().clone().requires_grad_(True) - y_oracle = _denom_detached_oracle( - x_oracle, - bridge.original_component.weight, - bridge.original_component.bias, - uses_rms=False, - offset=False, - eps=1e-5, - ) - y_oracle.backward(probe) - torch.testing.assert_close(grad_rule, x_oracle.grad) - - @pytest.mark.parametrize("offset", [False, True], ids=["plain_rms", "gemma_offset"]) - def test_rmsnorm(self, offset): - bridge = _make_bridge(native=True, rms=not offset, offset=offset) - block = _Block(bridge) - x = torch.randn(2, 5, 16, requires_grad=True) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - y = bridge(x) - y.sum().backward() - grad_rule = x.grad.clone() - - x_oracle = x.detach().clone().requires_grad_(True) - y_oracle = _denom_detached_oracle( - x_oracle, - bridge.original_component.weight, - None, - uses_rms=True, - offset=offset, - eps=1e-5, - ) - y_oracle.sum().backward() - torch.testing.assert_close(grad_rule, x_oracle.grad) - - def test_rule_grad_disagrees_with_ordinary_autodiff(self): - """The whole point of the rule: it must actually change the gradient.""" - bridge = _make_bridge(native=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(normalization=True)): - y_rule = bridge(x) - (grad_rule,) = torch.autograd.grad(y_rule.sum(), x) - - x_plain = x.detach().clone().requires_grad_(True) - y_plain = bridge(x_plain) - (grad_plain,) = torch.autograd.grad(y_plain.sum(), x_plain) - assert not torch.allclose(grad_rule, grad_plain) - - -class TestParameterGradientsPreserved: - def test_weight_and_bias_receive_ordinary_gradient(self): - bridge = _make_bridge(native=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16, requires_grad=True) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - y = bridge(x) - y.sum().backward() - weight_grad = bridge.original_component.weight.grad - bias_grad = bridge.original_component.bias.grad - assert weight_grad is not None and torch.isfinite(weight_grad).all() - assert bias_grad is not None and torch.isfinite(bias_grad).all() - - # Ordinary gradient: d(output)/d(weight) = normalized value (pre-weight), - # independent of the rule's treatment of the x-path; d(output)/d(bias) = 1. - x_oracle = x.detach().clone() - normalized = _denom_detached_oracle( - x_oracle, - torch.ones_like(bridge.original_component.weight), - None, - uses_rms=False, - offset=False, - eps=1e-5, - ) - reduce_dims = tuple(range(normalized.dim() - 1)) - expected_weight_grad = normalized.sum(dim=reduce_dims) - expected_bias_grad = torch.full_like(bias_grad, x.shape[0] * x.shape[1]) - torch.testing.assert_close(weight_grad, expected_weight_grad, atol=1e-4, rtol=1e-4) - torch.testing.assert_close(bias_grad, expected_bias_grad, atol=1e-4, rtol=1e-4) - - -class TestRuleInactiveRegression: - @pytest.mark.parametrize("native", [True, False], ids=["native_autograd", "python_norm"]) - def test_forward_byte_identical_to_ordinary_call(self, native): - bridge = _make_bridge(native=native) - x = torch.randn(2, 5, 16) - expected = bridge(x) - # No use_relevance_rules context at all: today's behavior, unconditionally. - actual = bridge(x) - assert torch.equal(actual, expected) - - def test_non_native_path_rule_request_is_skipped_and_forward_unaffected(self): - """A Python-path norm cannot claim that the LN-rule shaped its gradients.""" - bridge = _make_bridge(native=False) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - baseline = bridge(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert coverage.installed == () - assert coverage.skipped == (_RelevanceRuleCoverageEntry("normalization", "ln1"),) - active = bridge(x) - assert torch.equal(active, baseline) - - def test_layer_norm_folding_config_flag_makes_the_rule_installable(self): - """layer_norm_folding also dispatches through the native-autograd branch, - so the rule must be installable there even with use_native_layernorm_autograd - left False.""" - bridge = _make_bridge(native=False, layer_norm_folding=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - baseline = bridge(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert coverage.installed == (_RelevanceRuleCoverageEntry("normalization", "ln1"),) - active = bridge(x) - assert torch.equal(active, baseline) - - @pytest.mark.parametrize( - "bridge_cls", [LayerNormPreBridge, RMSNormPreBridge], ids=["ln_pre", "rms_pre"] - ) - def test_param_free_pre_norm_is_skipped_and_forward_unaffected(self, bridge_cls): - """LNPre/RMSPre always take the python-norm path regardless of the native - flag, so they must never be reported installed.""" - bridge = bridge_cls(name="ln1", config=_Cfg()) - bridge.set_original_component(nn.LayerNorm(16, eps=1e-5, elementwise_affine=False)) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - baseline = bridge(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert coverage.installed == () - assert coverage.skipped == (_RelevanceRuleCoverageEntry("normalization", "ln1"),) - active = bridge(x) - assert torch.equal(active, baseline) - - -class TestMissingWeightTreatedAsIdentity: - """A native-autograd bridge over a parameter-free norm (OLMo's OlmoLayerNorm has - no ``weight``) reports the LN-rule installed, so its rule-active forward and - backward must treat the missing weight as a unit scale instead of dereferencing - ``self.weight`` and raising AttributeError.""" - - def _make_param_free_bridge(self, d: int = 16) -> NormalizationBridge: - bridge = NormalizationBridge( - name="ln1", - config=_Cfg(uses_rms_norm=False), - use_native_layernorm_autograd=True, - ) - bridge.set_original_component(_TinyOlmoLayerNorm(d)) - return bridge - - def test_active_forward_matches_native_forward(self): - bridge = self._make_param_free_bridge() - block = _Block(bridge) - x = torch.randn(2, 5, 16) - baseline = bridge.original_component(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert coverage.installed == (_RelevanceRuleCoverageEntry("normalization", "ln1"),) - active = bridge(x) - assert torch.equal(active, baseline) - - def test_vjp_matches_detached_denom_oracle_with_unit_weight(self): - bridge = self._make_param_free_bridge() - block = _Block(bridge) - d = 16 - x = torch.randn(2, 5, d, requires_grad=True) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - y = bridge(x) - y.sum().backward() - grad_rule = x.grad.clone() - - x_oracle = x.detach().clone().requires_grad_(True) - y_oracle = _denom_detached_oracle( - x_oracle, - torch.ones(d), - None, - uses_rms=False, - offset=False, - eps=1e-5, - ) - y_oracle.sum().backward() - torch.testing.assert_close(grad_rule, x_oracle.grad) - - -class TestFailClosedHookPrecedence: - def test_bwd_hook_raises_while_rule_active(self): - bridge = _make_bridge(native=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16, requires_grad=True) - bridge.hook_normalized.add_hook(lambda g, hook=None: g, dir="bwd") - with pytest.raises(RelevanceRuleConflictError, match="Backward hooks"): - with use_relevance_rules(block, RelevanceRules(normalization=True)): - bridge(x) - bridge.hook_normalized.remove_hooks() - - def test_forward_edit_raises_while_rule_active(self): - bridge = _make_bridge(native=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - bridge.hook_scale.add_hook(lambda t, hook=None: t * 2.0) - with pytest.raises(RelevanceRuleConflictError, match="forward hook edited"): - with use_relevance_rules(block, RelevanceRules(normalization=True)): - bridge(x) - bridge.hook_scale.remove_hooks() - - def test_observation_only_forward_hook_does_not_raise_while_rule_active(self): - """A hook that only observes (returns None) is not an edit and must not - trip the fail-closed check.""" - bridge = _make_bridge(native=True) - block = _Block(bridge) - x = torch.randn(2, 5, 16) - baseline = bridge(x) - cache = {} - - def observe(tensor, hook=None): - cache["normalized"] = tensor.detach() - return None - - bridge.hook_normalized.add_hook(observe) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - active = bridge(x) - bridge.hook_normalized.remove_hooks() - assert torch.equal(active, baseline) - assert "normalized" in cache - - def test_bwd_hook_still_falls_back_with_warning_when_rule_inactive(self): - """Control: unchanged behavior when no rule is active (regression guard).""" - bridge = _make_bridge(native=True) - x = torch.randn(2, 5, 16, requires_grad=True) - bridge.hook_normalized.add_hook(lambda g, hook=None: g, dir="bwd") - with pytest.warns(UserWarning, match="Backward hooks"): - bridge(x) - bridge.hook_normalized.remove_hooks() - - def test_edit_still_falls_back_with_warning_when_rule_inactive(self): - """Control: unchanged behavior when no rule is active (regression guard).""" - bridge = _make_bridge(native=True) - x = torch.randn(2, 5, 16) - bridge.hook_scale.add_hook(lambda t, hook=None: t * 2.0) - with pytest.warns(UserWarning, match="reconstructed from the hooked values"): - bridge(x) - bridge.hook_scale.remove_hooks() diff --git a/tests/unit/model_bridge/supported_architectures/test_qwen3_5_adapter.py b/tests/unit/model_bridge/supported_architectures/test_qwen3_5_adapter.py index 23b64fa5ad..ed67758a31 100644 --- a/tests/unit/model_bridge/supported_architectures/test_qwen3_5_adapter.py +++ b/tests/unit/model_bridge/supported_architectures/test_qwen3_5_adapter.py @@ -725,25 +725,6 @@ def test_backward_hook_preserves_forward_logits(self, bridge): error = identity_hook_logit_error(bridge, "ln_final.hook_scale", backward=True) assert error < 1e-5 - def test_ln_rule_backward_scales_by_offset_weight(self, bridge): - """The LN-rule VJP of an RMSNorm is (1 + weight) / denom, with denom held constant.""" - import torch - - from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRules, - use_relevance_rules, - ) - - ln1 = bridge.blocks[0].ln1 - hf_norm = ln1.original_component - x = torch.randn(1, 4, 128, generator=torch.Generator().manual_seed(0), requires_grad=True) - with use_relevance_rules(bridge, RelevanceRules(normalization=True)): - ln1(x).sum().backward() - - denom = (x.detach().pow(2).mean(-1, keepdim=True) + hf_norm.eps).sqrt() - expected = ((1.0 + hf_norm.weight.detach()) / denom).expand_as(x) - torch.testing.assert_close(x.grad, expected) - @pytest.mark.skipif( not _QWEN3_5_AVAILABLE, diff --git a/tests/unit/model_bridge/supported_architectures/test_relevance_rule_mount_placeholders.py b/tests/unit/model_bridge/supported_architectures/test_relevance_rule_mount_placeholders.py deleted file mode 100644 index 459d32bdd2..0000000000 --- a/tests/unit/model_bridge/supported_architectures/test_relevance_rule_mount_placeholders.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Relevance-rule coverage classification for architectures that mount q/k/v-norm -or sandwich-norm placeholders instead of a rule-capable ln1/ln2. - -``use_relevance_rules`` targets the "normalization" rule kind only at the ln1/ln2 -mount name (see ``_CANONICAL_MOUNTS`` in ``transformer_lens.model_bridge._relevance_rules``), -matched on the last segment of a live module's registered name. These tests inspect -each adapter's declared ``component_mapping`` directly (no HF weights, no live module -wiring) to lock in the structural facts that make the mechanism's "not applicable" -behavior correct for these three documented edge cases, without needing to build a -working forward-capable module tree per adapter: - -- Gemma 3n and Gemma 4: ln1/ln2 exist (sandwich norms) but are plain - ``GeneralizedComponent`` placeholders, not ``NormalizationBridge`` -- - rule-incapable even though the mount name matches. Gemma 3n's per-head q/k/v - norms are keyed under "self_attn", never "ln1"/"ln2". -- StableLM: ln1/ln2 ARE real ``NormalizationBridge`` instances (a genuine rule - target), but the per-head norms are keyed "q_norm"/"k_norm" under "attn" even - though the wrapped HF module is named "q_layernorm"/"k_layernorm" -- the mount - key, not the wrapped module's own name, is what the canonical-mount check reads. -""" - -from typing import Any - -from tests.unit.model_bridge.supported_architectures.helpers import make_bridge_cfg -from transformer_lens.model_bridge._relevance_rules import _RelevanceRuleCapable -from transformer_lens.model_bridge.generalized_components import NormalizationBridge -from transformer_lens.model_bridge.generalized_components.base import ( - GeneralizedComponent, -) -from transformer_lens.model_bridge.supported_architectures.gemma3n import ( - Gemma3nArchitectureAdapter, -) -from transformer_lens.model_bridge.supported_architectures.gemma4 import ( - Gemma4ArchitectureAdapter, -) -from transformer_lens.model_bridge.supported_architectures.stablelm import ( - StableLmArchitectureAdapter, -) - - -def _block_submodules(adapter: Any) -> dict: - return dict(adapter.component_mapping["blocks"].submodules) - - -def _attn_submodules(block_submodules: dict, attn_key: str) -> dict: - return dict(block_submodules[attn_key].submodules) - - -class TestGemma3nPlaceholders: - def _adapter(self) -> Gemma3nArchitectureAdapter: - cfg = make_bridge_cfg("Gemma3nForConditionalGeneration", n_key_value_heads=2, d_head=8) - return Gemma3nArchitectureAdapter(cfg) - - def test_sandwich_norms_are_plain_placeholders_not_normalization_bridge(self): - block_submodules = _block_submodules(self._adapter()) - for key in ("ln1", "ln1_post", "ln2", "ln2_post"): - component = block_submodules[key] - assert type(component) is GeneralizedComponent - assert not isinstance(component, NormalizationBridge) - assert not isinstance(component, _RelevanceRuleCapable) - - def test_qkv_norm_placeholders_are_plain_and_rule_incapable(self): - block_submodules = _block_submodules(self._adapter()) - attn_submodules = _attn_submodules(block_submodules, "self_attn") - for key in ("q_norm", "k_norm", "v_norm"): - component = attn_submodules[key] - assert type(component) is GeneralizedComponent - assert not isinstance(component, NormalizationBridge) - assert not isinstance(component, _RelevanceRuleCapable) - - -class TestGemma4Placeholders: - def _adapter(self) -> Gemma4ArchitectureAdapter: - from types import SimpleNamespace - - cfg = make_bridge_cfg("Gemma4ForConditionalGeneration", n_key_value_heads=1, d_head=8) - cfg.vision_config = SimpleNamespace( - hidden_size=32, num_hidden_layers=2, num_attention_heads=4 - ) - cfg.vision_soft_tokens_per_image = 4 - return Gemma4ArchitectureAdapter(cfg) - - def test_ln1_ln2_are_plain_sandwich_placeholders_not_normalization_bridge(self): - block_submodules = _block_submodules(self._adapter()) - for key in ("ln1", "ln2"): - component = block_submodules[key] - assert type(component) is GeneralizedComponent - assert not isinstance(component, NormalizationBridge) - assert not isinstance(component, _RelevanceRuleCapable) - - def test_qkv_norm_placeholders_are_not_keyed_ln1_or_ln2(self): - block_submodules = _block_submodules(self._adapter()) - attn_submodules = _attn_submodules(block_submodules, "attn") - for key in ("q_norm", "k_norm", "v_norm"): - assert key not in ("ln1", "ln2") - component = attn_submodules[key] - assert type(component) is GeneralizedComponent - assert not isinstance(component, _RelevanceRuleCapable) - - -class TestStableLmPlaceholders: - def _adapter(self) -> StableLmArchitectureAdapter: - cfg = make_bridge_cfg("StableLmForCausalLM", n_key_value_heads=2, d_head=8) - return StableLmArchitectureAdapter(cfg) - - def test_ln1_and_ln2_are_genuine_rule_targets(self): - """Unlike Gemma 3n/4, StableLM's block-level norms ARE rule-capable -- - this is the genuine positive case the other two are contrasted against.""" - block_submodules = _block_submodules(self._adapter()) - for key in ("ln1", "ln2"): - component = block_submodules[key] - assert isinstance(component, NormalizationBridge) - assert isinstance(component, _RelevanceRuleCapable) - assert component._relevance_rule_kinds == ("normalization",) - - def test_per_head_norms_are_keyed_q_norm_k_norm_not_ln1_ln2(self): - """The wrapped HF module is named q_layernorm/k_layernorm, but the mount - KEY the canonical-mount check reads is q_norm/k_norm -- distinct either - way from ln1/ln2, so these stay invisible to the "normalization" rule - regardless of which naming convention the underlying HF module uses.""" - block_submodules = _block_submodules(self._adapter()) - attn_submodules = _attn_submodules(block_submodules, "attn") - assert attn_submodules["q_norm"].name == "q_layernorm" - assert attn_submodules["k_norm"].name == "k_layernorm" - for key in ("q_norm", "k_norm"): - component = attn_submodules[key] - assert key not in ("ln1", "ln2") - assert type(component) is GeneralizedComponent - assert not isinstance(component, _RelevanceRuleCapable) diff --git a/tests/unit/model_bridge/test_relevance_rules.py b/tests/unit/model_bridge/test_relevance_rules.py deleted file mode 100644 index f7403bb626..0000000000 --- a/tests/unit/model_bridge/test_relevance_rules.py +++ /dev/null @@ -1,400 +0,0 @@ -"""Scoped relevance rules: kind-aware coverage, positional targeting, cleanup, and nesting.""" - -import dataclasses - -import pytest -import torch -import torch.nn as nn - -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRuleCoverage, - RelevanceRules, - RelevanceRuleUnsupportedError, - _RelevanceRuleCoverageEntry, - ln_rule, - use_relevance_rules, -) - - -def _normalization_entries(*paths): - return tuple(_RelevanceRuleCoverageEntry(kind="normalization", path=path) for path in paths) - - -class _FakeNormComponent(nn.Module): - """A minimal ln1/ln2-style target: installs the real LN-rule primitive on request.""" - - _relevance_rule_kinds = ("normalization",) - - def __init__(self, eps: float = 1e-2): - super().__init__() - # Composing this division with a plain second normalization and a linear - # readout makes the LN-rule's denom-as-constant correction shrink linearly - # with eps, so a realistic normalization epsilon (1e-6) would leave the - # correction too small for torch.allclose to detect reliably end to end. - self.eps = eps - self._rule_active = False - - def _enable_relevance_rule(self, kind: str) -> None: - self._rule_active = True - - def _disable_relevance_rule(self, kind: str) -> None: - self._rule_active = False - - def forward(self, x: torch.Tensor) -> torch.Tensor: - denom = x.abs().mean(dim=-1, keepdim=True) + self.eps - if self._rule_active: - return ln_rule(x, denom) - return x / denom - - -class _PlainMount(nn.Module): - """Occupies a targeted mount name but implements no relevance-rule protocol.""" - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return x - - -class _TinyBlock(nn.Module): - """Mimics a block's ln1/ln2 mount points plus a same-class q_norm and an MLP. - - ``q_norm`` uses the identical fake-normalization class as ``ln1``/``ln2`` so tests - can assert that targeting is positional (by mount name) rather than class-based. - """ - - def __init__(self): - super().__init__() - self.ln1: nn.Module = _FakeNormComponent() - self.q_norm = _FakeNormComponent() - self.ln2: nn.Module = _FakeNormComponent() - self.mlp = nn.Linear(4, 4) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - x = self.ln1(x) - x = self.q_norm(x) - x = self.ln2(x) - return self.mlp(x) - - -def _tiny_block() -> _TinyBlock: - block = _TinyBlock() - with torch.no_grad(): - block.mlp.weight.copy_(torch.eye(4) * 0.5) - block.mlp.bias.zero_() - return block - - -class _SandwichBlock(nn.Module): - """Mimics a sandwich-norm block: pre-norms ln1/ln2 plus post-norms ln1_post/ln2_post. - - Sandwich-norm architectures mount a second normalization after attention and after - the MLP at ``ln1_post``/``ln2_post``, so the LN-rule must reach those mounts as well - as the pre-norms rather than leaving them silently out of coverage. - """ - - def __init__(self): - super().__init__() - self.ln1: nn.Module = _FakeNormComponent() - self.ln1_post: nn.Module = _FakeNormComponent() - self.ln2: nn.Module = _FakeNormComponent() - self.ln2_post: nn.Module = _FakeNormComponent() - self.mlp = nn.Linear(4, 4) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - x = self.ln1(x) - x = self.ln1_post(x) - x = self.ln2(x) - x = self.ln2_post(x) - return self.mlp(x) - - -class _FakeGatedMLPComponent(nn.Module): - """A minimal mlp-mount target that answers to two rule kinds independently. - - A real gated-MLP node answers to both "activation" (Identity-rule on its - activation function) and "multiplicative_gate" (Half-rule on its gate*up - product) at the same mount, and either can be requested without the other, - so this fixture tracks the two kinds as separate booleans rather than one. - """ - - _relevance_rule_kinds = ("activation", "multiplicative_gate") - - def __init__(self): - super().__init__() - self._activation_rule_active = False - self._gate_rule_active = False - - def _enable_relevance_rule(self, kind: str) -> None: - if kind == "activation": - self._activation_rule_active = True - elif kind == "multiplicative_gate": - self._gate_rule_active = True - - def _disable_relevance_rule(self, kind: str) -> None: - if kind == "activation": - self._activation_rule_active = False - elif kind == "multiplicative_gate": - self._gate_rule_active = False - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return x - - -class _Boom(Exception): - """Marker exception raised inside a context to test cleanup on failure.""" - - -def test_relevance_rules_defaults_to_no_rules(): - rules = RelevanceRules() - assert rules.normalization is False - assert rules.activation is False - assert rules.multiplicative_gate is False - assert rules.attention is False - - -def test_relevance_rules_is_frozen(): - rules = RelevanceRules(normalization=True) - with pytest.raises(dataclasses.FrozenInstanceError): - rules.normalization = False # type: ignore[misc] - - -def test_forward_is_identical_while_rule_active(): - block = _tiny_block() - x = torch.randn(2, 3, 4) - baseline = block(x) - with use_relevance_rules(block, RelevanceRules(normalization=True)): - active = block(x) - assert torch.equal(active, baseline) - - -def test_positional_targeting_excludes_same_class_component_at_other_mount(): - block = _tiny_block() - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert block.ln1._rule_active is True - assert block.ln2._rule_active is True - assert block.q_norm._rule_active is False - assert coverage.installed == _normalization_entries("ln1", "ln2") - assert block.ln1._rule_active is False - assert block.ln2._rule_active is False - - -def test_normalization_rule_installs_on_sandwich_post_norm_mounts(): - block = _SandwichBlock() - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert block.ln1._rule_active is True - assert block.ln1_post._rule_active is True - assert block.ln2._rule_active is True - assert block.ln2_post._rule_active is True - assert coverage.installed == _normalization_entries("ln1", "ln1_post", "ln2", "ln2_post") - assert coverage.skipped == () - assert block.ln1_post._rule_active is False - assert block.ln2_post._rule_active is False - - -def test_sandwich_post_norm_on_python_path_is_reported_skipped_never_absent(): - block = _SandwichBlock() - # A post-norm mount whose occupant takes the python-norm path (no rule protocol) - # must surface as skipped, never vanish from coverage as it did before the mount - # names were recognized. - block.ln2_post = _PlainMount() - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert coverage.installed == _normalization_entries("ln1", "ln1_post", "ln2") - assert coverage.skipped == _normalization_entries("ln2_post") - - -def test_requesting_a_kind_with_no_canonical_mount_raises(): - block = _tiny_block() - # "attention" is a valid RelevanceRules field but has no canonical mount, so the - # request must raise rather than install nothing and return empty coverage. - with pytest.raises(ValueError, match="attention"): - with use_relevance_rules(block, RelevanceRules(attention=True)): - pass - - -def test_unsupported_component_at_targeted_mount_is_skipped(): - block = _tiny_block() - block.ln2 = _PlainMount() - with use_relevance_rules(block, RelevanceRules(normalization=True)) as coverage: - assert isinstance(coverage, RelevanceRuleCoverage) - assert coverage.installed == _normalization_entries("ln1") - assert coverage.skipped == _normalization_entries("ln2") - - -def test_requesting_unsupported_kind_on_capable_component_raises(): - block = _tiny_block() - block.mlp = _FakeGatedMLPComponent() - # This mount implements the protocol and currently only supports "activation", - # but -- unlike a mount that never deals with "multiplicative_gate" at all -- - # names it in _relevance_rule_unsupported_kinds as one it is expected to honor - # here and currently cannot, so requesting it raises instead of being skipped. - block.mlp._relevance_rule_kinds = ("activation",) - block.mlp._relevance_rule_unsupported_kinds = ("multiplicative_gate",) - with pytest.raises(RelevanceRuleUnsupportedError, match="mlp"): - with use_relevance_rules(block, RelevanceRules(multiplicative_gate=True)): - pass - assert block.mlp._gate_rule_active is False - - -def test_no_rules_requested_installs_nothing(): - block = _tiny_block() - with use_relevance_rules(block, RelevanceRules()) as coverage: - assert coverage.installed == () - assert coverage.skipped == () - assert block.ln1._rule_active is False - assert block.ln2._rule_active is False - - -def test_ordinary_gradients_restored_after_exit(): - block = _tiny_block() - x = torch.randn(2, 3, 4, requires_grad=True) - - with use_relevance_rules(block, RelevanceRules(normalization=True)): - y_rule = block(x) - (grad_rule,) = torch.autograd.grad(y_rule.sum(), x) - - assert block.ln1._rule_active is False - assert block.ln2._rule_active is False - - x_plain = x.detach().clone().requires_grad_(True) - y_plain = block(x_plain) - (grad_plain,) = torch.autograd.grad(y_plain.sum(), x_plain) - - # The LN-rule treats the denominator as constant, so its VJP differs from - # ordinary autodiff through the same division wherever the denominator - # actually depends on the input -- true for every row of this fixture. - assert not torch.allclose(grad_rule, grad_plain) - - # A second plain pass confirms the exit left no residual rule state: it must - # reproduce grad_plain exactly rather than drifting toward grad_rule. - x_plain_again = x.detach().clone().requires_grad_(True) - y_plain_again = block(x_plain_again) - (grad_plain_again,) = torch.autograd.grad(y_plain_again.sum(), x_plain_again) - torch.testing.assert_close(grad_plain_again, grad_plain) - - -def test_nested_contexts_restore_outer_state_on_inner_exit(): - block = _tiny_block() - x = torch.randn(2, 3, 4) - baseline = block(x) - - with use_relevance_rules(block, RelevanceRules(normalization=True)) as outer_coverage: - assert block.ln1._rule_active is True - with use_relevance_rules(block, RelevanceRules(normalization=True)) as inner_coverage: - assert block.ln1._rule_active is True - assert torch.equal(block(x), baseline) - # The inner exit must not disable the rule the outer context still needs. - assert block.ln1._rule_active is True - assert block.ln2._rule_active is True - assert torch.equal(block(x), baseline) - - assert block.ln1._rule_active is False - assert block.ln2._rule_active is False - assert outer_coverage.installed == _normalization_entries("ln1", "ln2") - assert inner_coverage.installed == _normalization_entries("ln1", "ln2") - - -def test_exception_inside_context_leaves_no_rule_state(): - block = _tiny_block() - x = torch.randn(2, 3, 4) - baseline = block(x) - - with pytest.raises(_Boom): - with use_relevance_rules(block, RelevanceRules(normalization=True)): - assert block.ln1._rule_active is True - raise _Boom("failure inside the scoped context") - - assert block.ln1._rule_active is False - assert block.ln2._rule_active is False - assert torch.equal(block(x), baseline) - - -def test_exception_during_nested_context_restores_outer_state(): - block = _tiny_block() - x = torch.randn(2, 3, 4) - baseline = block(x) - - with use_relevance_rules(block, RelevanceRules(normalization=True)): - with pytest.raises(_Boom): - with use_relevance_rules(block, RelevanceRules(normalization=True)): - raise _Boom("failure inside the nested scoped context") - # The outer context is still active after the inner one unwinds. - assert block.ln1._rule_active is True - assert torch.equal(block(x), baseline) - - assert block.ln1._rule_active is False - assert block.ln2._rule_active is False - - -@pytest.mark.parametrize("kind", ["activation", "multiplicative_gate"]) -def test_one_kind_requested_on_a_two_kind_mount_leaves_the_other_kind_inactive(kind): - block = _tiny_block() - block.mlp = _FakeGatedMLPComponent() - with use_relevance_rules(block, RelevanceRules(**{kind: True})) as coverage: - assert block.mlp._gate_rule_active is (kind == "multiplicative_gate") - assert block.mlp._activation_rule_active is (kind == "activation") - assert [(entry.kind, entry.path) for entry in coverage.installed] == [(kind, "mlp")] - assert coverage.skipped == () - assert block.mlp._gate_rule_active is False - assert block.mlp._activation_rule_active is False - - -@pytest.mark.parametrize("supported_kind", ["activation", "multiplicative_gate"]) -def test_kind_coverage_distinguishes_installed_and_skipped_at_the_same_path(supported_kind): - block = _tiny_block() - block.mlp = _FakeGatedMLPComponent() - block.mlp._relevance_rule_kinds = (supported_kind,) - other_kind = "multiplicative_gate" if supported_kind == "activation" else "activation" - with use_relevance_rules( - block, RelevanceRules(activation=True, multiplicative_gate=True) - ) as coverage: - assert [(entry.kind, entry.path) for entry in coverage.installed] == [ - (supported_kind, "mlp") - ] - assert [(entry.kind, entry.path) for entry in coverage.skipped] == [(other_kind, "mlp")] - - -def test_aliases_do_not_duplicate_kind_coverage(): - block = _tiny_block() - block.mlp = _FakeGatedMLPComponent() - block.original = nn.Module() - block.original.mlp = block.mlp - with use_relevance_rules( - block, RelevanceRules(activation=True, multiplicative_gate=True) - ) as coverage: - assert [(entry.kind, entry.path) for entry in coverage.installed] == [ - ("activation", "mlp"), - ("multiplicative_gate", "mlp"), - ] - assert coverage.skipped == () - with pytest.raises(dataclasses.FrozenInstanceError): - setattr(coverage.installed[0], "kind", "normalization") - - -def test_both_kinds_requested_together_both_activate_on_the_same_mount(): - block = _tiny_block() - block.mlp = _FakeGatedMLPComponent() - with use_relevance_rules( - block, RelevanceRules(activation=True, multiplicative_gate=True) - ) as coverage: - assert block.mlp._activation_rule_active is True - assert block.mlp._gate_rule_active is True - assert [(entry.kind, entry.path) for entry in coverage.installed] == [ - ("activation", "mlp"), - ("multiplicative_gate", "mlp"), - ] - assert block.mlp._activation_rule_active is False - assert block.mlp._gate_rule_active is False - - -def test_nested_scopes_over_different_kinds_on_one_mount_refcount_independently(): - block = _tiny_block() - block.mlp = _FakeGatedMLPComponent() - with use_relevance_rules(block, RelevanceRules(activation=True)): - assert block.mlp._activation_rule_active is True - with use_relevance_rules(block, RelevanceRules(multiplicative_gate=True)): - assert block.mlp._activation_rule_active is True - assert block.mlp._gate_rule_active is True - # The inner (multiplicative_gate-only) scope's exit must not disable - # the outer scope's independently refcounted activation rule. - assert block.mlp._activation_rule_active is True - assert block.mlp._gate_rule_active is False - assert block.mlp._activation_rule_active is False diff --git a/tests/unit/tools/test_jacobian_lens_causal_swap_benchmark.py b/tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark.py similarity index 80% rename from tests/unit/tools/test_jacobian_lens_causal_swap_benchmark.py rename to tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark.py index 46d2e67a76..576fb8d9ca 100644 --- a/tests/unit/tools/test_jacobian_lens_causal_swap_benchmark.py +++ b/tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark.py @@ -12,7 +12,7 @@ import pytest import torch -from transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark import ( +from scripts.jacobian_lens_causal_swap_benchmark import ( SCHEMA_VERSION, AnswerMetrics, BaselineRecord, @@ -27,12 +27,11 @@ filter_baseline_capable, fingerprint_manifest, iter_prompt_trials, - load_artifact, results_fingerprint, select_displacement_matched_control_token, serialize_artifact, - success_rate_ci, ) +from transformer_lens.utilities import success_rate_ci def test_iter_prompt_trials_yields_ordered_pairs_per_function() -> None: @@ -305,60 +304,6 @@ def test_select_displacement_matched_control_token_is_device_independent() -> No assert device_choice == cpu_choice -def test_success_rate_ci_bounds_bracket_point_estimate_and_lie_in_unit_interval() -> None: - successes = [True, True, False, True, False, True, True, False] - result = success_rate_ci(successes) - assert result.ci_low <= result.point_estimate <= result.ci_high - assert 0.0 <= result.ci_low and result.ci_high <= 1.0 - assert result.point_estimate == pytest.approx(sum(successes) / len(successes)) - - -def test_success_rate_ci_pins_zero_of_six_upper_bound() -> None: - # The exact interval must widen for an all-failure sample. A percentile bootstrap returns - # [0, 0] here by construction, asserting a certainty the data does not support. - result = success_rate_ci([False] * 6) - assert result.ci_low == 0.0 - assert result.ci_high == pytest.approx(0.4592581, abs=1e-6) - - -def test_success_rate_ci_pins_all_success_lower_bound() -> None: - result = success_rate_ci([True] * 6) - assert result.ci_high == 1.0 - assert result.ci_low == pytest.approx(0.5407419, abs=1e-6) - - -def test_success_rate_ci_matches_closed_form_at_the_boundaries() -> None: - # At k=0 and k=n the Clopper-Pearson bounds reduce to closed forms, which pins the - # bisection direction independently of the interior values. Reversing it collapses every - # interval to [0, 1]. - assert success_rate_ci([False] * 6).ci_high == pytest.approx(1 - 0.025 ** (1 / 6)) - assert success_rate_ci([True] * 6).ci_low == pytest.approx(0.025 ** (1 / 6)) - - -def test_success_rate_ci_narrows_as_the_trial_count_grows() -> None: - # The whole point of the change: the same observed rate carries less uncertainty with - # more trials. A degenerate interval would not move. - small = success_rate_ci([False] * 6) - large = success_rate_ci([False] * 600) - assert large.ci_high < small.ci_high - - -def test_success_rate_ci_records_trial_and_success_counts() -> None: - result = success_rate_ci([True, False, True]) - assert result.n_trials == 3 - assert result.n_successes == 2 - - -def test_success_rate_ci_is_deterministic() -> None: - successes = [True, False, True] - assert success_rate_ci(successes) == success_rate_ci(successes) - - -def test_success_rate_ci_rejects_empty_input() -> None: - with pytest.raises(ValueError, match="empty"): - success_rate_ci([]) - - def test_fingerprint_manifest_matches_the_notebook_recipe() -> None: manifest = {"b": 2, "a": 1} expected = hashlib.sha256( @@ -463,21 +408,22 @@ def _skipped_trial(*, layer: int) -> TrialResult: ) -def test_serialize_then_load_artifact_round_trips(tmp_path) -> None: +def test_serialized_fingerprints_reproduce_after_a_json_round_trip() -> None: + # The demo notebook recomputes both fingerprints from the reloaded JSON, so they must + # survive serialization, not just match the in-memory artifact. manifest = build_protocol_manifest(**_full_manifest_fields()) - trials: List[TrialResult] = [] + trials: List[TrialResult] = [_ok_trial(target="France", layer=9)] excluded: List[BaselineRecord] = [] real_ci = success_rate_ci([True, False]) control_ci = success_rate_ci([False, False]) artifact = serialize_artifact(manifest, trials, excluded, real_ci, control_ci) - path = tmp_path / "artifact.json" - path.write_text(json.dumps(artifact)) - loaded = load_artifact(path) - assert loaded["protocol_fingerprint"] == artifact["protocol_fingerprint"] + loaded = json.loads(json.dumps(artifact, indent=2, sort_keys=True)) assert loaded["schema_version"] == SCHEMA_VERSION + assert fingerprint_manifest(loaded["protocol_manifest"]) == loaded["protocol_fingerprint"] + assert results_fingerprint(loaded) == loaded["results_fingerprint"] -def test_serialize_artifact_round_trips_trial_and_baseline_records(tmp_path) -> None: +def test_serialize_artifact_round_trips_trial_and_baseline_records() -> None: manifest = build_protocol_manifest(**_full_manifest_fields(layers_swept=[6])) trial = TrialResult( function="capital", @@ -500,26 +446,21 @@ def test_serialize_artifact_round_trips_trial_and_baseline_records(tmp_path) -> real_ci = success_rate_ci([True]) control_ci = success_rate_ci([False]) artifact = serialize_artifact(manifest, [trial], excluded, real_ci, control_ci) - path = tmp_path / "artifact.json" - path.write_text(json.dumps(artifact)) - loaded = load_artifact(path) + loaded = json.loads(json.dumps(artifact)) assert loaded["trials"] == [dataclasses.asdict(trial)] assert loaded["excluded_baselines"] == [dataclasses.asdict(excluded[0])] -def test_load_artifact_rejects_tampered_fingerprint(tmp_path) -> None: +def test_protocol_fingerprint_detects_a_tampered_manifest() -> None: manifest = build_protocol_manifest(**_full_manifest_fields(layers_swept=[1])) artifact = serialize_artifact( manifest, [], [], success_rate_ci([True]), success_rate_ci([False]) ) artifact["protocol_manifest"]["layers_swept"] = [2] - path = tmp_path / "bad.json" - path.write_text(json.dumps(artifact)) - with pytest.raises(ValueError, match="fingerprint"): - load_artifact(path) + assert fingerprint_manifest(artifact["protocol_manifest"]) != artifact["protocol_fingerprint"] -def test_load_artifact_rejects_tampered_trials(tmp_path) -> None: +def test_results_fingerprint_detects_tampered_trials() -> None: manifest = build_protocol_manifest(**_full_manifest_fields()) artifact = serialize_artifact( manifest, @@ -529,22 +470,16 @@ def test_load_artifact_rejects_tampered_trials(tmp_path) -> None: success_rate_ci([False]), ) artifact["trials"][0]["status"] = "skipped_source_inactive" - path = tmp_path / "tampered_trials.json" - path.write_text(json.dumps(artifact)) - with pytest.raises(ValueError, match="results_fingerprint"): - load_artifact(path) + assert results_fingerprint(artifact) != artifact["results_fingerprint"] -def test_load_artifact_rejects_tampered_ci_block(tmp_path) -> None: +def test_results_fingerprint_detects_a_tampered_ci_block() -> None: manifest = build_protocol_manifest(**_full_manifest_fields()) artifact = serialize_artifact( manifest, [], [], success_rate_ci([True, False]), success_rate_ci([False, False]) ) artifact["real_success_ci"]["n_trials"] = 99 - path = tmp_path / "tampered_ci.json" - path.write_text(json.dumps(artifact)) - with pytest.raises(ValueError, match="results_fingerprint"): - load_artifact(path) + assert results_fingerprint(artifact) != artifact["results_fingerprint"] def test_results_fingerprint_is_stable_across_key_order() -> None: @@ -575,31 +510,6 @@ def test_serialize_artifact_records_executed_layers_and_status_counts() -> None: assert artifact["trial_status_counts"] == {"ok": 2, "skipped_source_inactive": 1} -def test_load_artifact_rejects_wrong_schema_version(tmp_path) -> None: - manifest = build_protocol_manifest(**_full_manifest_fields()) - artifact = serialize_artifact( - manifest, [], [], success_rate_ci([True]), success_rate_ci([False]) - ) - artifact["schema_version"] = SCHEMA_VERSION + 1 - artifact["protocol_fingerprint"] = fingerprint_manifest(artifact["protocol_manifest"]) - path = tmp_path / "bad_version.json" - path.write_text(json.dumps(artifact)) - with pytest.raises(ValueError, match="schema_version"): - load_artifact(path) - - -def test_load_artifact_rejects_missing_required_key(tmp_path) -> None: - manifest = build_protocol_manifest(**_full_manifest_fields()) - artifact = serialize_artifact( - manifest, [], [], success_rate_ci([True]), success_rate_ci([False]) - ) - del artifact["excluded_baselines"] - path = tmp_path / "bad_missing.json" - path.write_text(json.dumps(artifact)) - with pytest.raises(ValueError, match="excluded_baselines"): - load_artifact(path) - - def test_serialize_artifact_counts_independent_prompts(tmp_path) -> None: # Several trials can share one prompt, so the pooled rate rests on fewer independent # prompts than trials. The count must travel with the rate. diff --git a/tests/unit/tools/test_jacobian_lens_causal_swap_benchmark_cli.py b/tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark_cli.py similarity index 51% rename from tests/unit/tools/test_jacobian_lens_causal_swap_benchmark_cli.py rename to tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark_cli.py index 8e822f8e30..705612fb5d 100644 --- a/tests/unit/tools/test_jacobian_lens_causal_swap_benchmark_cli.py +++ b/tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark_cli.py @@ -1,44 +1,24 @@ -"""Unit tests for the causal-swap benchmark's artifact-generation CLI. +"""Unit tests for the causal-swap benchmark script's argument parser. -Model-free: these pin the double-execution fix and the argument parser, so no model is -loaded. +Model-free: only the parser and seed-list parsing run, so no model is loaded. """ -import subprocess -import sys - import pytest -from transformer_lens.tools.analysis.jacobian_lens import DEFAULT_K -from transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark_cli import ( +from scripts.jacobian_lens_causal_swap_benchmark import ( _DEFAULT_ARTIFACT_PATH, _DEFAULT_CONTROL_SEEDS, _build_parser, _parse_seed_list, ) - -_CLI_MODULE = "transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark_cli" - - -def test_cli_module_is_not_imported_by_the_package_init() -> None: - # The analysis package's __init__ imports the library module, so pointing the documented - # `python -m` command at that module executes it twice and runpy warns. The CLI module - # must stay out of the package import graph for the documented command to run once. - # Checked in a fresh interpreter: importing the CLI here would mask the regression. - probe = ( - "import sys; import transformer_lens.tools.analysis; " - f"raise SystemExit(1 if {_CLI_MODULE!r} in sys.modules else 0)" - ) - result = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True) - assert result.returncode == 0, ( - "importing transformer_lens.tools.analysis pulled in the CLI module, which would " - f"make `python -m {_CLI_MODULE}` execute twice; stderr: {result.stderr}" - ) +from transformer_lens.tools.analysis.jacobian_lens import DEFAULT_K def test_cli_main_parses_defaults() -> None: args = _build_parser().parse_args([]) assert args.output == _DEFAULT_ARTIFACT_PATH + # A bare run must regenerate the frozen artifact the demo notebook loads. + assert _DEFAULT_ARTIFACT_PATH.is_file() assert args.control_tolerance == 0.1 assert args.control_seeds == _DEFAULT_CONTROL_SEEDS assert args.alpha == 1.0 diff --git a/tests/unit/tools/test_jacobian_lens_causal_swap_benchmark_trials.py b/tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark_trials.py similarity index 99% rename from tests/unit/tools/test_jacobian_lens_causal_swap_benchmark_trials.py rename to tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark_trials.py index ec178c4555..e5ed5065ea 100644 --- a/tests/unit/tools/test_jacobian_lens_causal_swap_benchmark_trials.py +++ b/tests/unit/scripts/test_jacobian_lens_causal_swap_benchmark_trials.py @@ -11,9 +11,7 @@ import pytest import torch -from tests.unit.tools.conftest import D_MODEL, D_VOCAB, _ToyBridge -from transformer_lens.tools.analysis import JacobianLens -from transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark import ( +from scripts.jacobian_lens_causal_swap_benchmark import ( AnswerMetrics, BenchmarkCorpus, FunctionSpec, @@ -22,6 +20,8 @@ run_causal_swap_benchmark, run_causal_swap_trial, ) +from tests.unit.tools.conftest import D_MODEL, D_VOCAB, _ToyBridge +from transformer_lens.tools.analysis import JacobianLens from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( JSpaceDecomposition, ) diff --git a/tests/unit/test_direct_path_patching.py b/tests/unit/test_direct_path_patching.py index a3c0ef6ce2..32b4e500b4 100644 --- a/tests/unit/test_direct_path_patching.py +++ b/tests/unit/test_direct_path_patching.py @@ -1,459 +1,369 @@ -"""Tests for transformer_lens/tools/analysis/direct_path_patching.py +"""Tests for direct path patching in transformer_lens/patching.py. -Run with: - pytest tests/unit/test_direct_path_patching.py -v - -These tests use a tiny randomly-initialised 3-layer model so they run -in seconds on CPU without downloading any weights. +The models are tiny randomly initialised bridges built in memory, so the tests download nothing. """ -import warnings +import contextlib import pytest import torch +from transformers import ( + GPT2Config, + GPT2LMHeadModel, + GraniteConfig, + GraniteForCausalLM, + LlamaConfig, + LlamaForCausalLM, + Olmo2Config, + Olmo2ForCausalLM, +) from transformer_lens.config import TransformerBridgeConfig +from transformer_lens.hook_points import HookPoint from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.tools.analysis.direct_path_patching import ( - _check_fold_ln, +from transformer_lens.model_bridge.sources._bridge_builder import ( + build_bridge_from_module, +) +from transformer_lens.patching import ( get_act_patch_direct_path, get_act_patch_direct_path_all_sources, ) +from transformer_lens.tools.analysis.attribution_patching import ( + Node, + _ablate_edges, + _edge_hook_flags, + capture_writer_outputs, +) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -def _tiny_cfg(**overrides): - defaults = dict( - n_layers=3, - d_model=64, - d_head=16, - n_heads=4, - d_mlp=128, - d_vocab=100, - n_ctx=16, - act_fn="gelu", - normalization_type="LN", - attn_only=False, - seed=0, - ) - defaults.update(overrides) - return TransformerBridgeConfig(**defaults) +# float64 models keep implementation rounding far below this; results are stored in float32. +_EXACT_ATOL = 1e-6 -@pytest.fixture(scope="module") -def tiny_model(): - """A small, randomly-initialised native bridge with LN folded in. - - seed pins the weight init: without it the weights depend on the ambient - RNG state of the xdist worker, and unlucky draws push the linear-LN - approximation error past tolerance. - """ - model = TransformerBridge.boot_native(_tiny_cfg()) - model.process_weights( - fold_ln=True, center_writing_weights=True, center_unembed=True, fold_value_biases=True +def _tiny_gpt2(fold_ln: bool = True) -> TransformerBridge: + torch.manual_seed(0) + # A wider init than GPT-2's 0.02 puts path effects near 1e-3, far above the tolerance. + hf_config = GPT2Config( + n_layer=3, n_head=4, n_embd=64, vocab_size=100, n_positions=16, initializer_range=0.1 ) - model.eval() - return model - - -@pytest.fixture(scope="module") -def tokens_and_caches(tiny_model): - """Precompute clean/corrupted tokens and their caches.""" - torch.manual_seed(42) - clean_tokens = torch.randint(0, 100, (1, 8)) - corrupted_tokens = torch.randint(0, 100, (1, 8)) - - with torch.no_grad(): - _, clean_cache = tiny_model.run_with_cache(clean_tokens) - _, corrupted_cache = tiny_model.run_with_cache(corrupted_tokens) - - return clean_tokens, corrupted_tokens, clean_cache, corrupted_cache - - -def simple_metric(logits): - """Sum of last-token logits — a trivially differentiable scalar.""" - return logits[0, -1, :].sum() - - -# --------------------------------------------------------------------------- -# _check_fold_ln tests -# --------------------------------------------------------------------------- - - -class TestCheckFoldLn: - def test_folded_model_no_warning(self, tiny_model): - """No warning when LN is already folded (tiny_model fixture calls process_weights()).""" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _check_fold_ln(tiny_model) - user_warnings = [x for x in w if issubclass(x.category, UserWarning)] - assert len(user_warnings) == 0, "Should not warn when LN is folded" - - def test_unfolded_model_warns(self): - """UserWarning fires when LN has a non-unit learned scale (pretrained, pre-fold).""" - model = TransformerBridge.boot_native(_tiny_cfg(n_layers=2, seed=1)) - model.eval() - # Simulate a pretrained model that has learned non-unit LN scale (not yet folded). + hf_model = GPT2LMHeadModel(hf_config).to(torch.float64).eval() + model = build_bridge_from_module( + hf_model, "GPT2LMHeadModel", hf_config=hf_config, tokenizer=None, device="cpu" + ) + if fold_ln: + model.enable_compatibility_mode() + else: + generator = torch.Generator().manual_seed(1) with torch.no_grad(): - model.blocks[0].ln1.weight.fill_(2.0) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _check_fold_ln(model) - user_warnings = [x for x in w if issubclass(x.category, UserWarning)] - assert len(user_warnings) == 1 - assert "fold" in str(user_warnings[0].message).lower() - - def test_legacy_w_attribute_branch(self): - """The guard also reads the legacy ``.w`` layout when ``.weight`` is absent.""" - - class LegacyLN: - w = torch.full((8,), 2.0) - - class Block: - ln1 = LegacyLN() - - class LegacyModel: - blocks = [Block()] - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _check_fold_ln(LegacyModel()) - user_warnings = [x for x in w if issubclass(x.category, UserWarning)] - assert len(user_warnings) == 1 - - def test_offset_norm_identity_weight_no_warning(self): - """Offset-RMS (Gemma) folded identity is w = 0; reading it raw would misfire.""" - - class OffsetCfg: - rmsnorm_uses_offset = True + for block in model.blocks: + ln1 = block.ln1 + ln1.weight.copy_(1 + 0.5 * torch.randn(ln1.weight.shape, generator=generator)) + ln1.bias.copy_(0.2 * torch.randn(ln1.bias.shape, generator=generator)) + return model.eval() + + +def _tiny_llama_gqa() -> TransformerBridge: + torch.manual_seed(0) + hf_config = LlamaConfig( + hidden_size=64, + intermediate_size=128, + num_hidden_layers=3, + num_attention_heads=4, + num_key_value_heads=2, + vocab_size=100, + max_position_embeddings=16, + ) + hf_model = LlamaForCausalLM(hf_config).eval() + model = build_bridge_from_module( + hf_model, "LlamaForCausalLM", hf_config=hf_config, tokenizer=None, device="cpu" + ) + return model.eval() + + +def _tiny_decoder(config_cls, model_cls, architecture, **overrides) -> TransformerBridge: + torch.manual_seed(0) + hf_config = config_cls( + hidden_size=64, + intermediate_size=128, + num_hidden_layers=3, + num_attention_heads=4, + vocab_size=100, + max_position_embeddings=16, + **overrides, + ) + hf_model = model_cls(hf_config).eval() + model = build_bridge_from_module( + hf_model, architecture, hf_config=hf_config, tokenizer=None, device="cpu" + ) + return model.eval() - class OffsetLN: - weight = torch.zeros(8) - class Block: - ln1 = OffsetLN() +def _tiny_granite(residual_multiplier: float) -> TransformerBridge: + return _tiny_decoder( + GraniteConfig, + GraniteForCausalLM, + "GraniteForCausalLM", + residual_multiplier=residual_multiplier, + ) - class OffsetModel: - cfg = OffsetCfg() - blocks = [Block()] - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _check_fold_ln(OffsetModel()) - user_warnings = [x for x in w if issubclass(x.category, UserWarning)] - assert len(user_warnings) == 0, "offset identity (w=0) must read as folded" +def _tiny_olmo2() -> TransformerBridge: + return _tiny_decoder(Olmo2Config, Olmo2ForCausalLM, "Olmo2ForCausalLM") - def test_offset_norm_learned_weight_warns(self): - """Offset-RMS with a learned scale (1 + w != 1) still warns.""" - class OffsetCfg: - rmsnorm_uses_offset = True +def _tokens_and_caches(model, batch=1): + generator = torch.Generator().manual_seed(42) + clean_tokens = torch.randint(0, 100, (batch, 8), generator=generator) + corrupted_tokens = torch.randint(0, 100, (batch, 8), generator=generator) + with torch.no_grad(): + _, clean_cache = model.run_with_cache(clean_tokens) + _, corrupted_cache = model.run_with_cache(corrupted_tokens) + return clean_tokens, corrupted_tokens, clean_cache, corrupted_cache - class OffsetLN: - weight = torch.full((8,), 0.7) - class Block: - ln1 = OffsetLN() +@pytest.fixture(scope="module") +def tiny_model(): + return _tiny_gpt2() - class OffsetModel: - cfg = OffsetCfg() - blocks = [Block()] - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _check_fold_ln(OffsetModel()) - user_warnings = [x for x in w if issubclass(x.category, UserWarning)] - assert len(user_warnings) == 1 +@pytest.fixture(scope="module") +def unfolded_model(): + return _tiny_gpt2(fold_ln=False) - def test_no_crash_on_missing_attribute(self): - """_check_fold_ln silently passes when the model has no .blocks[0].ln1.""" - class WeirdModel: - class cfg: - pass +@pytest.fixture(scope="module") +def tokens_and_caches(tiny_model): + return _tokens_and_caches(tiny_model) - class blocks: - pass - # Should not raise — the except block in _check_fold_ln catches AttributeError - _check_fold_ln(WeirdModel()) # type: ignore[arg-type] +def logit_diff(logits): + return logits[0, -1, 17] - logits[0, -1, 42] -# --------------------------------------------------------------------------- -# Shape tests -# --------------------------------------------------------------------------- +def _direct_path(model, corrupted_tokens, clean_cache, corrupted_cache, **kwargs): + kwargs.setdefault("patching_metric", logit_diff) + kwargs.setdefault("src_layer", 0) + kwargs.setdefault("src_head", 0) + with torch.no_grad(): + return get_act_patch_direct_path( + model, corrupted_tokens, clean_cache, corrupted_cache, verbose=False, **kwargs + ) class TestOutputShape: def test_single_source_shape(self, tiny_model, tokens_and_caches): _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=0, - src_head=0, - component="q", - verbose=False, - ) + results = _direct_path(tiny_model, corrupted_tokens, clean_cache, corrupted_cache) assert results.shape == (tiny_model.cfg.n_layers, tiny_model.cfg.n_heads) def test_all_sources_shape(self, tiny_model, tokens_and_caches): _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches with torch.no_grad(): results = get_act_patch_direct_path_all_sources( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - component="q", + tiny_model, + corrupted_tokens, + clean_cache, + corrupted_cache, + logit_diff, verbose=False, ) - n = tiny_model.cfg.n_layers - h = tiny_model.cfg.n_heads + n, h = tiny_model.cfg.n_layers, tiny_model.cfg.n_heads assert results.shape == (n, h, n, h) @pytest.mark.parametrize("component", ["q", "k", "v"]) def test_all_components(self, tiny_model, tokens_and_caches, component): _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=0, - src_head=1, - component=component, - verbose=False, - ) + results = _direct_path( + tiny_model, + corrupted_tokens, + clean_cache, + corrupted_cache, + src_head=1, + component=component, + ) assert results.shape == (tiny_model.cfg.n_layers, tiny_model.cfg.n_heads) -# --------------------------------------------------------------------------- -# Causal structure tests -# --------------------------------------------------------------------------- - - class TestCausalStructure: def test_earlier_layers_are_zero(self, tiny_model, tokens_and_caches): - """Entries for dst_layer <= src_layer must be exactly 0 (no causal path).""" _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - src_layer = 1 - with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=src_layer, - src_head=0, - component="q", - verbose=False, - ) - assert results[: src_layer + 1].eq(0).all() + results = _direct_path( + tiny_model, corrupted_tokens, clean_cache, corrupted_cache, src_layer=1 + ) + assert results[:2].eq(0).all() - def test_later_layers_are_nonzero(self, tiny_model, tokens_and_caches): - """At least some entries for dst_layer > src_layer should be non-zero.""" + def test_later_layers_move_the_metric(self, tiny_model, tokens_and_caches): _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=0, - src_head=0, - component="q", - verbose=False, - ) - assert not results[1:].eq(0).all() + baseline = logit_diff(tiny_model(corrupted_tokens)).item() + results = _direct_path(tiny_model, corrupted_tokens, clean_cache, corrupted_cache) + assert (results[1:] - baseline).abs().max() > 1e-4 - def test_clean_equals_corrupted_gives_zero_delta(self, tiny_model): - """If clean == corrupted, delta is zero and every entry equals the baseline.""" - torch.manual_seed(7) - tokens = torch.randint(0, 100, (1, 6)) + def test_clean_equals_corrupted_gives_baseline(self, tiny_model, tokens_and_caches): + _, tokens, _, cache = tokens_and_caches with torch.no_grad(): - baseline_logits, cache = tiny_model.run_with_cache(tokens) + baseline = logit_diff(tiny_model(tokens)).item() + results = _direct_path(tiny_model, tokens, cache, cache) + torch.testing.assert_close( + results[1:], torch.full_like(results[1:], baseline), atol=_EXACT_ATOL, rtol=0 + ) + + +def _ablate_path(model, clean_tokens, corrupted_tokens, src, dst, component): + """The same path patch through the edge-ablation primitive, one edge per position.""" + edges = [ + (Node("attn_head_out", pos, *src), Node(f"{component}_input", pos, *dst)) + for pos in range(corrupted_tokens.shape[1]) + ] + with torch.no_grad(), _edge_hook_flags(model): + replacement = capture_writer_outputs(model, clean_tokens) + return logit_diff(_ablate_edges(model, corrupted_tokens, edges, [], replacement)).item() - baseline = simple_metric(baseline_logits).item() +def _fork_reference(model, clean_tokens, corrupted_tokens, src, dst, component): + """Shift dst's fork input by src's hook_result difference, by hand.""" + result_name = f"blocks.{src[0]}.attn.hook_result" + model.set_use_attn_result(True) + try: with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=tokens, - clean_cache=cache, - corrupted_cache=cache, - patching_metric=simple_metric, - src_layer=0, - src_head=0, - component="q", - verbose=False, + _, clean = model.run_with_cache(clean_tokens, names_filter=result_name) + _, corrupted = model.run_with_cache(corrupted_tokens, names_filter=result_name) + finally: + model.set_use_attn_result(False) + delta = clean[result_name][:, :, src[1]] - corrupted[result_name][:, :, src[1]] + + def shift(fork_input, hook): + fork_input = fork_input.clone() + fork_input[:, :, dst[1]] = fork_input[:, :, dst[1]] + delta + return fork_input + + model.set_use_split_qkv_input(True) + try: + with torch.no_grad(): + logits = model.run_with_hooks( + corrupted_tokens, + fwd_hooks=[(f"blocks.{dst[0]}.attn.hook_{component}_input", shift)], ) + finally: + model.set_use_split_qkv_input(False) + return logit_diff(logits).item() - nonzero_entries = results[results != 0] - assert nonzero_entries.numel() == 0 or torch.allclose( - nonzero_entries, - torch.full_like(nonzero_entries, baseline), - atol=1e-4, - ) +def _frozen_scale_patch(model, corrupted_tokens, clean_cache, corrupted_cache, src, dst, component): + """Frozen-scale linearisation: divide by the corrupted run's ln1 scale, project, add.""" + W_O = model.blocks[src[0]].attn.W_O[src[1]] + z_name = f"blocks.{src[0]}.attn.hook_z" + delta_resid = (clean_cache[z_name][:, :, src[1]] - corrupted_cache[z_name][:, :, src[1]]) @ W_O + ln_scale = corrupted_cache[f"blocks.{dst[0]}.ln1.hook_scale"] + W_comp = getattr(model.blocks[dst[0]].attn, f"W_{component.upper()}")[dst[1]] + delta = (delta_resid / ln_scale) @ W_comp -# --------------------------------------------------------------------------- -# Correctness: independent verification for a single pair -# --------------------------------------------------------------------------- + def add(value, hook): + value = value.clone() + value[:, :, dst[1]] = value[:, :, dst[1]] + delta + return value + with torch.no_grad(): + logits = model.run_with_hooks( + corrupted_tokens, fwd_hooks=[(f"blocks.{dst[0]}.attn.hook_{component}", add)] + ) + return logit_diff(logits).item() -class TestCorrectness: - @pytest.mark.parametrize("component", ["q", "k", "v"]) - def test_matches_frozen_ln_scale_reference(self, tiny_model, tokens_and_caches, component): - """Matches a hand-written hook dividing delta_resid by the corrupted run's ln1 scale.""" - _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - src_layer, src_head = 0, 0 - dst_layer, dst_head = 2, 1 - correct_tok = 17 - incorrect_tok = 42 +_SRC, _DST = (0, 0), (2, 1) - def logit_diff(logits): - return logits[0, -1, correct_tok] - logits[0, -1, incorrect_tok] - # Compute delta_resid from src head - W_O = tiny_model.blocks[src_layer].attn.W_O # type: ignore[union-attr] - clean_z = clean_cache[f"blocks.{src_layer}.attn.hook_z"][:, :, src_head, :] - corrupted_z = corrupted_cache[f"blocks.{src_layer}.attn.hook_z"][:, :, src_head, :] - delta_resid = (clean_z @ W_O[src_head]) - (corrupted_z @ W_O[src_head]) # type: ignore[index] +class TestCorrectness: + @pytest.mark.parametrize("component", ["q", "k", "v"]) + def test_matches_hand_written_fork_patch(self, tiny_model, tokens_and_caches, component): + clean_tokens, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches + ours = _direct_path( + tiny_model, corrupted_tokens, clean_cache, corrupted_cache, component=component + )[_DST].item() + ref = _fork_reference(tiny_model, clean_tokens, corrupted_tokens, _SRC, _DST, component) + assert abs(ours - ref) < _EXACT_ATOL + + @pytest.mark.parametrize("model_fixture", ["tiny_model", "unfolded_model"]) + @pytest.mark.parametrize("component", ["q", "k", "v"]) + def test_matches_edge_ablation(self, request, model_fixture, component): + model = request.getfixturevalue(model_fixture) + clean_tokens, corrupted_tokens, clean_cache, corrupted_cache = _tokens_and_caches(model) + ours = _direct_path( + model, corrupted_tokens, clean_cache, corrupted_cache, component=component + )[_DST].item() + ablated = _ablate_path(model, clean_tokens, corrupted_tokens, _SRC, _DST, component) + assert abs(ours - ablated) < _EXACT_ATOL - # Not the true LN forward: its gap to the approximation is weight-dependent and, on - # tiny_model, about as large as the patch effect, so it can't catch scale bugs. - ln_scale = corrupted_cache[f"blocks.{dst_layer}.ln1.hook_scale"] - W_comp = getattr(tiny_model.blocks[dst_layer].attn, f"W_{component.upper()}")[dst_head] - ref_delta = (delta_resid / ln_scale) @ W_comp + @pytest.mark.parametrize("component", ["q", "k", "v"]) + def test_frozen_scale_linearisation_is_resolvably_different( + self, tiny_model, tokens_and_caches, component + ): + """The exactness tolerance resolves the frozen-scale linearisation from the exact patch.""" + _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches + ours = _direct_path( + tiny_model, corrupted_tokens, clean_cache, corrupted_cache, component=component + )[_DST].item() + frozen = _frozen_scale_patch( + tiny_model, corrupted_tokens, clean_cache, corrupted_cache, _SRC, _DST, component + ) + assert abs(ours - frozen) > 100 * _EXACT_ATOL - def ref_hook(value, hook): - if value.requires_grad: - value = value.clone() - value[:, :, dst_head, :] = value[:, :, dst_head, :] + ref_delta - return value + def test_batched_sweep_patches_every_example(self, tiny_model): + _, corrupted_tokens, clean_cache, corrupted_cache = _tokens_and_caches(tiny_model, batch=2) - with torch.no_grad(): - ref_logits = tiny_model.run_with_hooks( - corrupted_tokens, - fwd_hooks=[(f"blocks.{dst_layer}.attn.hook_{component}", ref_hook)], - ) - ref_metric = logit_diff(ref_logits).item() + def summed_logit_diff(logits): + return (logits[:, -1, 17] - logits[:, -1, 42]).sum() - with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=logit_diff, - src_layer=src_layer, - src_head=src_head, - component=component, - verbose=False, + batched = _direct_path( + tiny_model, + corrupted_tokens, + clean_cache, + corrupted_cache, + patching_metric=summed_logit_diff, + ) + per_example = sum( + _direct_path( + tiny_model, + corrupted_tokens[i : i + 1], + clean_cache.apply_slice_to_batch_dim((i, i + 1)), + corrupted_cache.apply_slice_to_batch_dim((i, i + 1)), ) - our_metric = results[dst_layer, dst_head].item() - - assert abs(our_metric - ref_metric) < 1e-5, ( - f"Direct-path patch {our_metric:.6f} disagrees with frozen-scale reference " - f"{ref_metric:.6f} (diff={abs(our_metric - ref_metric):.2e})." + for i in range(2) ) + torch.testing.assert_close(batched, per_example, atol=_EXACT_ATOL, rtol=0) def test_all_sources_consistent_with_single(self, tiny_model, tokens_and_caches): - """get_act_patch_direct_path_all_sources matches individual calls.""" _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - src_layer, src_head = 0, 2 - + single = _direct_path( + tiny_model, corrupted_tokens, clean_cache, corrupted_cache, src_head=2 + ) with torch.no_grad(): - single = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=src_layer, - src_head=src_head, - component="q", - verbose=False, - ) all_sources = get_act_patch_direct_path_all_sources( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - component="q", + tiny_model, + corrupted_tokens, + clean_cache, + corrupted_cache, + logit_diff, verbose=False, ) - - assert torch.allclose(single, all_sources[src_layer, src_head], atol=1e-5) - - -# --------------------------------------------------------------------------- -# Edge cases -# --------------------------------------------------------------------------- + assert torch.equal(single, all_sources[0, 2]) class TestEdgeCases: def test_last_layer_source_all_zero(self, tiny_model, tokens_and_caches): - """A source in the last layer has no downstream heads → all zeros.""" _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - src_layer = tiny_model.cfg.n_layers - 1 - with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=src_layer, - src_head=0, - component="q", - verbose=False, - ) + results = _direct_path( + tiny_model, corrupted_tokens, clean_cache, corrupted_cache, src_layer=2 + ) assert results.eq(0).all() def test_returns_cpu_tensor(self, tiny_model, tokens_and_caches): - """Return tensor should be on the same device as the model.""" _, corrupted_tokens, clean_cache, corrupted_cache = tokens_and_caches - with torch.no_grad(): - results = get_act_patch_direct_path( - model=tiny_model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=simple_metric, - src_layer=0, - src_head=0, - component="q", - verbose=False, - ) + results = _direct_path(tiny_model, corrupted_tokens, clean_cache, corrupted_cache) assert results.device.type == tiny_model.cfg.device or results.is_cpu -# --------------------------------------------------------------------------- -# Unsupported architectures -# --------------------------------------------------------------------------- - -_GQA_REFUSAL = r'per-query-head K/V semantics are undefined\. component="q" is available' - - -def _fresh_model_and_cache(**overrides): - model = TransformerBridge.boot_native(_tiny_cfg(**overrides)) - model.eval() +def _fresh_model_and_cache(build=_tiny_gpt2): + model = build() tokens = torch.randint(0, 100, (1, 8), generator=torch.Generator().manual_seed(0)) with torch.no_grad(): _, cache = model.run_with_cache(tokens) @@ -467,16 +377,71 @@ def _count_forwards(model): return calls -def _sweep(model, tokens, cache, src_layer=0, component="q"): +def _sweep(model, tokens, cache, src_layer=0, component="q", metric=logit_diff): return get_act_patch_direct_path( - model, tokens, cache, cache, simple_metric, src_layer, 0, component, verbose=False + model, tokens, cache, cache, metric, src_layer, 0, component, verbose=False + ) + + +class TestSplitQkvFlags: + @pytest.mark.parametrize("fails", [False, True], ids=["returns", "raises"]) + @pytest.mark.parametrize( + "split, attn_in", + [(False, False), (True, False), (False, True)], + ids=["off", "split", "attn_in"], ) + def test_caller_flags_restored(self, split, attn_in, fails): + model, tokens, cache = _fresh_model_and_cache() + model.set_use_split_qkv_input(split) + model.set_use_attn_in(attn_in) + + def metric(logits): + if fails: + raise RuntimeError("metric failed") + return logit_diff(logits) + + raised = pytest.raises(RuntimeError, match="metric failed") + with torch.no_grad(), raised if fails else contextlib.nullcontext(): + _sweep(model, tokens, cache, metric=metric) + assert (model.cfg.use_split_qkv_input, model.cfg.use_attn_in) == (split, attn_in) + + def test_destination_without_fork_refused(self, monkeypatch): + model, tokens, cache = _fresh_model_and_cache() + attn = model.blocks[2].attn + fork = attn._fork_and_norm_per_head + # Route the fork through an unhooked point, as an attention class without the fork would. + monkeypatch.setattr( + attn, "_fork_and_norm_per_head", lambda source, hook, n: fork(source, HookPoint(), n) + ) + with torch.no_grad(), pytest.raises(NotImplementedError, match="did not fire"): + _sweep(model, tokens, cache, src_layer=1) + assert not model.cfg.use_split_qkv_input + + def test_model_without_fork_refused_before_any_forward(self): + cfg = TransformerBridgeConfig( + n_layers=2, d_model=32, d_head=8, n_heads=4, d_mlp=64, d_vocab=100, n_ctx=16 + ) + model, tokens, cache = _fresh_model_and_cache( + lambda: TransformerBridge.boot_native(cfg).eval() + ) + forwards = _count_forwards(model) + with pytest.raises(NotImplementedError, match="use_split_qkv_input"): + _sweep(model, tokens, cache) + assert forwards == [] + assert not model.cfg.use_split_qkv_input + + +_GQA_REFUSAL = r'per-query-head K/V semantics are undefined\. component="q" is available' + + +def _non_additive(detail: str) -> str: + return rf"scaled before the residual add on this architecture \({detail}.*not hook_z @ W_O" class TestUnsupportedArchitectures: @pytest.mark.parametrize("component", ["k", "v"]) def test_gqa_kv_refused_before_any_forward(self, component): - model, tokens, cache = _fresh_model_and_cache(n_key_value_heads=2) + model, tokens, cache = _fresh_model_and_cache(_tiny_llama_gqa) forwards = _count_forwards(model) with pytest.raises(ValueError, match=_GQA_REFUSAL): _sweep(model, tokens, cache, component=component) @@ -484,10 +449,12 @@ def test_gqa_kv_refused_before_any_forward(self, component): assert _sweep(model, tokens, cache, src_layer=2, component=component).eq(0).all() def test_gqa_q_still_runs(self): - model, tokens, cache = _fresh_model_and_cache(n_key_value_heads=2) + model, tokens, cache = _fresh_model_and_cache(_tiny_llama_gqa) + assert model.cfg.n_key_value_heads == 2 with torch.no_grad(): results = _sweep(model, tokens, cache, component="q") assert results.shape == (model.cfg.n_layers, model.cfg.n_heads) + assert results[1:].isfinite().all() def test_per_layer_kv_count_checked_on_destination_layers(self): model, tokens, cache = _fresh_model_and_cache() @@ -507,44 +474,74 @@ def test_block_without_attention_refused_before_any_forward(self, monkeypatch): _sweep(model, tokens, cache, src_layer=src_layer) with pytest.raises(ValueError, match=r"blocks\.1 has no `attn` component"): get_act_patch_direct_path_all_sources( - model, tokens, cache, cache, simple_metric, verbose=False + model, tokens, cache, cache, logit_diff, verbose=False ) assert forwards == [] assert _sweep(model, tokens, cache, src_layer=2).eq(0).all() @pytest.mark.parametrize( - "kv_heads, drop_attn, component, refusal", + "build, detail", [ - (2, False, "k", _GQA_REFUSAL), - (2, False, "v", _GQA_REFUSAL), - (None, True, "q", r"blocks\.2 has no `attn` component"), + (lambda: _tiny_granite(0.25), r"blocks\.0 scales its attention output by 0\.25"), + (_tiny_olmo2, r"blocks\.0\.hook_attn_out reads ln1_post"), + ], + ids=["scaled-residual", "post-norm"], + ) + def test_transformed_attention_write_refused_before_any_forward(self, build, detail): + model, tokens, cache = _fresh_model_and_cache(build) + forwards = _count_forwards(model) + with pytest.raises(ValueError, match=_non_additive(detail)): + _sweep(model, tokens, cache) + with pytest.raises(ValueError, match=_non_additive(detail)): + get_act_patch_direct_path_all_sources( + model, tokens, cache, cache, logit_diff, verbose=False + ) + assert forwards == [] + + def test_unscaled_granite_runs(self): + model, tokens, cache = _fresh_model_and_cache(lambda: _tiny_granite(1.0)) + with torch.no_grad(): + results = _sweep(model, tokens, cache) + assert results[1:].isfinite().all() + + def test_only_source_blocks_need_an_additive_write(self, monkeypatch): + model, tokens, cache = _fresh_model_and_cache() + monkeypatch.setattr(model.blocks[1], "residual_contribution_scale", 0.5, raising=False) + forwards = _count_forwards(model) + refusal = _non_additive(r"blocks\.1 scales its attention output by 0\.5") + with pytest.raises(ValueError, match=refusal): + _sweep(model, tokens, cache, src_layer=1) + with pytest.raises(ValueError, match=refusal): + get_act_patch_direct_path_all_sources( + model, tokens, cache, cache, logit_diff, verbose=False + ) + assert forwards == [] + with torch.no_grad(): + assert _sweep(model, tokens, cache, src_layer=0)[1:].isfinite().all() + + @pytest.mark.parametrize( + "build, drop_attn, component, refusal", + [ + (_tiny_llama_gqa, False, "k", _GQA_REFUSAL), + (_tiny_llama_gqa, False, "v", _GQA_REFUSAL), + (_tiny_gpt2, True, "q", r"blocks\.2 has no `attn` component"), ], ids=["gqa-k", "gqa-v", "no-attn"], ) - def test_all_sources_refuses_before_fold_warning( - self, monkeypatch, kv_heads, drop_attn, component, refusal + def test_all_sources_refuses_before_any_forward( + self, monkeypatch, build, drop_attn, component, refusal ): - """A refused all-sources sweep must not first warn that its results need folded LN.""" - model, tokens, cache = _fresh_model_and_cache(n_key_value_heads=kv_heads) + model, tokens, cache = _fresh_model_and_cache(build) if drop_attn: monkeypatch.delattr(model.blocks[2], "attn") - with torch.no_grad(): - model.blocks[0].ln1.weight.fill_(2.0) forwards = _count_forwards(model) - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - with pytest.raises(ValueError, match=refusal): - get_act_patch_direct_path_all_sources( - model, tokens, cache, cache, simple_metric, component, verbose=False - ) + with pytest.raises(ValueError, match=refusal): + get_act_patch_direct_path_all_sources( + model, tokens, cache, cache, logit_diff, component, verbose=False + ) assert forwards == [] -# --------------------------------------------------------------------------- -# Evaluation-mode guard -# --------------------------------------------------------------------------- - - def _training_flags(model): modules = [*model.named_modules(), *model.original_model.named_modules()] return [(name, module.training) for name, module in modules] @@ -559,27 +556,44 @@ class TestEvalModeGuard: ], ids=["whole-model", "one-attn"], ) - def test_both_entry_points_refuse_before_any_forward_or_fold_warning( - self, enter_training, root_training - ): + def test_both_entry_points_refuse_before_any_forward(self, enter_training, root_training): model, tokens, cache = _fresh_model_and_cache() - with torch.no_grad(): - model.blocks[0].ln1.weight.fill_(2.0) enter_training(model) flags = _training_flags(model) forwards = _count_forwards(model) - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - with pytest.raises( - ValueError, match=r"get_act_patch_direct_path\(\) .*model\.eval\(\)" - ): - _sweep(model, tokens, cache) - with pytest.raises( - ValueError, match=r"get_act_patch_direct_path_all_sources\(\) .*model\.eval\(\)" - ): - get_act_patch_direct_path_all_sources( - model, tokens, cache, cache, simple_metric, verbose=False - ) + with pytest.raises(ValueError, match=r"get_act_patch_direct_path\(\) .*model\.eval\(\)"): + _sweep(model, tokens, cache) + with pytest.raises( + ValueError, match=r"get_act_patch_direct_path_all_sources\(\) .*model\.eval\(\)" + ): + get_act_patch_direct_path_all_sources( + model, tokens, cache, cache, logit_diff, verbose=False + ) assert forwards == [] assert model.training is root_training assert _training_flags(model) == flags + + +def test_old_module_path_warns_that_results_changed(): + from transformer_lens.tools.analysis import direct_path_patching + + for name, moved in [ + ("get_act_patch_direct_path", get_act_patch_direct_path), + ("get_act_patch_direct_path_all_sources", get_act_patch_direct_path_all_sources), + ]: + with pytest.warns(FutureWarning, match="transformer_lens.patching.*results differ"): + assert getattr(direct_path_patching, name) is moved + + +def test_old_module_star_import_binds_the_moved_names_and_warns(): + from transformer_lens.tools.analysis import direct_path_patching + + namespace: dict = {} + with pytest.warns(FutureWarning, match="transformer_lens.patching"): + exec("from transformer_lens.tools.analysis.direct_path_patching import *", namespace) + namespace.pop("__builtins__") + assert namespace == { + "get_act_patch_direct_path": get_act_patch_direct_path, + "get_act_patch_direct_path_all_sources": get_act_patch_direct_path_all_sources, + } + assert set(namespace) <= set(dir(direct_path_patching)) diff --git a/tests/unit/test_make_docs.py b/tests/unit/test_make_docs.py index b13dfb32ef..ab77faa9a0 100644 --- a/tests/unit/test_make_docs.py +++ b/tests/unit/test_make_docs.py @@ -38,6 +38,7 @@ def test_copy_demos_creates_generated_dir_when_absent(tmp_path, monkeypatch): copied = sorted(p.name for p in (generated / "demos").iterdir()) assert copied == [ + "Backward_Lens_Demo.ipynb", "Exploratory_Analysis_Demo.ipynb", "Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb", "Jacobian_Lens_Decomposition_Demo.ipynb", diff --git a/tests/unit/tools/conftest.py b/tests/unit/tools/conftest.py index 122a91f8b0..c476c42bf9 100644 --- a/tests/unit/tools/conftest.py +++ b/tests/unit/tools/conftest.py @@ -22,6 +22,8 @@ class _ToyBlock(nn.Module): + hook_out_is_single_residual_stream = True + def __init__(self, d_model: int, layer: int, dtype: torch.dtype): super().__init__() self.linear = nn.Linear(d_model, d_model, bias=False, dtype=dtype) @@ -36,6 +38,8 @@ def forward(self, residual: torch.Tensor) -> torch.Tensor: class _CausalSumBlock(nn.Module): """Causal cross-position mixing with an exact triangular Jacobian.""" + hook_out_is_single_residual_stream = True + def __init__(self, layer: int): super().__init__() self.hook_out = HookPoint() @@ -106,6 +110,10 @@ def __init__( def W_U(self) -> torch.Tensor: return self.unembed.weight.T + @property + def b_U(self) -> torch.Tensor: + return torch.zeros(D_VOCAB, dtype=self.unembed.weight.dtype) + @property def hook_dict(self) -> dict[str, HookPoint]: return { diff --git a/tests/unit/tools/test_attribution_patching.py b/tests/unit/tools/test_attribution_patching.py index 85cd1847ec..21c4e7be65 100644 --- a/tests/unit/tools/test_attribution_patching.py +++ b/tests/unit/tools/test_attribution_patching.py @@ -3048,6 +3048,34 @@ def test_faithfulness_raises_on_more_than_one_pair() -> None: faithfulness(model, batched_clean, batched_corrupt, metric, []) +@pytest.mark.parametrize( + ("operation", "method"), + [("attribution_patch", "Attribution patching"), ("faithfulness", "Faithfulness")], +) +@pytest.mark.parametrize( + ("clean", "corrupt", "message"), + [ + (torch.tensor([1, 2, 3]), torch.tensor([[3, 2, 1]]), r"{operation} expects 2D"), + ( + torch.tensor([[1, 2, 3], [3, 2, 1]]), + torch.tensor([[3, 2, 1]]), + "same number of prompt pairs", + ), + (torch.tensor([[1, 2, 3]]), torch.tensor([[3, 2]]), "same length.*{method} aligns"), + ], +) +def test_both_entry_points_reject_malformed_pairs_in_their_own_words( + operation: str, method: str, clean: torch.Tensor, corrupt: torch.Tensor, message: str +) -> None: + model, _, _, metric = _faithfulness_toy() + match = message.format(operation=operation, method=method) + with pytest.raises(ValueError, match=match): + if operation == "attribution_patch": + attribution_patch(model, clean, corrupt, metric) + else: + faithfulness(model, clean, corrupt, metric, []) + + def test_faithfulness_rejects_model_in_training_mode() -> None: model, clean, corrupt, metric = _faithfulness_toy() model.train() diff --git a/tests/unit/tools/test_backward_lens.py b/tests/unit/tools/test_backward_lens.py deleted file mode 100644 index 1d04d98f5d..0000000000 --- a/tests/unit/tools/test_backward_lens.py +++ /dev/null @@ -1,765 +0,0 @@ -"""Model-free tests for Backward Lens gradient-factor contracts.""" - -from dataclasses import FrozenInstanceError -from types import SimpleNamespace -from typing import Any - -import pytest -import torch -import torch.nn.functional as F -from transformers.pytorch_utils import Conv1D - -from transformer_lens.config import TransformerBridgeConfig -from transformer_lens.conversion_utils.conversion_steps import RearrangeTensorConversion -from transformer_lens.model_bridge import TransformerBridge -from transformer_lens.model_bridge.generalized_components.linear import LinearBridge -from transformer_lens.model_bridge.generalized_components.mlp import MLPBridge -from transformer_lens.tools.analysis.backward_lens import ( - BackwardLens, - BackwardLensMatrixResult, - LinearGradientFactors, - _build_linear_gradient_factors, - _build_matrix_result, - _factor_norms_and_normalized_rows, - _get_dense_mlp_projections, - _project_residual_factors, - _rank_vocabulary_logits, - _require_unconverted_mlp_blocks, - _single_batch_matrix, -) - - -def _dense_mlp_bridge( - *, input_component: torch.nn.Module, output_component: torch.nn.Module, gate: Any = None -) -> MLPBridge: - """Build a real MLPBridge wrapping the given input/output projection modules.""" - mlp = MLPBridge(name="mlp") - input_projection = LinearBridge(name="mlp.in") - input_projection.set_original_component(input_component) - output_projection = LinearBridge(name="mlp.out") - output_projection.set_original_component(output_component) - setattr(mlp, "in", input_projection) - setattr(mlp, "out", output_projection) - if gate is not None: - setattr(mlp, "gate", gate) - return mlp - - -def _dense_mlp_model(mlp: MLPBridge, *, d_model: int, d_mlp: int) -> SimpleNamespace: - return SimpleNamespace( - cfg=SimpleNamespace(d_model=d_model, d_mlp=d_mlp), - blocks=[SimpleNamespace(mlp=mlp)], - ) - - -class _ReadoutModel: - def __init__(self, *, affine: bool = True): - self.cfg = SimpleNamespace(d_model=3) - self.ln_final = torch.nn.LayerNorm(3, elementwise_affine=affine) - self.unembed = torch.nn.Linear(3, 5, bias=False) - with torch.no_grad(): - self.unembed.weight.copy_(torch.arange(15, dtype=torch.float32).reshape(5, 3) / 10) - - @property - def W_U(self) -> torch.Tensor: - return self.unembed.weight.T - - -def _matrix_result( - logits: torch.Tensor, - *, - normalized: bool = True, - return_full_logits: bool = True, - top_k: int = 2, -) -> BackwardLensMatrixResult: - factors = _build_linear_gradient_factors( - torch.tensor([[1.0, 2.0], [3.0, 4.0]]), - torch.tensor([[0.5, -1.0], [2.0, 1.5]]), - torch.tensor([[6.5, 3.5], [9.0, 4.0]]), - weight_layout="in_out", - ) - normalized_logits = logits * 2 if normalized else None - target_token_id = 2 - return BackwardLensMatrixResult( - factors=factors, - projected_factor="forward_inputs", - factor_norms=torch.tensor([1.0, 2.0]), - zero_norm_mask=torch.tensor([False, False]), - vocabulary_size=logits.shape[-1], - target_token_id=target_token_id, - top_ranking=_rank_vocabulary_logits(logits, k=top_k, largest=True), - bottom_ranking=_rank_vocabulary_logits(logits, k=top_k, largest=False), - target_largest_ranks=(logits > logits[:, target_token_id, None]).sum( - dim=-1, dtype=torch.int64 - ), - target_smallest_ranks=(logits < logits[:, target_token_id, None]).sum( - dim=-1, dtype=torch.int64 - ), - normalized_top_ranking=( - _rank_vocabulary_logits(normalized_logits, k=top_k, largest=True) - if normalized_logits is not None - else None - ), - normalized_bottom_ranking=( - _rank_vocabulary_logits(normalized_logits, k=top_k, largest=False) - if normalized_logits is not None - else None - ), - normalized_target_largest_ranks=( - (normalized_logits > normalized_logits[:, target_token_id, None]).sum( - dim=-1, dtype=torch.int64 - ) - if normalized_logits is not None - else None - ), - normalized_target_smallest_ranks=( - (normalized_logits < normalized_logits[:, target_token_id, None]).sum( - dim=-1, dtype=torch.int64 - ) - if normalized_logits is not None - else None - ), - vocabulary_logits=logits.clone() if return_full_logits else None, - normalized_vocabulary_logits=( - normalized_logits.clone() - if return_full_logits and normalized_logits is not None - else None - ), - ) - - -def test_reconstructs_gpt2_style_in_out_gradient_from_autograd() -> None: - torch.manual_seed(1) - inputs = torch.randn(7, 3) - weight = torch.randn(3, 5, requires_grad=True) - output_gradients = torch.randn(7, 5) - outputs = inputs @ weight - (weight_gradient,) = torch.autograd.grad(outputs, weight, grad_outputs=output_gradients) - - result = _build_linear_gradient_factors( - inputs, output_gradients, weight_gradient, weight_layout="in_out" - ) - - assert isinstance(result, LinearGradientFactors) - assert result.reconstructed_gradient.shape == (3, 5) - assert torch.allclose(result.reconstructed_gradient, weight_gradient) - assert result.absolute_reconstruction_error < 1e-6 - assert result.relative_reconstruction_error < 1e-6 - - -def test_reconstructs_out_in_gradient_from_autograd() -> None: - torch.manual_seed(2) - inputs = torch.randn(6, 3) - weight = torch.randn(5, 3, requires_grad=True) - output_gradients = torch.randn(6, 5) - outputs = F.linear(inputs, weight) - (weight_gradient,) = torch.autograd.grad(outputs, weight, grad_outputs=output_gradients) - - result = _build_linear_gradient_factors( - inputs, output_gradients, weight_gradient, weight_layout="out_in" - ) - - assert result.reconstructed_gradient.shape == (5, 3) - assert torch.allclose(result.reconstructed_gradient, weight_gradient) - assert result.absolute_reconstruction_error < 1e-6 - assert result.relative_reconstruction_error < 1e-6 - - -def test_asymmetric_dimensions_reject_wrong_orientation() -> None: - inputs = torch.randn(4, 2) - output_gradients = torch.randn(4, 5) - out_in_gradient = output_gradients.T @ inputs - - with pytest.raises(ValueError, match="does not match"): - _build_linear_gradient_factors( - inputs, output_gradients, out_in_gradient, weight_layout="in_out" - ) - - -def test_result_owns_detached_float32_copies_of_all_inputs() -> None: - inputs = torch.randn(3, 2, dtype=torch.float64, requires_grad=True) - output_gradients = torch.randn(3, 4, dtype=torch.float64) - weight_gradient = inputs.detach().T @ output_gradients - result = _build_linear_gradient_factors( - inputs, output_gradients, weight_gradient, weight_layout="in_out" - ) - saved_inputs = result.forward_inputs.clone() - saved_output_gradients = result.output_gradients.clone() - saved_weight_gradient = result.weight_gradient.clone() - - with torch.no_grad(): - inputs.add_(100) - output_gradients.add_(100) - weight_gradient.add_(100) - - assert result.forward_inputs.dtype == torch.float32 - assert result.forward_inputs.device.type == "cpu" - assert result.forward_inputs.grad_fn is None - assert torch.equal(result.forward_inputs, saved_inputs) - assert torch.equal(result.output_gradients, saved_output_gradients) - assert torch.equal(result.weight_gradient, saved_weight_gradient) - with pytest.raises(FrozenInstanceError): - setattr(result, "weight_layout", "out_in") - - -def test_zero_gradient_has_finite_zero_errors() -> None: - result = _build_linear_gradient_factors( - torch.zeros(3, 2), torch.zeros(3, 4), torch.zeros(2, 4), weight_layout="in_out" - ) - - assert result.absolute_reconstruction_error == 0.0 - assert result.relative_reconstruction_error == 0.0 - - -def test_zero_reference_with_nonzero_reconstruction_has_finite_relative_error() -> None: - result = _build_linear_gradient_factors( - torch.tensor([[1.0, 0.0]]), - torch.tensor([[1.0, 0.0, 0.0]]), - torch.zeros(2, 3), - weight_layout="in_out", - ) - - assert result.absolute_reconstruction_error == 1.0 - assert result.relative_reconstruction_error == 1.0 - assert torch.isfinite(torch.tensor(result.relative_reconstruction_error)) - - -@pytest.mark.parametrize( - ("inputs", "gradients", "weight", "message"), - [ - (torch.randn(2), torch.randn(3, 4), torch.randn(2, 4), "rank 2"), - (torch.randn(3, 2), torch.randn(4, 4), torch.randn(2, 4), "same number"), - (torch.empty(0, 2), torch.empty(0, 4), torch.randn(2, 4), "empty"), - (torch.full((3, 2), float("nan")), torch.ones(3, 4), torch.ones(2, 4), "finite"), - (torch.ones(3, 2), torch.full((3, 4), float("inf")), torch.ones(2, 4), "finite"), - (torch.ones(3, 2), torch.ones(3, 4), torch.full((2, 4), float("nan")), "finite"), - ], -) -def test_factor_validation_errors(inputs, gradients, weight, message) -> None: - with pytest.raises(ValueError, match=message): - _build_linear_gradient_factors(inputs, gradients, weight, weight_layout="in_out") - - -def test_rejects_invalid_layout_and_integer_factors() -> None: - with pytest.raises(ValueError, match="weight_layout"): - _build_linear_gradient_factors( - torch.randn(3, 2), - torch.randn(3, 4), - torch.randn(2, 4), - weight_layout="other", - ) - with pytest.raises(TypeError, match="floating"): - _build_linear_gradient_factors( - torch.ones(3, 2, dtype=torch.long), - torch.randn(3, 4), - torch.randn(2, 4), - weight_layout="in_out", - ) - - -def test_rejects_non_tensor_factors() -> None: - with pytest.raises(TypeError, match="torch.Tensor"): - _build_linear_gradient_factors( - "not a tensor", - torch.randn(3, 4), - torch.randn(2, 4), - weight_layout="in_out", - ) - - -def test_rejects_values_that_overflow_during_float32_conversion() -> None: - with pytest.raises(ValueError, match="remain finite"): - _build_linear_gradient_factors( - torch.full((3, 2), 1e300, dtype=torch.float64), - torch.ones(3, 4, dtype=torch.float64), - torch.full((2, 4), 3e300, dtype=torch.float64), - weight_layout="in_out", - ) - - -def test_rejects_nonfinite_float32_reconstruction() -> None: - large = torch.full((2, 1), 3e38) - with pytest.raises(ValueError, match="reconstruction"): - _build_linear_gradient_factors( - large, - large, - torch.zeros(1, 1), - weight_layout="in_out", - ) - - -def test_vocabulary_rankings_match_topk_without_mutation() -> None: - logits = torch.tensor([[1.0, -3.0, 7.0, 2.0], [8.0, 0.5, -2.0, 4.0]]) - original = logits.clone() - - top = _rank_vocabulary_logits(logits, k=2, largest=True) - bottom = _rank_vocabulary_logits(logits, k=2, largest=False) - - assert top.indices.tolist() == [[2, 3], [0, 3]] - assert top.values.tolist() == [[7.0, 2.0], [8.0, 4.0]] - assert bottom.indices.tolist() == [[1, 0], [2, 1]] - assert torch.equal(logits, original) - - -def test_one_dimensional_vocabulary_ranking_is_detached_cpu_copy() -> None: - logits = torch.tensor([2.0, -4.0, 8.0, 1.0], requires_grad=True) - bottom = _rank_vocabulary_logits(logits, k=2, largest=False) - saved_values = bottom.values.clone() - - with torch.no_grad(): - logits.add_(100) - - assert bottom.indices.tolist() == [1, 3] - assert bottom.values.tolist() == [-4.0, 1.0] - assert bottom.values.device.type == "cpu" - assert bottom.values.grad_fn is None - assert torch.equal(bottom.values, saved_values) - - -@pytest.mark.parametrize("k", [0, 5, True]) -def test_vocabulary_ranking_rejects_invalid_k(k) -> None: - with pytest.raises(ValueError, match="k must"): - _rank_vocabulary_logits(torch.randn(2, 4), k=k, largest=True) - - -def test_vocabulary_ranking_rejects_non_integer_k() -> None: - with pytest.raises(ValueError, match="k must"): - _rank_vocabulary_logits(torch.randn(2, 4), k=1.5, largest=True) - - -@pytest.mark.parametrize("largest", [1, "yes", None]) -def test_vocabulary_ranking_rejects_non_boolean_largest(largest) -> None: - with pytest.raises(TypeError, match="largest must be a bool"): - _rank_vocabulary_logits(torch.randn(2, 4), k=1, largest=largest) - - -@pytest.mark.parametrize( - "logits", - [ - torch.randn(2, 3, 4), - torch.empty(2, 0), - torch.ones(4, dtype=torch.long), - torch.tensor([0.0, float("nan")]), - torch.tensor([0.0, float("inf")]), - ], -) -def test_vocabulary_ranking_rejects_invalid_logits(logits) -> None: - with pytest.raises((TypeError, ValueError)): - _rank_vocabulary_logits(logits, k=1, largest=True) - - -def test_vocabulary_ranking_rejects_non_tensor_logits() -> None: - with pytest.raises(TypeError, match="torch.Tensor"): - _rank_vocabulary_logits([1.0, 2.0], k=1, largest=True) - - -def test_projection_matches_fresh_final_norm_and_unembedding() -> None: - model = _ReadoutModel() - factors = torch.tensor([[1.0, -2.0, 4.0], [0.25, 0.5, -0.75]]) - - actual = _project_residual_factors(model, factors) - expected = model.unembed(model.ln_final(factors.unsqueeze(0))).squeeze(0) - - torch.testing.assert_close(actual, expected) - assert actual.dtype == torch.float32 - assert actual.device.type == "cpu" - assert actual.grad_fn is None - - -def test_normalized_rows_preserve_norms_and_handle_zero_without_nan() -> None: - factors = torch.tensor([[3.0, 4.0, 0.0], [0.0, 0.0, 0.0]]) - - norms, zero_mask, normalized = _factor_norms_and_normalized_rows(factors) - - assert norms.tolist() == [5.0, 0.0] - assert zero_mask.tolist() == [False, True] - torch.testing.assert_close(normalized[0], torch.tensor([0.6, 0.8, 0.0])) - assert torch.equal(normalized[1], torch.zeros(3)) - assert torch.isfinite(normalized).all() - - -def test_normalized_projection_changes_low_norm_readout_but_keeps_zero_finite() -> None: - model = _ReadoutModel() - factors = torch.tensor([[1e-6, -2e-6, 1e-6], [0.0, 0.0, 0.0]]) - _, _, normalized = _factor_norms_and_normalized_rows(factors) - - raw_logits = _project_residual_factors(model, factors) - normalized_logits = _project_residual_factors(model, normalized) - - assert not torch.allclose(raw_logits[0], normalized_logits[0]) - assert torch.isfinite(normalized_logits).all() - - -def test_projection_sign_symmetry_depends_on_final_norm_bias() -> None: - factors = torch.tensor([[1.0, -2.0, 4.0]]) - bias_free = _ReadoutModel(affine=False) - biased = _ReadoutModel(affine=True) - with torch.no_grad(): - biased.ln_final.bias.fill_(0.25) - - torch.testing.assert_close( - _project_residual_factors(bias_free, -factors), - -_project_residual_factors(bias_free, factors), - ) - assert not torch.allclose( - _project_residual_factors(biased, -factors), - -_project_residual_factors(biased, factors), - ) - - -@pytest.mark.parametrize( - ("tensor", "message"), - [ - ("not a tensor", "torch.Tensor"), - (torch.randn(2, 3), "shape"), - (torch.randn(2, 3, 4), "shape"), - (torch.ones(1, 3, 4, dtype=torch.long), "floating dtype"), - ], -) -def test_single_batch_matrix_rejects_invalid_internal_tensors(tensor, message) -> None: - with pytest.raises(RuntimeError, match=message): - _single_batch_matrix("captured tensor", tensor) - - -def test_matrix_result_rankings_decoding_and_target_rank_conventions() -> None: - logits = torch.tensor([[3.0, 1.0, -2.0, 0.0], [0.0, -1.0, 4.0, 2.0]]) - result = _matrix_result(logits) - - assert result.top(k=2).indices.tolist() == [[0, 1], [2, 3]] - assert result.bottom(k=2).indices.tolist() == [[2, 3], [1, 0]] - assert result.target_ranks(2, largest=True).tolist() == [3, 0] - assert result.gradient_descent_target_ranks(2).tolist() == [0, 3] - assert result.target_ranks(1, largest=True).tolist() == [1, 3] - assert torch.equal(result.logits(normalized=True), logits * 2) - - tokenizer = SimpleNamespace(decode=lambda ids: f"token-{ids[0]}") - assert result.top_tokens(tokenizer, k=1) == [["token-0"], ["token-2"]] - assert result.bottom_tokens(tokenizer, k=1) == [["token-2"], ["token-1"]] - - -def test_matrix_result_rejects_missing_normalized_logits_and_invalid_target() -> None: - logits = torch.randn(2, 4) - result = _matrix_result(logits) - result_without_normalized = _matrix_result(logits, normalized=False) - - with pytest.raises(ValueError, match="were not requested"): - result_without_normalized.logits(normalized=True) - with pytest.raises(ValueError, match="target_token_id"): - result.target_ranks(4, largest=True) - with pytest.raises(TypeError, match="decode"): - result.top_tokens(object(), k=1) - - -@pytest.mark.parametrize("k", [0, 3, True]) -def test_matrix_result_rejects_invalid_retained_ranking_k(k) -> None: - result = _matrix_result(torch.randn(2, 4)) - - with pytest.raises(ValueError, match="retained top_k=2"): - result.top(k=k) - - -def test_matrix_result_keeps_rankings_and_analyzed_target_ranks_without_full_logits() -> None: - result = _matrix_result( - torch.tensor([[3.0, 1.0, -2.0, 0.0], [0.0, -1.0, 4.0, 2.0]]), - return_full_logits=False, - ) - - assert result.vocabulary_logits is None - assert result.normalized_vocabulary_logits is None - assert result.top(k=2).indices.tolist() == [[0, 1], [2, 3]] - assert result.bottom(k=2).indices.tolist() == [[2, 3], [1, 0]] - assert result.gradient_descent_target_ranks(2).tolist() == [0, 3] - assert result.gradient_descent_target_ranks(2, normalized=True).tolist() == [0, 3] - with pytest.raises(ValueError, match="return_full_logits=True"): - result.logits() - with pytest.raises(ValueError, match="return_full_logits=True"): - result.target_ranks(1, largest=True) - - -@pytest.mark.parametrize("normalized", [False, True]) -@pytest.mark.parametrize("return_full_logits", [False, True]) -def test_build_matrix_result_bounds_storage_and_matches_projection( - normalized: bool, return_full_logits: bool -) -> None: - model = _ReadoutModel() - inputs = torch.tensor([[1.0, -2.0, 4.0], [0.25, 0.5, -0.75]]) - output_gradients = torch.tensor([[0.5, -1.0, 2.0], [1.5, 0.25, -0.5]]) - factors = _build_linear_gradient_factors( - inputs, - output_gradients, - inputs.T @ output_gradients, - weight_layout="in_out", - ) - direct = _project_residual_factors(model, inputs) - - result = _build_matrix_result( - model, - factors, - projected_factor="forward_inputs", - include_normalized_logits=normalized, - target_token_id=2, - top_k=2, - return_full_logits=return_full_logits, - ) - - direct_top = torch.topk(direct, k=2, dim=-1, largest=True) - direct_bottom = torch.topk(direct, k=2, dim=-1, largest=False) - assert torch.equal(result.top_ranking.values, direct_top.values) - assert torch.equal(result.top_ranking.indices, direct_top.indices) - assert torch.equal(result.bottom_ranking.values, direct_bottom.values) - assert torch.equal(result.bottom_ranking.indices, direct_bottom.indices) - assert (result.vocabulary_logits is not None) is return_full_logits - assert (result.normalized_top_ranking is not None) is normalized - assert (result.normalized_bottom_ranking is not None) is normalized - assert (result.normalized_vocabulary_logits is not None) is (normalized and return_full_logits) - - -def test_ranking_accessors_return_owned_retained_prefixes() -> None: - result = _matrix_result(torch.tensor([[3.0, 1.0, -2.0, 0.0], [0.0, -1.0, 4.0, 2.0]])) - saved_top_values = result.top_ranking.values.clone() - saved_top_indices = result.top_ranking.indices.clone() - assert result.normalized_bottom_ranking is not None - saved_bottom_values = result.normalized_bottom_ranking.values.clone() - saved_bottom_indices = result.normalized_bottom_ranking.indices.clone() - top = result.top(k=1) - bottom = result.bottom(k=1, normalized=True) - - top.values.add_(100) - top.indices.add_(100) - bottom.values.add_(100) - bottom.indices.add_(100) - - assert torch.equal(result.top_ranking.values, saved_top_values) - assert torch.equal(result.top_ranking.indices, saved_top_indices) - assert torch.equal(result.normalized_bottom_ranking.values, saved_bottom_values) - assert torch.equal(result.normalized_bottom_ranking.indices, saved_bottom_indices) - - -def test_get_dense_mlp_projections_resolves_nn_linear_as_out_in() -> None: - d_model, d_mlp = 4, 6 - mlp = _dense_mlp_bridge( - input_component=torch.nn.Linear(d_model, d_mlp), - output_component=torch.nn.Linear(d_mlp, d_model), - ) - model = _dense_mlp_model(mlp, d_model=d_model, d_mlp=d_mlp) - - projections = _get_dense_mlp_projections(model, (0,)) - - input_record, output_record = projections[0] - assert input_record.weight_layout == "out_in" - assert output_record.weight_layout == "out_in" - assert tuple(input_record.projection.original_component.weight.shape) == (d_mlp, d_model) - assert tuple(output_record.projection.original_component.weight.shape) == (d_model, d_mlp) - - -def test_get_dense_mlp_projections_resolves_conv1d_as_in_out() -> None: - d_model, d_mlp = 4, 6 - mlp = _dense_mlp_bridge( - input_component=Conv1D(d_mlp, d_model), - output_component=Conv1D(d_model, d_mlp), - ) - model = _dense_mlp_model(mlp, d_model=d_model, d_mlp=d_mlp) - - projections = _get_dense_mlp_projections(model, (0,)) - - input_record, output_record = projections[0] - assert input_record.weight_layout == "in_out" - assert output_record.weight_layout == "in_out" - assert tuple(input_record.projection.original_component.weight.shape) == (d_model, d_mlp) - assert tuple(output_record.projection.original_component.weight.shape) == (d_mlp, d_model) - - -def test_get_dense_mlp_projections_rejects_an_unorientable_component() -> None: - d_model, d_mlp = 4, 6 - mlp = _dense_mlp_bridge( - input_component=torch.nn.Identity(), - output_component=torch.nn.Linear(d_mlp, d_model), - ) - model = _dense_mlp_model(mlp, d_model=d_model, d_mlp=d_mlp) - - with pytest.raises(ValueError, match="unknown weight layout"): - _get_dense_mlp_projections(model, (0,)) - - -def test_get_dense_mlp_projections_rejects_a_gated_mlp() -> None: - d_model, d_mlp = 4, 6 - mlp = _dense_mlp_bridge( - input_component=torch.nn.Linear(d_model, d_mlp), - output_component=torch.nn.Linear(d_mlp, d_model), - gate=LinearBridge(name="mlp.gate"), - ) - model = _dense_mlp_model(mlp, d_model=d_model, d_mlp=d_mlp) - - with pytest.raises(ValueError, match="dense, non-gated"): - _get_dense_mlp_projections(model, (0,)) - - -def _linear_mlp(d_model: int = 4, d_mlp: int = 6) -> MLPBridge: - return _dense_mlp_bridge( - input_component=torch.nn.Linear(d_model, d_mlp), - output_component=torch.nn.Linear(d_mlp, d_model), - ) - - -def _mixed_stack_model() -> SimpleNamespace: - """Layer 0 has a dense MLP; layer 1 is an MLP-free block, as in hybrid stacks.""" - return SimpleNamespace( - cfg=SimpleNamespace(d_model=4, d_mlp=6), - blocks=[SimpleNamespace(mlp=_linear_mlp()), SimpleNamespace()], - ) - - -def test_get_dense_mlp_projections_names_a_requested_layer_without_an_mlp() -> None: - model = _mixed_stack_model() - - assert set(_get_dense_mlp_projections(model, (0,))) == {0} - with pytest.raises(ValueError, match="layer 1 has no MLP.*SimpleNamespace has no mlp"): - _get_dense_mlp_projections(model, (0, 1)) - - -def test_unconverted_mlp_blocks_accepts_a_stack_with_some_mlp_free_blocks() -> None: - _require_unconverted_mlp_blocks(_mixed_stack_model().blocks) - - -def test_unconverted_mlp_blocks_refuses_a_stack_without_any_mlp() -> None: - with pytest.raises(ValueError, match=r"no block has an mlp.*block types: SimpleNamespace\)"): - _require_unconverted_mlp_blocks([SimpleNamespace(), SimpleNamespace()]) - - -@pytest.mark.parametrize("projection_key", ["in", "out"]) -@pytest.mark.parametrize("hook_name", ["hook_in", "hook_out"]) -def test_unconverted_mlp_blocks_refuses_a_converted_projection_hook( - projection_key: str, hook_name: str -) -> None: - converted = _linear_mlp() - hook_point = getattr(getattr(converted, projection_key), hook_name) - hook_point.hook_conversion = RearrangeTensorConversion("(b p) d -> b p d", b=1) - blocks = [SimpleNamespace(mlp=_linear_mlp()), SimpleNamespace(), SimpleNamespace(mlp=converted)] - name = "input" if projection_key == "in" else "output" - - with pytest.raises( - ValueError, - match=rf"layer 2 {name} projection {hook_name} .*RearrangeTensorConversion.*bypass", - ): - _require_unconverted_mlp_blocks(blocks) - - -def _native_dense_cfg(**overrides: Any) -> TransformerBridgeConfig: - fields: dict[str, Any] = dict( - n_layers=2, - d_model=8, - d_head=4, - n_heads=2, - d_mlp=16, - d_vocab=12, - n_ctx=8, - act_fn="gelu", - normalization_type="LN", - seed=0, - ) - fields.update(overrides) - return TransformerBridgeConfig(**fields) - - -def _with_tokenizer(model: TransformerBridge) -> TransformerBridge: - # Only its presence is checked by the constructor. - model.tokenizer = SimpleNamespace() - return model - - -def test_constructor_refuses_a_bridge_whose_blocks_have_no_mlp() -> None: - model = _with_tokenizer(TransformerBridge.boot_native(_native_dense_cfg(attn_only=True))) - - with pytest.raises(ValueError, match="no block has an mlp submodule"): - BackwardLens(model) - - -def test_constructor_refuses_a_converted_mlp_projection_hook() -> None: - model = _with_tokenizer(TransformerBridge.boot_native(_native_dense_cfg())) - BackwardLens(model) - - model.blocks[1].mlp.out.hook_in.hook_conversion = RearrangeTensorConversion( - "(b p) d -> b p d", b=1 - ) - with pytest.raises(ValueError, match="layer 1 output projection hook_in"): - BackwardLens(model) - - -def _native_lens_that_must_not_run(monkeypatch) -> tuple[TransformerBridge, BackwardLens]: - """A raw dense-MLP native Bridge in eval mode whose forward raises.""" - model = TransformerBridge.boot_native(_native_dense_cfg()).eval() - # Only its presence is checked; tokenization itself is replaced below. - model.tokenizer = SimpleNamespace() - - def one_token_per_word(text: str, **kwargs) -> torch.Tensor: - return torch.arange(1, len(text.split()) + 1).unsqueeze(0) - - def forward_ran(*args) -> None: - raise AssertionError("Backward Lens ran a forward") - - monkeypatch.setattr(model, "to_tokens", one_token_per_word) - model.register_forward_pre_hook(forward_ran) - return model, BackwardLens(model) - - -def _training_flags(model: TransformerBridge) -> list[tuple[str, bool]]: - modules = [*model.named_modules(), *model.original_model.named_modules()] - return [(name, module.training) for name, module in modules] - - -@pytest.mark.parametrize( - ("enter_training", "root_training"), - [ - (lambda model: model.train(), True), - (lambda model: model.blocks[1].mlp.train(), False), - ], - ids=["whole-model", "one-mlp"], -) -def test_analyze_refuses_training_mode_before_any_forward( - monkeypatch, enter_training, root_training -) -> None: - model, lens = _native_lens_that_must_not_run(monkeypatch) - # In eval mode the same call reaches the forward, so the sentinel is live. - with pytest.raises(AssertionError, match="ran a forward"): - lens.analyze("prompt", " token", [0]) - - enter_training(model) - flags = _training_flags(model) - with pytest.raises(ValueError, match=r"BackwardLens\.analyze\(\) .*model\.eval\(\)"): - lens.analyze("prompt", " token", [0]) - assert model.training is root_training - assert _training_flags(model) == flags - - -@pytest.mark.parametrize( - ("prompt", "target", "layers", "match"), - [ - ("prompt", " token", [2], r"layers must be in \[0, 1\]"), - ("prompt", " two tokens", [0], "exactly one token"), - (" ".join(["word"] * 9), " token", [0], "exceeds model context limit"), - ], - ids=["layer-out-of-range", "multi-token-target", "prompt-over-n_ctx"], -) -def test_analyze_reports_invalid_inputs_before_training_mode( - monkeypatch, prompt: str, target: str, layers: list[int], match: str -) -> None: - model, lens = _native_lens_that_must_not_run(monkeypatch) - model.train() - with pytest.raises(ValueError, match=match): - lens.analyze(prompt, target, layers) - - -def test_public_backward_lens_symbols_are_exported() -> None: - from transformer_lens.tools.analysis import ( - BackwardLens, - BackwardLensLayerResult, - BackwardLensMatrixResult, - BackwardLensResult, - ) - - _ = ( - BackwardLens, - BackwardLensLayerResult, - BackwardLensMatrixResult, - BackwardLensResult, - ) diff --git a/tests/unit/tools/test_direct_logit_attribution.py b/tests/unit/tools/test_direct_logit_attribution.py index 2012720f35..81fc6a024e 100644 --- a/tests/unit/tools/test_direct_logit_attribution.py +++ b/tests/unit/tools/test_direct_logit_attribution.py @@ -58,7 +58,6 @@ def test_direct_logit_attribution_is_exported_from_analysis_package(): from transformer_lens.tools import analysis assert analysis.direct_logit_attribution is direct_logit_attribution - assert hasattr(analysis, "DirectLogitAttribution") def _native_bridge(compat=True): diff --git a/tests/unit/tools/test_jacobian_lens.py b/tests/unit/tools/test_jacobian_lens.py index 2f52c0329f..e62ece2698 100644 --- a/tests/unit/tools/test_jacobian_lens.py +++ b/tests/unit/tools/test_jacobian_lens.py @@ -1,6 +1,7 @@ """Unit tests for the TransformerBridge-only Jacobian lens implementation.""" import gc +import re from dataclasses import FrozenInstanceError from enum import IntEnum from inspect import Parameter, signature @@ -33,14 +34,18 @@ DeepseekV4BlockBridge, ) from transformer_lens.tools.analysis import ( - CoordinatePatch, JacobianLens, JSpaceDecomposition, - JSpaceOccupancy, - JSpaceVarianceProfile, get_sparse_decomposition, solve_coordinate_patch, ) +from transformer_lens.tools.analysis.jacobian_lens_coordinate_patch import ( + CoordinatePatch, +) +from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( + JSpaceOccupancy, + JSpaceVarianceProfile, +) from transformer_lens.utilities.activation_functions import apply_softcap from transformer_lens.utilities.parameter_swap import temporarily_swap_parameter @@ -305,13 +310,22 @@ def test_readout_final_layer_reuses_model_outputs( assert torch.equal(result.lens_topk_indices[final_layer], result.model_topk_indices) -def test_unembed_rows_applies_softcap_before_fp32_conversion() -> None: +def _identity_readout_logits(model: _ToyBridge) -> tuple[torch.Tensor, torch.Tensor]: + """Identity-lens layer-0 readout logits and the raw logits of the residual they read.""" + prompt = "a toy prompt" + result = _lens().readout(model, prompt, layers=[0], return_full_logits=True) + assert result.lens_logits is not None + _, cache = model.run_with_cache(model.to_tokens(prompt)) + residual = cache["blocks.0.hook_out"][0] + return result.lens_logits[0], model.ln_final(residual) @ model.W_U + model.b_U + + +def test_readout_applies_softcap_before_fp32_conversion() -> None: model = _ToyBridge(dtype=torch.bfloat16) model.cfg.output_logits_soft_cap = 0.75 - residual = 4 * torch.randn(3, D_MODEL) - actual = jacobian_lens_module._unembed(model, residual) - raw_logits = model.unembed(model.ln_final(residual.to(torch.bfloat16).unsqueeze(0))).squeeze(0) + actual, raw_logits = _identity_readout_logits(model) + assert raw_logits.dtype == torch.bfloat16 expected = ( model.cfg.output_logits_soft_cap * torch.tanh(raw_logits / model.cfg.output_logits_soft_cap) ).float() @@ -323,17 +337,15 @@ def test_unembed_rows_applies_softcap_before_fp32_conversion() -> None: assert not torch.equal(actual, fp32_first) -def test_unembed_rows_applies_architecture_logit_scale_before_softcap() -> None: +def test_readout_applies_architecture_logit_scale_before_softcap() -> None: model = _ToyBridge() model.cfg.logit_scale = 0.125 model.cfg.output_logits_soft_cap = 0.75 model.adapter.apply_output_logits_transform = lambda logits: apply_softcap( logits * model.cfg.logit_scale, model.cfg.output_logits_soft_cap ) - residual = 4 * torch.randn(3, D_MODEL) - actual = jacobian_lens_module._unembed(model, residual) - raw_logits = model.unembed(model.ln_final(residual.unsqueeze(0))).squeeze(0) + actual, raw_logits = _identity_readout_logits(model) scaled_logits = raw_logits * model.cfg.logit_scale expected = model.cfg.output_logits_soft_cap * torch.tanh( scaled_logits / model.cfg.output_logits_soft_cap @@ -348,15 +360,13 @@ def test_unembed_rows_applies_architecture_logit_scale_before_softcap() -> None: assert not torch.allclose(actual, wrong_order) -def test_unembed_rows_ignores_unowned_logit_scale_config() -> None: +def test_readout_ignores_unowned_logit_scale_config() -> None: model = _ToyBridge() model.cfg.logit_scale = "inv_sqrt_d_model" - residual = torch.randn(2, D_MODEL) - actual = jacobian_lens_module._unembed(model, residual) - expected = model.unembed(model.ln_final(residual.unsqueeze(0))).squeeze(0).float() + actual, raw_logits = _identity_readout_logits(model) - torch.testing.assert_close(actual, expected) + torch.testing.assert_close(actual, raw_logits) def test_transported_readout_matches_linear_model( @@ -796,6 +806,110 @@ def test_fit_rejects_negative_skip_first_positions(toy_model: _ToyBridge) -> Non ) +def _forbid_fit_work(*args: Any, **kwargs: Any) -> Any: + raise AssertionError("invalid fit options reached the transport-matrix fit") + + +@pytest.mark.parametrize( + "options, message", + [ + ({"corpus": ""}, "corpus"), + ({"corpus": " "}, "corpus"), + ({"source_layers": []}, "source_layers"), + ({"source_layers": [N_LAYERS - 1]}, "target_layer"), + ({"source_layers": [N_LAYERS]}, "out of range"), + ({"dim_batch": 0}, "dim_batch"), + ({"skip_first_positions": -1}, "skip_first_positions"), + ], +) +def test_fit_rejects_invalid_options_before_fit_work( + options: dict[str, Any], message: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(jacobian_lens_module, "_fit_transport_matrices", _forbid_fit_work) + with pytest.raises(ValueError, match=message): + JacobianLens.fit(_ToyBridge(), ["a toy prompt"], **{"corpus": CORPUS, **options}) + + +@pytest.mark.parametrize( + "state, message", + [("compatibility", "compatibility mode"), ("processed", "process_weights")], +) +def test_fit_rejects_compatibility_or_processed_bridge(state: str, message: str) -> None: + model = _ToyBridge() + if state == "compatibility": + model.compatibility_mode = True + else: + model._weights_processed = True + with pytest.raises(ValueError, match=message): + JacobianLens.fit(model, ["a toy prompt"], corpus=CORPUS, show_progress=False) + + +def test_fit_rejects_non_bridge() -> None: + with pytest.raises(TypeError, match="TransformerBridge"): + JacobianLens.fit(object(), ["a toy prompt"], corpus=CORPUS) + + +@pytest.mark.parametrize( + "key", + [ + "model_name", + "model_revision", + "transformer_lens_version", + "model_system", + "estimator", + "processing", + "hook_convention", + "corpus", + "n_prompts", + "fit_dtype", + "target_layer", + "dim_batch", + "max_seq_len", + "skip_first_positions", + "transformer_lens_fit", + ], +) +def test_fit_reserves_provenance_keys_before_fit_work( + key: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(jacobian_lens_module, "_fit_transport_matrices", _forbid_fit_work) + with pytest.raises(ValueError, match="cannot override fit provenance"): + JacobianLens.fit(_ToyBridge(), ["a toy prompt"], corpus=CORPUS, metadata={key: None}) + + +@pytest.mark.parametrize("value", [torch.tensor(1), {"unsafe"}, object()]) +def test_fit_rejects_unsafe_user_metadata_before_fit_work( + value: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(jacobian_lens_module, "_fit_transport_matrices", _forbid_fit_work) + with pytest.raises(ValueError, match="metadata"): + JacobianLens.fit(_ToyBridge(), ["a toy prompt"], corpus=CORPUS, metadata={"run": value}) + + +def test_fit_preserves_custom_metadata_without_mutating_it() -> None: + metadata = {"run": {"seed": 0, "tags": ["offline", "fit"]}} + lens = JacobianLens.fit( + _ToyBridge(), + ["a toy prompt"], + corpus=CORPUS, + skip_first_positions=SKIP_FIRST, + show_progress=False, + metadata=metadata, + ) + assert lens.metadata["run"] == metadata["run"] + assert set(metadata) == {"run"} + assert lens.metadata["n_prompts"] == lens.n_prompts == 1 + + +def test_artifact_without_estimator_provenance_loads_as_jacobian_lens(tmp_path: Any) -> None: + path = tmp_path / "legacy.pt" + JacobianLens({0: torch.eye(D_MODEL)}, n_prompts=1, d_model=D_MODEL).save(str(path)) + + loaded = JacobianLens.load(str(path)) + assert loaded.estimator == "jacobian_lens" + assert loaded.metadata == {} + + def _prepare_toy_fit(dtype: torch.dtype = torch.float32) -> Any: return jacobian_lens_module._prepare_fit( _ToyBridge(dtype=dtype), @@ -849,26 +963,6 @@ def warn(message: str, category: Any, *, stacklevel: int) -> None: assert calls[0][1:] == (UserWarning, 3) -def test_estimators_share_fit_preparation_and_provenance_helpers() -> None: - import transformer_lens.tools.analysis.relevance_lens as relevance_lens_module - - assert relevance_lens_module._prepare_fit is jacobian_lens_module._prepare_fit - assert relevance_lens_module._build_fit_metadata is jacobian_lens_module._build_fit_metadata - - -def test_estimator_fields_cannot_override_common_fit_provenance() -> None: - model = _ToyBridge() - with pytest.raises(ValueError, match="estimator metadata cannot override"): - jacobian_lens_module._build_fit_metadata( - model, - _prepare_toy_fit(), - estimator="jacobian_lens", - n_prompts=1, - metadata=None, - estimator_metadata={"corpus": "other"}, - ) - - def test_fit_signature_requires_corpus_and_has_no_target_layer() -> None: parameters = signature(JacobianLens.fit).parameters assert parameters["corpus"].default is Parameter.empty @@ -922,11 +1016,11 @@ def test_lens_vectors_rejects_empty_or_out_of_range_tokens(tokens: list[int]) -> @pytest.mark.parametrize("token", [True, False]) -def test_to_token_ids_rejects_bool(token: bool) -> None: +def test_lens_vectors_rejects_bool_token(token: bool) -> None: """A bool token must not silently coerce to id 1/0 via int(token).""" model = _ToyBridge() with pytest.raises(ValueError, match="bool"): - jacobian_lens_module._to_token_ids(model, [token]) + _lens().lens_vectors(model, [token], 0) def _intervention_hooks( @@ -1025,9 +1119,14 @@ def record_device( assert calls == [activation.device] * expected_transfers -def test_swap_rejects_identical_tokens(toy_model: _ToyBridge) -> None: - with pytest.raises(ValueError, match="same token|identical|distinct"): - _lens().swap_hooks(toy_model, 3, 3, layers=[0]) +# The toy tokenizer maps a string to its length, so " ab" spells id 3. +_SAME_ID_SPELLINGS = pytest.mark.parametrize("source", [3, " ab"], ids=["id", "string"]) + + +@_SAME_ID_SPELLINGS +def test_swap_rejects_identical_tokens(toy_model: _ToyBridge, source: Any) -> None: + with pytest.raises(ValueError, match="same token id; a coordinate swap would be"): + _lens().swap_hooks(toy_model, source, 3, layers=[0]) def test_swap_warns_for_near_parallel_vectors() -> None: @@ -1078,7 +1177,7 @@ def test_swap_clamp_holds_each_layer_at_exchanged_clean_coordinates( tokens = toy_model.to_tokens("a toy prompt") _, clean_cache_dict = toy_model.run_with_cache(tokens) clean_cache = ActivationCache(clean_cache_dict, toy_model) - hooks = fitted_lens.swap_clamp_hooks(toy_model, 3, 5, layers, clean_cache) + hooks = fitted_lens.swap_hooks(toy_model, 3, 5, layers, clamp_to=clean_cache) with toy_model.hooks(fwd_hooks=hooks): _, clamped_cache = toy_model.run_with_cache(tokens) @@ -1099,7 +1198,7 @@ def test_swap_clamp_accepts_plain_cache_dict( tokens = toy_model.to_tokens("a toy prompt") _, clean_cache_dict = toy_model.run_with_cache(tokens, return_cache_object=False) assert isinstance(clean_cache_dict, dict) - hooks = fitted_lens.swap_clamp_hooks(toy_model, 3, 5, [layer], clean_cache_dict) + hooks = fitted_lens.swap_hooks(toy_model, 3, 5, [layer], clamp_to=clean_cache_dict) with toy_model.hooks(fwd_hooks=hooks): _, clamped_cache = toy_model.run_with_cache(tokens) @@ -1112,19 +1211,56 @@ def test_swap_clamp_accepts_plain_cache_dict( torch.testing.assert_close(clamped_coords, clean_coords[..., [1, 0]], atol=1e-5, rtol=1e-5) +def test_deprecated_swap_clamp_hooks_builds_the_clamp_to_hooks( + toy_model: _ToyBridge, fitted_lens: JacobianLens +) -> None: + tokens = toy_model.to_tokens("a toy prompt") + _, clean_cache = toy_model.run_with_cache(toy_model.to_tokens("another toy prompt")) + with pytest.warns(DeprecationWarning, match=r"swap_hooks\(.*clamp_to=clean_cache\)"): + old = fitted_lens.swap_clamp_hooks(toy_model, 3, 5, [1, 2], clean_cache, positions=[0, -1]) + new = fitted_lens.swap_hooks(toy_model, 3, 5, [1, 2], positions=[0, -1], clamp_to=clean_cache) + + with toy_model.hooks(fwd_hooks=old): + old_logits = toy_model(tokens) + with toy_model.hooks(fwd_hooks=new): + new_logits = toy_model(tokens) + + assert torch.equal(old_logits, new_logits) + assert not torch.equal(new_logits, toy_model(tokens)) + + +def test_swap_clamp_alpha_moves_that_fraction_toward_the_clean_exchange( + toy_model: _ToyBridge, fitted_lens: JacobianLens +) -> None: + layer = 1 + name = f"blocks.{layer}.hook_out" + tokens = toy_model.to_tokens("a toy prompt") + _, clean_cache = toy_model.run_with_cache(toy_model.to_tokens("another toy prompt")) + _, live_cache = toy_model.run_with_cache(tokens) + hooks = fitted_lens.swap_hooks(toy_model, 3, 5, [layer], alpha=0.25, clamp_to=clean_cache) + + with toy_model.hooks(fwd_hooks=hooks): + _, clamped_cache = toy_model.run_with_cache(tokens) + + pinv = torch.linalg.pinv(fitted_lens.lens_vectors(toy_model, [3, 5], layer).T) + clean_coords = clean_cache[name].float() @ pinv.T + live_coords = live_cache[name].float() @ pinv.T + expected = live_coords + 0.25 * (clean_coords[..., [1, 0]] - live_coords) + torch.testing.assert_close(clamped_cache[name].float() @ pinv.T, expected) + + def test_swap_clamp_requires_each_clean_activation(toy_model: _ToyBridge) -> None: - with pytest.raises(ValueError, match="clean_cache is missing"): - _lens().swap_clamp_hooks( - toy_model, 3, 5, layers=[0], clean_cache=ActivationCache({}, toy_model) - ) + with pytest.raises(ValueError, match="clamp_to is missing"): + _lens().swap_hooks(toy_model, 3, 5, layers=[0], clamp_to=ActivationCache({}, toy_model)) -def test_swap_clamp_rejects_identical_tokens(toy_model: _ToyBridge) -> None: +@_SAME_ID_SPELLINGS +def test_swap_clamp_rejects_identical_tokens(toy_model: _ToyBridge, source: Any) -> None: tokens = toy_model.to_tokens("a toy prompt") _, clean_cache_dict = toy_model.run_with_cache(tokens) clean_cache = ActivationCache(clean_cache_dict, toy_model) - with pytest.raises(ValueError, match="same token|identical|distinct"): - _lens().swap_clamp_hooks(toy_model, 3, 3, layers=[0], clean_cache=clean_cache) + with pytest.raises(ValueError, match="same token id; a coordinate clamp would be"): + _lens().swap_hooks(toy_model, source, 3, layers=[0], clamp_to=clean_cache) def test_swap_clamp_warns_for_near_parallel_vectors() -> None: @@ -1139,7 +1275,7 @@ def test_swap_clamp_warns_for_near_parallel_vectors() -> None: _, clean_cache_dict = model.run_with_cache(tokens) clean_cache = ActivationCache(clean_cache_dict, model) with pytest.warns(UserWarning, match="parallel|ill-conditioned|poorly conditioned"): - hooks = _lens().swap_clamp_hooks(model, 3, 5, layers=[0], clean_cache=clean_cache) + hooks = _lens().swap_hooks(model, 3, 5, layers=[0], clamp_to=clean_cache) assert hooks[0][0] == "blocks.0.hook_out" @@ -1150,7 +1286,7 @@ def test_swap_clamp_validates_clean_activation_shape(toy_model: _ToyBridge) -> N name = "blocks.0.hook_out" clean_cache.cache_dict[name] = clean_cache[name][..., :-1] with pytest.raises(ValueError, match="d_model|shape"): - _lens().swap_clamp_hooks(toy_model, 3, 5, layers=[0], clean_cache=clean_cache) + _lens().swap_hooks(toy_model, 3, 5, layers=[0], clamp_to=clean_cache) def test_swap_clamp_validates_clean_live_batch_shape(toy_model: _ToyBridge) -> None: @@ -1159,7 +1295,7 @@ def test_swap_clamp_validates_clean_live_batch_shape(toy_model: _ToyBridge) -> N clean_cache = ActivationCache(clean_cache_dict, toy_model) name = "blocks.0.hook_out" clean_cache.cache_dict[name] = clean_cache[name].repeat(2, 1, 1) - hooks = _lens().swap_clamp_hooks(toy_model, 3, 5, layers=[0], clean_cache=clean_cache) + hooks = _lens().swap_hooks(toy_model, 3, 5, layers=[0], clamp_to=clean_cache) with pytest.raises(ValueError, match="incompatible with the live activation"): with toy_model.hooks(fwd_hooks=hooks): toy_model(tokens) @@ -1174,8 +1310,8 @@ def test_swap_clamp_positions_and_orthogonal_complement( tokens = toy_model.to_tokens("a toy prompt") _, clean_cache_dict = toy_model.run_with_cache(tokens) clean_cache = ActivationCache(clean_cache_dict, toy_model) - hooks = fitted_lens.swap_clamp_hooks( - toy_model, 3, 5, layers=[layer], clean_cache=clean_cache, positions=positions + hooks = fitted_lens.swap_hooks( + toy_model, 3, 5, layers=[layer], clamp_to=clean_cache, positions=positions ) with toy_model.hooks(fwd_hooks=hooks): _, clamped_cache = toy_model.run_with_cache(tokens) @@ -1202,8 +1338,6 @@ def test_exports() -> None: from transformer_lens.tools import analysis assert analysis.JacobianLens is JacobianLens - assert hasattr(analysis, "JacobianLensReadout") - assert analysis.CoordinatePatch is CoordinatePatch assert analysis.solve_coordinate_patch is solve_coordinate_patch @@ -1229,8 +1363,9 @@ def test_registry_is_valid_json_and_nonempty(self) -> None: assert path.is_file(), f"Registry not found at {path}" with path.open() as fh: registry = json.load(fh) - model_entries = {k: v for k, v in registry.items() if not k.startswith("_")} + model_entries = {k: v for k, v in registry.items() if isinstance(v, dict)} assert len(model_entries) >= 30, "Expected at least 30 model entries" + assert re.fullmatch(r"[0-9a-f]{40}", registry["revision"]) def test_registry_entries_have_required_fields(self) -> None: import json @@ -1246,7 +1381,7 @@ def test_registry_entries_have_required_fields(self) -> None: with path.open() as fh: registry = json.load(fh) for key, entry in registry.items(): - if key.startswith("_"): + if key.startswith("_") or key == "revision": continue assert "repo_id" in entry, f"Missing repo_id in entry '{key}'" assert "filename" in entry, f"Missing filename in entry '{key}'" @@ -1285,6 +1420,40 @@ def test_resolve_registry_entry_unknown_returns_none(self) -> None: assert _resolve_registry_entry("not-a-real-model") is None assert _resolve_registry_entry("some/unknown-repo") is None + assert _resolve_registry_entry("revision") is None + + @pytest.mark.parametrize( + ("name", "revision", "expected"), + [ + ("gemma-2-2b", None, "registry"), + ("google/gemma-2-2b", None, "registry"), + ("gemma-2-2b", "refs/pr/1", "refs/pr/1"), + ("my-org/my-repo", None, None), + ], + ) + def test_from_pretrained_pins_registry_names_to_the_registry_revision( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, + name: str, + revision: Optional[str], + expected: Optional[str], + ) -> None: + """Registry names load the registry's commit unless the caller pins one.""" + path = tmp_path / "lens.pt" + _lens().save(str(path)) + calls: dict[str, Any] = {} + + def fake_retry(function: Any, **kwargs: Any) -> str: + calls.update(kwargs) + return str(path) + + monkeypatch.setattr(jacobian_lens_module, "call_hf_with_retry", fake_retry) + JacobianLens.from_pretrained(name, revision=revision) + + pinned = jacobian_lens_module._load_registry()["revision"] + assert re.fullmatch(r"[0-9a-f]{40}", pinned) + assert calls["revision"] == (pinned if expected == "registry" else expected) def test_from_pretrained_resolves_short_name_via_registry( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any diff --git a/tests/unit/tools/test_jacobian_lens_coordinate_patch_hooks.py b/tests/unit/tools/test_jacobian_lens_coordinate_patch_hooks.py index d393f0f9e8..b279136f59 100644 --- a/tests/unit/tools/test_jacobian_lens_coordinate_patch_hooks.py +++ b/tests/unit/tools/test_jacobian_lens_coordinate_patch_hooks.py @@ -54,9 +54,13 @@ def test_coordinate_patch_hooks_requires_nonempty_positions(toy_model: _ToyBridg _lens().coordinate_patch_hooks(toy_model, 3, 5, layers=[0], positions=[]) -def test_coordinate_patch_hooks_rejects_identical_tokens(toy_model: _ToyBridge) -> None: - with pytest.raises(ValueError, match="same token|identical|distinct"): - _lens().coordinate_patch_hooks(toy_model, 3, 3, layers=[0], positions=[0]) +# The toy tokenizer maps a string to its length, so " ab" spells id 3. +@pytest.mark.parametrize("source", [3, " ab"], ids=["id", "string"]) +def test_coordinate_patch_hooks_rejects_identical_tokens( + toy_model: _ToyBridge, source: Any +) -> None: + with pytest.raises(ValueError, match="same token id; a coordinate patch would be"): + _lens().coordinate_patch_hooks(toy_model, source, 3, layers=[0], positions=[0]) def test_coordinate_patch_hooks_changes_only_requested_positions(toy_model: _ToyBridge) -> None: diff --git a/tests/unit/tools/test_logit_lens.py b/tests/unit/tools/test_logit_lens.py index fac7cc09d5..4c65abdf91 100644 --- a/tests/unit/tools/test_logit_lens.py +++ b/tests/unit/tools/test_logit_lens.py @@ -17,12 +17,8 @@ LinearBridge, ) from transformer_lens.model_bridge.sources import build_bridge_from_module -from transformer_lens.tools.analysis import ( - LogitLensResult, - LogitReadout, - logit_lens, - logit_readout, -) +from transformer_lens.tools.analysis import logit_lens, logit_readout +from transformer_lens.tools.analysis.logit_lens import LogitLensResult, LogitReadout D_VOCAB = 64 D_MODEL = 32 @@ -403,7 +399,7 @@ def forward_ran(*args, **kwargs): logit_lens(model, TOKENS, chunk_size=0) with pytest.raises(ValueError, match="top_k must be"): logit_lens(model, TOKENS, top_k=D_VOCAB + 1) - with pytest.raises(ValueError, match="within"): + with pytest.raises(ValueError, match="out of range"): logit_lens(model, TOKENS, targets=[D_VOCAB]) @@ -417,6 +413,15 @@ def test_mixed_targets_keep_positional_names(tiny, monkeypatch): result.rank_trajectory(" y") +def test_shared_targets_reject_bool_and_empty(tiny): + model, _, cache = tiny + resid = cache["blocks.1.hook_resid_post"] + with pytest.raises(ValueError, match="not bool"): + logit_readout(model, resid, targets=[True]) + with pytest.raises(ValueError, match="at least one token"): + logit_readout(model, resid, targets=[]) + + def test_tensor_targets_and_vocab_need_integer_dtypes(tiny): model, _, cache = tiny resid = cache["blocks.1.hook_resid_post"] diff --git a/tests/unit/tools/test_model_state.py b/tests/unit/tools/test_model_state.py new file mode 100644 index 0000000000..d63c06f64d --- /dev/null +++ b/tests/unit/tools/test_model_state.py @@ -0,0 +1,85 @@ +"""Unit tests for the validators shared by the analysis tools.""" + +import re +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from transformer_lens.tools.analysis._model_state import ( + hybrid_block_types, + require_int, + to_token_ids, +) + + +@pytest.mark.parametrize("value", [True, False, 1.0, "3", None]) +def test_require_int_refuses_bools_and_non_integers(value: object) -> None: + with pytest.raises(ValueError, match="k must be an integer, got"): + require_int(value, "k") + + +def test_require_int_accepts_inclusive_bounds_and_numpy_integers() -> None: + assert require_int(1, "k") == 1 + assert require_int(3, "k", maximum=3) == 3 + result = require_int(np.int64(4), "k") + assert result == 4 and type(result) is int + + +@pytest.mark.parametrize( + ("kwargs", "value", "message"), + [ + ({}, 0, "k must be a positive integer, got 0"), + ({"minimum": 0}, -1, "k must be a nonnegative integer, got -1"), + ({"minimum": 2}, 1, "k must be an integer at least 2, got 1"), + ({"maximum": 3}, 4, "k must be between 1 and 3, got 4"), + ], +) +def test_require_int_names_the_violated_range( + kwargs: dict[str, int], value: int, message: str +) -> None: + with pytest.raises(ValueError, match=re.escape(message)): + require_int(value, "k", **kwargs) + + +@pytest.mark.parametrize("value", [True, 0]) +def test_require_int_message_replaces_both_failures(value: object) -> None: + with pytest.raises(ValueError, match="^ks must hold integers$"): + require_int(value, "k", message="ks must hold integers") + + +def test_hybrid_block_types_are_sorted_distinct_and_default_to_none() -> None: + model = SimpleNamespace( + layer_types=lambda: ["mamba+mlp", "attn+mlp", "linear_attn", "mamba+mlp"] + ) + assert hybrid_block_types(model) == ["linear_attn", "mamba+mlp"] + assert hybrid_block_types(SimpleNamespace()) == [] + + +def _vocab_model(d_vocab: int = 10) -> SimpleNamespace: + return SimpleNamespace(W_U=torch.zeros(4, d_vocab), to_single_token=len) + + +@pytest.mark.parametrize("token", [1.9, np.float64(2.0), torch.tensor(2.0), None]) +def test_to_token_ids_refuses_non_integer_ids(token: object) -> None: + with pytest.raises(TypeError, match="strings or integer token ids, got"): + to_token_ids(_vocab_model(), [3, token]) + + +def test_to_token_ids_accepts_every_integer_spelling() -> None: + ids = to_token_ids(_vocab_model(), ["abc", np.int64(3), torch.tensor(3, dtype=torch.int32)]) + assert ids == [3, 3, 3] and all(type(token_id) is int for token_id in ids) + + +@pytest.mark.parametrize( + "token", [3, np.int64(3), torch.tensor(3), torch.tensor(3, dtype=torch.uint8)] +) +def test_to_token_ids_resolves_a_bare_id_like_a_one_element_sequence(token: object) -> None: + assert to_token_ids(_vocab_model(), token) == to_token_ids(_vocab_model(), [token]) == [3] + + +@pytest.mark.parametrize("token", [True, torch.tensor(2.0), torch.tensor(True)]) +def test_to_token_ids_refuses_a_bare_non_integer_id(token: object) -> None: + with pytest.raises((TypeError, ValueError), match="integer token id"): + to_token_ids(_vocab_model(), token) diff --git a/tests/unit/tools/test_relevance_lens.py b/tests/unit/tools/test_relevance_lens.py deleted file mode 100644 index 7bd7983d27..0000000000 --- a/tests/unit/tools/test_relevance_lens.py +++ /dev/null @@ -1,1184 +0,0 @@ -"""Unit tests for ``RelevanceLens.fit``: shape/finite parity and rule coverage. - -These tests build a tiny random Qwen2 fully offline (programmatic HF config, -random weights, no network access) so the fit runs on a real assembled -``TransformerBridge`` with the opaque gated-MLP native-forward path. Both -estimators are fitted on the same model and prompts, so the only difference -between the two sets of transport matrices is the backward semantics each -estimator selects. -""" - -from __future__ import annotations - -import warnings -from typing import Any, NamedTuple, Sequence - -import pytest -import torch -from tokenizers import Tokenizer -from tokenizers.models import WordLevel -from tokenizers.pre_tokenizers import Whitespace -from transformers import AutoConfig, AutoModelForCausalLM, PreTrainedTokenizerFast - -import transformer_lens.tools.analysis.jacobian_lens as jacobian_lens_module -import transformer_lens.tools.analysis.relevance_lens as relevance_lens_module -from tests.unit.tools.conftest import _ToyBridge -from transformer_lens.factories.architecture_adapter_factory import ( - ArchitectureAdapterFactory, -) -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRuleCoverage, - RelevanceRuleUnsupportedError, - _RelevanceRuleCoverageEntry, - use_relevance_rules, -) -from transformer_lens.model_bridge.bridge import TransformerBridge -from transformer_lens.model_bridge.sources import build_bridge_config_from_hf -from transformer_lens.model_bridge.supported_architectures.gpt2 import ( - GPT2ArchitectureAdapter, -) -from transformer_lens.model_bridge.supported_architectures.qwen2 import ( - Qwen2ArchitectureAdapter, -) -from transformer_lens.tools.analysis import JacobianLens -from transformer_lens.tools.analysis.relevance_lens import ( - RELEVANCE_RULE_VERSION, - RELEVANCE_RULES, - RelevanceLens, - _installed_rule_names, -) - -N_LAYERS = 2 -D_MODEL = 32 -SOURCE_LAYERS = [0] -CORPUS = "unit-test-corpus" -PROMPTS = [ - "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi " - "omicron pi rho sigma tau upsilon phi chi psi omega alpha beta gamma delta", - "one two three four five six seven eight nine ten eleven twelve thirteen " - "fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one " - "twenty two twenty three twenty four twenty five twenty six twenty seven", - "red orange yellow green blue indigo violet cyan magenta amber teal olive " - "maroon navy silver gold bronze copper crimson scarlet azure beige ivory " - "khaki lilac peach plum russet saffron tan umber", -] -TINY_DIMS = dict( - vocab_size=97, - hidden_size=D_MODEL, - intermediate_size=64, - num_hidden_layers=N_LAYERS, - num_attention_heads=4, - num_key_value_heads=2, - max_position_embeddings=64, - pad_token_id=0, - bos_token_id=1, - eos_token_id=2, -) - - -def _offline_tokenizer(prompts: Sequence[str]) -> PreTrainedTokenizerFast: - """Word-level tokenizer over the prompts' own vocabulary, built offline. - - A real ``PreTrainedTokenizerFast`` (rather than a duck-typed stub) satisfies - the jaxtyping contract on ``TransformerBridge.to_tokens``, and deriving the - vocabulary from the prompts keeps every word in-vocab so no prompt collapses - to a run of ````. - """ - words = sorted({word for prompt in prompts for word in prompt.split()}) - vocabulary = {"": 0, "": 1, "": 2, "": 3} - vocabulary.update({word: index + 4 for index, word in enumerate(words)}) - backend = Tokenizer(WordLevel(vocabulary, unk_token="")) - backend.pre_tokenizer = Whitespace() - return PreTrainedTokenizerFast( - tokenizer_object=backend, - eos_token="", - pad_token="", - unk_token="", - ) - - -def _build_tiny_qwen2() -> TransformerBridge: - hf_config = AutoConfig.for_model("qwen2", **TINY_DIMS) - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "Qwen2ForCausalLM", "qwen2-tiny", torch.float32 - ) - adapter = Qwen2ArchitectureAdapter(bridge_config) - return TransformerBridge(model=hf_model, adapter=adapter, tokenizer=_offline_tokenizer(PROMPTS)) - - -def _build_tiny_qwen2_relu() -> TransformerBridge: - """A gated MLP whose activation the Identity-rule cannot honor.""" - hf_config = AutoConfig.for_model("qwen2", **TINY_DIMS, hidden_act="relu") - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "Qwen2ForCausalLM", "qwen2-relu-tiny", torch.float32 - ) - adapter = Qwen2ArchitectureAdapter(bridge_config) - return TransformerBridge(model=hf_model, adapter=adapter, tokenizer=_offline_tokenizer(PROMPTS)) - - -def _residual_gradient(model: TransformerBridge, tokens: torch.Tensor) -> torch.Tensor: - """Probe downstream backward semantics without accumulating parameter gradients.""" - captured: dict[str, torch.Tensor] = {} - - def capture(activation: torch.Tensor, hook: Any) -> torch.Tensor: - captured["residual"] = activation.detach().requires_grad_(True) - return captured["residual"] - - with torch.enable_grad(), model.hooks(fwd_hooks=[("blocks.0.hook_out", capture)]): - logits = model(tokens) - (gradient,) = torch.autograd.grad(logits[0, -1, 0], captured["residual"]) - assert torch.isfinite(gradient).all() - assert gradient.count_nonzero() > 0 - return gradient.detach().clone() - - -class _FittedPair(NamedTuple): - model: TransformerBridge - logits_before_fit: torch.Tensor - gradient_before_fit: torch.Tensor - jacobian: JacobianLens - relevance: RelevanceLens - - -@pytest.fixture(scope="module") -def fitted() -> _FittedPair: - """Both estimators on one model, with separate forward and backward baselines.""" - model = _build_tiny_qwen2() - tokens = model.to_tokens(PROMPTS[0]) - with torch.no_grad(): - logits_before_fit = model(tokens) - gradient_before_fit = _residual_gradient(model, tokens) - torch.testing.assert_close( - _residual_gradient(model, tokens), gradient_before_fit, atol=0, rtol=0 - ) - jacobian = JacobianLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - relevance = RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - return _FittedPair( - model=model, - logits_before_fit=logits_before_fit, - gradient_before_fit=gradient_before_fit, - jacobian=jacobian, - relevance=relevance, - ) - - -def _expected_rule_mounts() -> tuple[_RelevanceRuleCoverageEntry, ...]: - """Canonical rule-kind/path pairs, excluding attention-internal norms.""" - return tuple( - _RelevanceRuleCoverageEntry(kind=kind, path=f"blocks.{layer}.{mount}") - for kind, mounts in ( - ("normalization", ("ln1", "ln2")), - ("activation", ("mlp",)), - ("multiplicative_gate", ("mlp",)), - ) - for layer in range(N_LAYERS) - for mount in mounts - ) - - -@pytest.fixture(params=[JacobianLens, RelevanceLens], ids=["jacobian", "relevance"]) -def estimator(request: pytest.FixtureRequest) -> Any: - return request.param - - -def _forbid_fit_work(*args: Any, **kwargs: Any) -> Any: - raise AssertionError("invalid fit options reached tokenization or rule installation") - - -class TestSharedFitValidation: - @pytest.mark.parametrize( - "options, message", - [ - ({"corpus": ""}, "corpus"), - ({"corpus": " "}, "corpus"), - ({"source_layers": []}, "source_layers"), - ({"source_layers": [N_LAYERS - 1]}, "target_layer"), - ({"source_layers": [N_LAYERS]}, "out of range"), - ({"dim_batch": 0}, "dim_batch"), - ({"skip_first_positions": -1}, "skip_first_positions"), - ], - ) - def test_invalid_options_fail_before_fit_work( - self, - estimator: Any, - options: dict[str, Any], - message: str, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - model = _build_tiny_qwen2() - monkeypatch.setattr(jacobian_lens_module, "_fit_transport_matrices", _forbid_fit_work) - monkeypatch.setattr(relevance_lens_module, "_fit_transport_matrices", _forbid_fit_work) - monkeypatch.setattr(relevance_lens_module, "use_relevance_rules", _forbid_fit_work) - with pytest.raises(ValueError, match=message): - estimator.fit(model, PROMPTS, **{"corpus": CORPUS, **options}) - - @pytest.mark.parametrize( - "state, message", - [ - ("compatibility", "compatibility mode"), - ("processed", "process_weights"), - ("training", r"model\.eval\(\)"), - ("submodule", r"model\.eval\(\)"), - ], - ) - def test_model_state_rejections_are_shared( - self, estimator: Any, state: str, message: str - ) -> None: - model = _build_tiny_qwen2() - if state == "compatibility": - model.compatibility_mode = True - elif state == "processed": - model._weights_processed = True - elif state == "training": - model.train() - else: - model.blocks[0].train() - with pytest.raises(ValueError, match=message): - estimator.fit(model, PROMPTS, corpus=CORPUS, show_progress=False) - - def test_hidden_original_submodule_in_training_mode_is_refused(self, estimator: Any) -> None: - model = _ToyBridge() - original_model = torch.nn.Sequential(torch.nn.Linear(4, 4)).eval() - original_model[0].train() - model.original_model = original_model - with pytest.raises(ValueError, match="original_model"): - estimator.fit(model, PROMPTS, corpus=CORPUS) - assert original_model.training is False - assert original_model[0].training is True - - def test_non_bridge_is_refused(self, estimator: Any) -> None: - with pytest.raises(TypeError, match="TransformerBridge"): - estimator.fit(object(), PROMPTS, corpus=CORPUS) - - def test_both_estimators_reach_the_same_raw_bridge_guard( - self, - estimator: Any, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - def refuse(*args: Any, **kwargs: Any) -> None: - raise ValueError("shared raw-bridge guard") - - monkeypatch.setattr(jacobian_lens_module, "_require_raw_bridge", refuse) - with pytest.raises(ValueError, match="shared raw-bridge guard"): - estimator.fit(_ToyBridge(), PROMPTS, corpus=CORPUS) - - @pytest.mark.parametrize( - "key", - [ - "model_name", - "model_revision", - "transformer_lens_version", - "model_system", - "estimator", - "processing", - "hook_convention", - "corpus", - "n_prompts", - "fit_dtype", - "target_layer", - "dim_batch", - "max_seq_len", - "skip_first_positions", - "transformer_lens_fit", - ], - ) - def test_common_provenance_is_reserved_before_fit_work( - self, - estimator: Any, - key: str, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setattr(jacobian_lens_module, "_fit_transport_matrices", _forbid_fit_work) - monkeypatch.setattr(relevance_lens_module, "_fit_transport_matrices", _forbid_fit_work) - monkeypatch.setattr(relevance_lens_module, "use_relevance_rules", _forbid_fit_work) - with pytest.raises(ValueError, match="cannot override fit provenance"): - estimator.fit(_ToyBridge(), PROMPTS, corpus=CORPUS, metadata={key: None}) - - @pytest.mark.parametrize("key", ["relevance_rule_version", "enabled_rules", "rule_coverage"]) - def test_relevance_provenance_is_reserved_before_rule_installation( - self, - key: str, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setattr(relevance_lens_module, "use_relevance_rules", _forbid_fit_work) - with pytest.raises(ValueError, match="cannot override fit provenance"): - RelevanceLens.fit(_ToyBridge(), PROMPTS, corpus=CORPUS, metadata={key: None}) - - @pytest.mark.parametrize("value", [torch.tensor(1), {"unsafe"}, object()]) - def test_unsafe_user_metadata_is_refused_before_fit_work( - self, - estimator: Any, - value: Any, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.setattr(jacobian_lens_module, "_fit_transport_matrices", _forbid_fit_work) - monkeypatch.setattr(relevance_lens_module, "use_relevance_rules", _forbid_fit_work) - with pytest.raises(ValueError, match="metadata"): - estimator.fit(_ToyBridge(), PROMPTS, corpus=CORPUS, metadata={"run": value}) - - -class TestSharedFitProvenance: - def test_common_provenance_matches_between_estimators(self, fitted: _FittedPair) -> None: - relevance_only = {"relevance_rule_version", "enabled_rules", "rule_coverage"} - assert fitted.relevance.metadata["estimator"] == "relevance_lens" - assert fitted.jacobian.metadata["estimator"] == "jacobian_lens" - assert { - key: value - for key, value in fitted.relevance.metadata.items() - if key not in relevance_only | {"estimator"} - } == {key: value for key, value in fitted.jacobian.metadata.items() if key != "estimator"} - - def test_custom_metadata_is_preserved(self, estimator: Any) -> None: - metadata = {"run": {"seed": 0, "tags": ["offline", "fit"]}} - lens = estimator.fit( - _build_tiny_qwen2(), - PROMPTS[:1], - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - metadata=metadata, - ) - assert lens.metadata["run"] == metadata["run"] - assert set(metadata) == {"run"} - assert lens.metadata["n_prompts"] == lens.n_prompts == 1 - - -class TestFitShapeAndFiniteness: - def test_source_layers_and_d_model_match_jacobian_lens(self, fitted: _FittedPair) -> None: - assert fitted.relevance.source_layers == fitted.jacobian.source_layers - assert fitted.relevance.d_model == fitted.jacobian.d_model - assert fitted.relevance.n_prompts == fitted.jacobian.n_prompts - - def test_every_transport_matrix_is_finite_and_square(self, fitted: _FittedPair) -> None: - for layer in fitted.relevance.source_layers: - matrix = fitted.relevance.jacobians[layer] - assert matrix.shape == (D_MODEL, D_MODEL) - assert torch.isfinite(matrix).all() - - -class TestRuleCoverageSelection: - def test_installs_residual_norm_activation_and_gate_rules_only( - self, fitted: _FittedPair - ) -> None: - coverage = fitted.relevance.rule_coverage - assert coverage.installed == _expected_rule_mounts() - assert coverage.skipped == () - - def test_fit_preserves_forward_values(self, fitted: _FittedPair) -> None: - with torch.no_grad(): - logits_after_fit = fitted.model(fitted.model.to_tokens(PROMPTS[0])) - assert torch.equal(logits_after_fit, fitted.logits_before_fit) - - def test_rule_scope_does_not_leak_past_the_fit(self, fitted: _FittedPair) -> None: - tokens = fitted.model.to_tokens(PROMPTS[0]) - after_fit = _residual_gradient(fitted.model, tokens) - with use_relevance_rules(fitted.model, RELEVANCE_RULES): - active_gradient = _residual_gradient(fitted.model, tokens) - assert not torch.allclose(active_gradient, fitted.gradient_before_fit, atol=1e-6, rtol=1e-4) - torch.testing.assert_close(after_fit, fitted.gradient_before_fit, atol=0, rtol=0) - assert all(parameter.grad is None for parameter in fitted.model.parameters()) - - -class _FitFailure(RuntimeError): - """Injected failure during a rule-active backward pass.""" - - -class TestFitRestoresBackwardSemantics: - def test_restores_gradients_after_backward_failure( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - model = _build_tiny_qwen2() - tokens = model.to_tokens(PROMPTS[0]) - baseline = _residual_gradient(model, tokens) - parameter_flags = [parameter.requires_grad for parameter in model.parameters()] - active_gradients = [] - - def fail_backward(*args: Any, **kwargs: Any) -> Any: - active_gradients.append(_residual_gradient(model, tokens)) - raise _FitFailure("injected backward failure") - - monkeypatch.setattr(relevance_lens_module, "_ordinary_vjp", fail_backward) - with pytest.raises(_FitFailure, match="injected backward failure"): - RelevanceLens.fit( - model, - PROMPTS[:1], - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - assert len(active_gradients) == 1 - assert not torch.allclose(active_gradients[0], baseline, atol=1e-6, rtol=1e-4) - torch.testing.assert_close(_residual_gradient(model, tokens), baseline, atol=0, rtol=0) - assert [parameter.requires_grad for parameter in model.parameters()] == parameter_flags - assert all(parameter.grad is None for parameter in model.parameters()) - - def test_nested_fit_preserves_outer_backward_rules(self) -> None: - model = _build_tiny_qwen2() - tokens = model.to_tokens(PROMPTS[0]) - baseline = _residual_gradient(model, tokens) - with use_relevance_rules(model, RELEVANCE_RULES): - outer_gradient = _residual_gradient(model, tokens) - assert not torch.allclose(outer_gradient, baseline, atol=1e-6, rtol=1e-4) - RelevanceLens.fit( - model, - PROMPTS[:1], - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - torch.testing.assert_close( - _residual_gradient(model, tokens), outer_gradient, atol=0, rtol=0 - ) - torch.testing.assert_close(_residual_gradient(model, tokens), baseline, atol=0, rtol=0) - assert all(parameter.grad is None for parameter in model.parameters()) - - -class TestEstimatorDiffersFromOrdinaryJacobian: - def test_early_layer_transport_differs_from_jacobian_lens(self, fitted: _FittedPair) -> None: - differing = [ - layer - for layer in fitted.jacobian.source_layers - if not torch.allclose( - fitted.relevance.jacobians[layer], - fitted.jacobian.jacobians[layer], - ) - ] - assert differing, ( - "every relevance transport matrix equals the ordinary Jacobian; the " - "requested rules changed no backward semantics" - ) - - -def _shard( - *, - n_prompts: int, - metadata: dict[str, Any] | None = None, - rule_version: int = RELEVANCE_RULE_VERSION, - enabled_rules: Sequence[str] = ( - "normalization", - "activation", - "multiplicative_gate", - ), -) -> RelevanceLens: - """A hand-built relevance shard, so merge/registry tests need no model.""" - return RelevanceLens( - {0: torch.eye(D_MODEL)}, - n_prompts=n_prompts, - d_model=D_MODEL, - metadata=metadata, - relevance_rule_version=rule_version, - enabled_rules=enabled_rules, - ) - - -class TestMergeRejectsMixedEstimators: - @pytest.mark.parametrize("n_shards", [1, 2]) - @pytest.mark.parametrize("metadata", [{}, {"estimator": "jacobian_lens"}]) - def test_jacobian_shards_cannot_be_relabelled( - self, n_shards: int, metadata: dict[str, Any] - ) -> None: - shards = [ - JacobianLens({0: torch.eye(D_MODEL)}, n_prompts=1, d_model=D_MODEL, metadata=metadata) - for _ in range(n_shards) - ] - before = [dict(shard.metadata) for shard in shards] - - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge(shards) - - assert [shard.metadata for shard in shards] == before - - def test_generic_lens_with_relevance_metadata_is_not_a_relevance_shard(self) -> None: - shard = JacobianLens( - {0: torch.eye(D_MODEL)}, - n_prompts=1, - d_model=D_MODEL, - metadata={"estimator": "relevance_lens"}, - ) - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge([shard]) - - def test_relevance_shard_requires_positive_metadata(self) -> None: - shard = _shard(n_prompts=1) - del shard.metadata["estimator"] - before = dict(shard.metadata) - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge([shard]) - assert shard.metadata == before - - @pytest.mark.parametrize("reverse", [False, True]) - def test_jacobian_shard_cannot_merge_with_relevance_shard(self, reverse: bool) -> None: - jacobian = JacobianLens({0: torch.eye(D_MODEL)}, n_prompts=1, d_model=D_MODEL) - shards = [jacobian, _shard(n_prompts=1)] - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge(shards[::-1] if reverse else shards) - - @pytest.mark.parametrize("reverse", [False, True]) - def test_relevance_shard_cannot_merge_with_jacobian_shard(self, reverse: bool) -> None: - jacobian = JacobianLens({0: torch.eye(D_MODEL)}, n_prompts=1, d_model=D_MODEL) - shards = [jacobian, _shard(n_prompts=1)] - with pytest.raises(ValueError, match="provenance"): - JacobianLens.merge(shards[::-1] if reverse else shards) - - -class TestMergeRejectsMismatchedRuleConfiguration: - def test_different_rule_version_is_rejected(self) -> None: - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge( - [ - _shard(n_prompts=1, rule_version=RELEVANCE_RULE_VERSION), - _shard(n_prompts=1, rule_version=RELEVANCE_RULE_VERSION + 1), - ] - ) - - def test_different_enabled_rules_are_rejected(self) -> None: - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge( - [ - _shard(n_prompts=1), - _shard(n_prompts=1, enabled_rules=("normalization",)), - ] - ) - - -class TestMergeWeightsByPromptCount: - def test_identical_provenance_shards_merge_by_prompt_count(self) -> None: - low = _shard(n_prompts=2) - low.jacobians[0] = torch.zeros(D_MODEL, D_MODEL) - high = _shard(n_prompts=6) - high.jacobians[0] = 4 * torch.ones(D_MODEL, D_MODEL) - - merged = RelevanceLens.merge([low, high]) - - assert merged.n_prompts == 8 - assert torch.allclose(merged.jacobians[0], 3 * torch.ones(D_MODEL, D_MODEL)) - assert merged.estimator == "relevance_lens" - - -class TestRegistryResolutionIsDisabled: - def test_short_name_does_not_resolve_the_jacobian_registry( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - def fail_if_called(name_or_path: str) -> Any: - raise AssertionError( - f"the Jacobian registry must not resolve {name_or_path!r} for a " "relevance lens" - ) - - monkeypatch.setattr(jacobian_lens_module, "_resolve_registry_entry", fail_if_called) - with pytest.raises(ValueError, match="registry"): - RelevanceLens.from_pretrained("gemma-2-2b") - - def test_explicit_hub_repo_is_still_accepted( - self, monkeypatch: pytest.MonkeyPatch, tmp_path: Any - ) -> None: - path = tmp_path / "lens.pt" - _shard(n_prompts=1).save(str(path)) - calls: dict[str, Any] = {} - - def fake_retry(function: Any, **kwargs: Any) -> str: - calls["kwargs"] = kwargs - return str(path) - - monkeypatch.setattr(jacobian_lens_module, "call_hf_with_retry", fake_retry) - - loaded = RelevanceLens.from_pretrained("example/lenses", filename="model/lens.pt") - - assert calls["kwargs"]["repo_id"] == "example/lenses" - assert calls["kwargs"]["filename"] == "model/lens.pt" - assert loaded.estimator == "relevance_lens" - - -@pytest.mark.parametrize( - "kinds", - [ - (), - ("normalization",), - ("activation",), - ("multiplicative_gate",), - ("normalization", "activation", "multiplicative_gate"), - ("multiplicative_gate", "activation", "normalization"), - ], -) -def test_installed_rule_names_use_kinds_not_shared_mount_names(kinds: tuple[str, ...]) -> None: - coverage = RelevanceRuleCoverage( - installed=tuple( - _RelevanceRuleCoverageEntry( - kind, "blocks.0.ln1" if kind == "normalization" else "blocks.0.mlp" - ) - for kind in kinds - ), - skipped=(), - ) - assert _installed_rule_names(coverage) == [ - kind for kind in ("normalization", "activation", "multiplicative_gate") if kind in kinds - ] - - -class TestRuleProvenanceSurvivesPersistence: - def test_fitted_rule_configuration_round_trips( - self, fitted: _FittedPair, tmp_path: Any - ) -> None: - path = tmp_path / "lens.pt" - fitted.relevance.save(str(path)) - - loaded = RelevanceLens.load(str(path)) - - assert loaded.estimator == "relevance_lens" - assert loaded.relevance_rule_version == fitted.relevance.relevance_rule_version - assert loaded.enabled_rules == fitted.relevance.enabled_rules - assert loaded.rule_coverage == fitted.relevance.rule_coverage - assert loaded.metadata["rule_coverage"] == { - "schema_version": 1, - "installed": [ - {"kind": entry.kind, "path": entry.path} for entry in _expected_rule_mounts() - ], - "skipped": [], - } - - @pytest.mark.parametrize("kind", ["activation", "multiplicative_gate"]) - def test_partial_coverage_at_one_mount_round_trips(self, tmp_path: Any, kind: str) -> None: - other_kind = "multiplicative_gate" if kind == "activation" else "activation" - coverage = RelevanceRuleCoverage( - installed=(_RelevanceRuleCoverageEntry(kind, "blocks.0.mlp"),), - skipped=(_RelevanceRuleCoverageEntry(other_kind, "blocks.0.mlp"),), - ) - lens = RelevanceLens( - {0: torch.eye(D_MODEL)}, - n_prompts=1, - d_model=D_MODEL, - rule_coverage=coverage, - enabled_rules=_installed_rule_names(coverage), - ) - path = tmp_path / "partial.pt" - lens.save(str(path)) - loaded = RelevanceLens.load(str(path)) - assert loaded.rule_coverage == coverage - assert loaded.enabled_rules == [kind] - assert loaded.metadata["rule_coverage"] == { - "schema_version": 1, - "installed": [{"kind": kind, "path": "blocks.0.mlp"}], - "skipped": [{"kind": other_kind, "path": "blocks.0.mlp"}], - } - - def test_explicit_coverage_cannot_hide_contradictory_metadata(self) -> None: - metadata = {"rule_coverage": {"schema_version": 1, "installed": [], "skipped": []}} - coverage = RelevanceRuleCoverage( - installed=(_RelevanceRuleCoverageEntry("activation", "blocks.0.mlp"),), - skipped=(), - ) - with pytest.raises(ValueError, match="coverage provenance"): - RelevanceLens( - {0: torch.eye(D_MODEL)}, - n_prompts=1, - d_model=D_MODEL, - metadata=metadata, - rule_coverage=coverage, - ) - - def test_non_default_rule_configuration_round_trips(self, tmp_path: Any) -> None: - path = tmp_path / "lens.pt" - _shard( - n_prompts=1, - rule_version=RELEVANCE_RULE_VERSION + 3, - enabled_rules=("normalization",), - ).save(str(path)) - - loaded = RelevanceLens.load(str(path)) - - assert loaded.relevance_rule_version == RELEVANCE_RULE_VERSION + 3 - assert loaded.enabled_rules == ["normalization"] - assert loaded.relevance_rule_version == loaded.metadata["relevance_rule_version"] - assert loaded.enabled_rules == loaded.metadata["enabled_rules"] - - def test_loaded_lens_still_refuses_a_mismatched_merge(self, tmp_path: Any) -> None: - path = tmp_path / "lens.pt" - _shard(n_prompts=1, rule_version=RELEVANCE_RULE_VERSION + 3).save(str(path)) - loaded = RelevanceLens.load(str(path)) - - with pytest.raises(ValueError, match="provenance"): - RelevanceLens.merge([loaded, _shard(n_prompts=1)]) - - -class TestMergePropagatesRuleConfiguration: - def test_merged_lens_keeps_the_rule_configuration(self) -> None: - rule_version = RELEVANCE_RULE_VERSION + 2 - low = _shard( - n_prompts=2, - rule_version=rule_version, - enabled_rules=("normalization",), - ) - high = _shard( - n_prompts=6, - rule_version=rule_version, - enabled_rules=("normalization",), - ) - - merged = RelevanceLens.merge([low, high]) - - assert merged.relevance_rule_version == rule_version - assert merged.enabled_rules == ["normalization"] - - -class TestPublicExport: - def test_relevance_lens_is_importable_from_the_analysis_package(self) -> None: - from transformer_lens.tools import analysis - - assert analysis.RelevanceLens is RelevanceLens - assert "RelevanceLens" in analysis.__all__ - - def test_analysis_exports_stay_alphabetized(self) -> None: - from transformer_lens.tools import analysis - - assert analysis.__all__ == sorted(analysis.__all__) - - -class TestLoadRejectsForeignEstimator: - def test_jacobian_artifact_cannot_load_as_a_relevance_lens(self, tmp_path: Any) -> None: - path = tmp_path / "lens.pt" - JacobianLens( - {0: torch.eye(D_MODEL)}, - n_prompts=1, - d_model=D_MODEL, - metadata={"estimator": "jacobian_lens"}, - ).save(str(path)) - - with pytest.raises(ValueError, match="estimator"): - RelevanceLens.load(str(path)) - - @pytest.mark.parametrize( - "metadata", - [ - None, - {}, - {"relevance_rule_version": RELEVANCE_RULE_VERSION}, - {"estimator": None}, - {"estimator": 1}, - {"estimator": ["relevance_lens"]}, - {"estimator": "unknown"}, - ], - ) - def test_artifact_requires_positive_estimator_provenance( - self, tmp_path: Any, metadata: Any - ) -> None: - path = tmp_path / "lens.pt" - payload = { - "J": {0: torch.eye(D_MODEL)}, - "n_prompts": 1, - "source_layers": [0], - "d_model": D_MODEL, - } - if metadata is not None: - payload["metadata"] = metadata - torch.save(payload, path) - before = path.read_bytes() - - with pytest.raises(ValueError, match="estimator"): - RelevanceLens.load(str(path)) - - assert path.read_bytes() == before - - def test_legacy_jacobian_artifact_remains_loadable_as_jacobian(self, tmp_path: Any) -> None: - path = tmp_path / "legacy.pt" - JacobianLens({0: torch.eye(D_MODEL)}, n_prompts=1, d_model=D_MODEL).save(str(path)) - - loaded = JacobianLens.load(str(path)) - assert loaded.estimator == "jacobian_lens" - assert loaded.metadata == {} - with pytest.raises(ValueError, match="estimator"): - RelevanceLens.load(str(path)) - - @pytest.mark.parametrize("metadata", [{}, {"estimator": "relevance_lens"}]) - def test_running_sum_checkpoint_is_refused(self, tmp_path: Any, metadata: Any) -> None: - path = tmp_path / "checkpoint.pt" - torch.save( - {"jacobian_sum": {0: torch.eye(D_MODEL)}, "n_done": 1, "metadata": metadata}, - path, - ) - before = path.read_bytes() - with pytest.raises(ValueError, match="checkpoint"): - RelevanceLens.load(str(path)) - assert path.read_bytes() == before - - -def _coverage_record( - *, installed: Any = (), skipped: Any = (), schema_version: Any = 1 -) -> dict[str, Any]: - return {"schema_version": schema_version, "installed": installed, "skipped": skipped} - - -class TestCoverageMetadataValidation: - @pytest.mark.parametrize( - "recorded", - [ - None, - [], - {}, - {"installed": ["blocks.0.mlp"], "skipped": []}, - _coverage_record(schema_version=2), - _coverage_record(schema_version=True), - _coverage_record(schema_version="1"), - {"schema_version": 1, "installed": []}, - _coverage_record(installed="blocks.0.mlp"), - _coverage_record(skipped=None), - _coverage_record(installed=["blocks.0.mlp"]), - _coverage_record(skipped=["blocks.0.mlp"]), - _coverage_record(installed=[{"path": "blocks.0.mlp"}]), - _coverage_record(installed=[{"kind": "unknown", "path": "blocks.0.mlp"}]), - _coverage_record(installed=[{"kind": None, "path": "blocks.0.mlp"}]), - _coverage_record(installed=[{"kind": "activation", "path": 1}]), - _coverage_record(installed=[{"kind": "activation", "path": ""}]), - _coverage_record(skipped=[{"kind": "activation", "path": " "}]), - _coverage_record(installed=[{"kind": "activation", "path": "blocks.0.mlp"}] * 2), - _coverage_record( - installed=[{"kind": "activation", "path": "blocks.0.mlp"}], - skipped=[{"kind": "activation", "path": "blocks.0.mlp"}], - ), - ], - ) - def test_invalid_coverage_is_refused_on_load(self, tmp_path: Any, recorded: Any) -> None: - path = tmp_path / "coverage.pt" - _shard(n_prompts=1).save(str(path)) - payload = torch.load(path, weights_only=True) - payload["metadata"]["rule_coverage"] = recorded - torch.save(payload, path) - before = path.read_bytes() - with pytest.raises(ValueError, match="rule_coverage"): - RelevanceLens.load(str(path)) - assert path.read_bytes() == before - - def test_optional_coverage_can_be_absent(self, tmp_path: Any) -> None: - path = tmp_path / "without-coverage.pt" - _shard(n_prompts=1).save(str(path)) - loaded = RelevanceLens.load(str(path)) - assert loaded.rule_coverage is None - assert "rule_coverage" not in loaded.metadata - - def test_legacy_coverage_error_requires_refitting(self) -> None: - with pytest.raises(ValueError, match="path-only coverage is ambiguous.*refit"): - _shard( - n_prompts=1, - metadata={"rule_coverage": {"installed": ["blocks.0.mlp"], "skipped": []}}, - ) - - -class TestConstructorProvenance: - @pytest.mark.parametrize("estimator", [None, 1, ["relevance_lens"], "jacobian_lens", "other"]) - def test_contradictory_estimator_is_refused(self, estimator: Any) -> None: - metadata = {"estimator": estimator} - with pytest.raises(ValueError, match="estimator"): - _shard(n_prompts=1, metadata=metadata) - assert metadata == {"estimator": estimator} - - @pytest.mark.parametrize( - "metadata", - [ - {"relevance_rule_version": None}, - {"relevance_rule_version": True}, - {"relevance_rule_version": "1"}, - {"enabled_rules": None}, - {"enabled_rules": "normalization"}, - {"enabled_rules": [1]}, - ], - ) - def test_invalid_recorded_configuration_is_not_replaced_by_defaults( - self, metadata: dict[str, Any] - ) -> None: - before = dict(metadata) - with pytest.raises(ValueError, match="provenance"): - _shard(n_prompts=1, metadata=metadata) - assert metadata == before - - def test_recorded_configuration_takes_precedence_without_mutating_metadata(self) -> None: - metadata = { - "estimator": "relevance_lens", - "relevance_rule_version": RELEVANCE_RULE_VERSION + 2, - "enabled_rules": ("normalization",), - } - lens = _shard(n_prompts=1, metadata=metadata) - assert lens.estimator == lens.metadata["estimator"] - assert lens.relevance_rule_version == lens.metadata["relevance_rule_version"] - assert lens.enabled_rules == lens.metadata["enabled_rules"] == ["normalization"] - assert metadata["enabled_rules"] == ("normalization",) - - def test_generic_merge_preserves_recorded_relevance_identity(self) -> None: - merged = JacobianLens.merge([_shard(n_prompts=1), _shard(n_prompts=2)]) - assert merged.n_prompts == 3 - assert merged.estimator == merged.metadata["estimator"] == "relevance_lens" - - -class TestAnalysisSurfaceIsReusedNotDuplicated: - """The estimator changes only how matrices are estimated. - - Readout, vocabulary vectors, sparse decomposition, and the interventions are - inherited from the Jacobian lens rather than reimplemented, so a fix to any - of them reaches both estimators. This pins that contract: a future - reimplementation on the subclass would silently fork the two surfaces. - """ - - INHERITED_METHODS = ( - "transport", - "readout", - "lens_vectors", - "lens_vector_dictionary", - "decompose", - "occupancy", - "fraction_of_variance", - "steering_hooks", - "ablation_hooks", - "swap_hooks", - "swap_clamp_hooks", - "coordinate_patch", - "coordinate_patch_hooks", - "validate_model", - "save", - "load", - "merge", - "from_pretrained", - ) - - def test_downstream_methods_are_inherited_unchanged(self) -> None: - for name in self.INHERITED_METHODS: - assert name not in vars(RelevanceLens), ( - f"{name} is redefined on RelevanceLens; the estimator is meant to " - "change only how transport matrices are estimated" - ) - # Compare the underlying functions: a classmethod accessor returns a - # fresh bound method on every getattr, so identity on the accessor - # itself would never hold. - ours = getattr(RelevanceLens, name) - theirs = getattr(JacobianLens, name) - assert getattr(ours, "__func__", ours) is getattr(theirs, "__func__", theirs) - - def test_only_the_estimator_specific_surface_is_overridden(self) -> None: - overridden = { - name - for name in vars(RelevanceLens) - if not name.startswith("__") and callable(getattr(RelevanceLens, name)) - } - assert overridden == { - "fit", - "_validate_artifact_metadata", - "_validate_checkpoint_payload", - "_validate_merge_inputs", - } - - -def _build_tiny_gpt2() -> TransformerBridge: - """A dense-MLP, plain-python-norm model that can honor none of the rules. - - GPT-2 has no gated MLP and its norms do not dispatch through the - native-autograd branch the LN-rule wraps, so every canonical mount is - reported skipped. - """ - hf_config = AutoConfig.for_model( - "gpt2", - vocab_size=97, - n_embd=D_MODEL, - n_layer=N_LAYERS, - n_head=4, - n_positions=64, - n_ctx=64, - ) - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "GPT2LMHeadModel", "gpt2-tiny", torch.float32 - ) - adapter = GPT2ArchitectureAdapter(bridge_config) - return TransformerBridge(model=hf_model, adapter=adapter, tokenizer=_offline_tokenizer(PROMPTS)) - - -class TestFitRefusesSilentFallback: - """A fit that installs no rule must not masquerade as a relevance lens. - - Without this guard the estimator would return ordinary Jacobian matrices - labelled ``estimator="relevance_lens"``, which is indistinguishable from a - real relevance fit in the artifact and would silently mislead a downstream - comparison. - """ - - def test_fit_raises_when_no_rule_can_be_installed(self) -> None: - model = _build_tiny_gpt2() - - with pytest.raises(ValueError, match="no relevance rule"): - RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - def test_error_names_the_skipped_mounts(self) -> None: - model = _build_tiny_gpt2() - - with pytest.raises(ValueError) as excinfo: - RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - message = str(excinfo.value) - assert "blocks.0.ln1" in message - assert "blocks.0.mlp" in message - - -def _build_tiny_opt() -> TransformerBridge: - """A model where the LN-rule installs but the MLP rules cannot. - - OPT has residual-stream norms on the native-autograd path but a dense ReLU - MLP, so the normalization mounts install while every MLP mount is skipped. - """ - hf_config = AutoConfig.for_model( - "opt", - vocab_size=97, - hidden_size=D_MODEL, - ffn_dim=64, - num_hidden_layers=N_LAYERS, - num_attention_heads=4, - max_position_embeddings=64, - pad_token_id=0, - bos_token_id=1, - eos_token_id=2, - ) - torch.manual_seed(0) - hf_model = AutoModelForCausalLM.from_config(hf_config, attn_implementation="eager").eval() - bridge_config = build_bridge_config_from_hf( - hf_model.config, "OPTForCausalLM", "opt-tiny", torch.float32 - ) - adapter = ArchitectureAdapterFactory.select_architecture_adapter(bridge_config) - return TransformerBridge(model=hf_model, adapter=adapter, tokenizer=_offline_tokenizer(PROMPTS)) - - -class TestPartialCoverageIsRecordedHonestly: - """``enabled_rules`` must name the rules that actually shaped the matrices. - - A model can honor some rules and not others. Recording the requested set - rather than the installed set would claim the MLP rules contributed when - every MLP mount was skipped, which misdescribes the artifact. - """ - - def test_partial_coverage_installs_only_the_normalization_rule(self) -> None: - model = _build_tiny_opt() - - lens = RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - assert lens.rule_coverage.installed - assert lens.rule_coverage.skipped - assert lens.enabled_rules == ["normalization"] - - def test_recorded_enabled_rules_match_the_installed_mounts(self) -> None: - model = _build_tiny_opt() - - lens = RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - assert lens.metadata["enabled_rules"] == lens.enabled_rules - assert "activation" not in lens.enabled_rules - assert "multiplicative_gate" not in lens.enabled_rules - - def test_partial_coverage_warns_with_the_skipped_mounts(self) -> None: - model = _build_tiny_opt() - - with pytest.warns(UserWarning, match="skipped") as record: - RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - message = str(record[0].message) - assert "activation: blocks.0.mlp" in message - assert "multiplicative_gate: blocks.0.mlp" in message - assert "normalization" in message - - def test_full_coverage_does_not_warn_about_skipped_mounts(self) -> None: - model = _build_tiny_qwen2() - - with warnings.catch_warnings(record=True) as record: - warnings.simplefilter("always") - RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - assert not [ - item for item in record if "skipped" in str(item.message) - ], "a fully covered fit must not warn about skipped mounts" - - -class TestUnsupportedActivationIsRefusedBeforeFitting: - """A relu-family gated MLP must be refused, not scored with a wrong rule. - - The Identity-rule's backward multiplier ``f(x) / x`` reduces to ``relu(x)`` - for relu-squared rather than the true derivative ``2 * relu(x)``, so applying - it there would produce plausible but wrong transport matrices. The refusal - must happen before any fitting work, and must leave the model untouched. - """ - - def test_fit_raises_for_a_relu_gated_mlp(self) -> None: - model = _build_tiny_qwen2_relu() - - with pytest.raises(RelevanceRuleUnsupportedError, match="activation"): - RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - def test_refusal_leaves_the_model_untouched(self) -> None: - model = _build_tiny_qwen2_relu() - tokens = model.to_tokens(PROMPTS[0]) - with torch.no_grad(): - before = model(tokens) - - with pytest.raises(RelevanceRuleUnsupportedError): - RelevanceLens.fit( - model, - PROMPTS, - corpus=CORPUS, - source_layers=SOURCE_LAYERS, - show_progress=False, - ) - - with torch.no_grad(): - after = model(tokens) - assert torch.equal(after, before) - assert all(parameter.grad is None for parameter in model.parameters()) diff --git a/tests/unit/tools/test_relevance_rules.py b/tests/unit/tools/test_relevance_rules.py deleted file mode 100644 index c8a6e35046..0000000000 --- a/tests/unit/tools/test_relevance_rules.py +++ /dev/null @@ -1,444 +0,0 @@ -"""Analytic closed-form tests for the LN-, Identity-, and Half-relevance-rule primitives. - -Model-free: every primitive is a plain ``torch.autograd.Function`` exercised on -synthetic tensors, so no model, backward hook, or ``.data`` access is involved. Each -test asserts two properties independently: the forward value is exactly the native -(ordinary-autograd) value, and the backward value matches the rule's closed-form VJP -rather than what ordinary autodiff would produce. -""" - -import math -from functools import partial - -import pytest -import torch -import torch.nn as nn -import torch.nn.functional as F -from transformers.pytorch_utils import Conv1D - -from transformer_lens.model_bridge._relevance_rules import ( - half_rule, - identity_rule, - ln_rule, -) -from transformer_lens.model_bridge.generalized_components.mlp import ( - normalize_mlp_weight, - weight_layout_in_out, -) - -DTYPES = [torch.float32, torch.float64] - - -def _leaf(values: torch.Tensor, dtype: torch.dtype) -> torch.Tensor: - return values.to(dtype).clone().requires_grad_(True) - - -def _sample_rows(dtype: torch.dtype) -> torch.Tensor: - """A batch covering an all-zero, all-negative, mixed-sign, and positive row.""" - values = torch.tensor( - [ - [0.0, 0.0, 0.0, 0.0, 0.0], - [-1.0, -2.0, -0.5, -3.0, -0.25], - [1.0, -2.0, 3.0, -4.0, 0.5], - [2.0, 4.0, 6.0, 8.0, 10.0], - ] - ) - return values.to(dtype) - - -def _normal_cdf(x: torch.Tensor) -> torch.Tensor: - return 0.5 * (1.0 + torch.erf(x / math.sqrt(2.0))) - - -class TestLNRule: - """Forward equals ``numerator / denom``; the VJP treats ``denom`` as constant.""" - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_matches_plain_division(self, dtype): - numerator = _sample_rows(dtype) - denom = numerator.abs().mean(dim=-1, keepdim=True) + 1e-6 - assert torch.equal(ln_rule(numerator, denom), numerator / denom) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_backward_treats_denom_as_constant(self, dtype): - eps = 1e-6 - base = _sample_rows(dtype) - grad_out = torch.ones_like(base) - - x_rule = _leaf(base, dtype) - denom_rule = x_rule.abs().mean(dim=-1, keepdim=True) + eps - y_rule = ln_rule(x_rule, denom_rule) - (grad_rule,) = torch.autograd.grad(y_rule, x_rule, grad_outputs=grad_out) - - # The closed-form VJP of this rule: grad_x = grad_out / denom, with no - # contribution from d(denom)/dx. - expected = grad_out / denom_rule.detach() - torch.testing.assert_close(grad_rule, expected) - - # Plain autodiff through the same expression differentiates the denom too, - # so it disagrees with the rule everywhere the denom actually depends on x - # (every row here, since eps alone would zero out that dependency). - x_plain = _leaf(base, dtype) - denom_plain = x_plain.abs().mean(dim=-1, keepdim=True) + eps - y_plain = x_plain / denom_plain - (grad_plain,) = torch.autograd.grad(y_plain, x_plain, grad_outputs=grad_out) - assert not torch.allclose(grad_plain, grad_rule) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_backward_zero_row_has_no_division_by_zero(self, dtype): - eps = 1e-6 - x = _leaf(_sample_rows(dtype)[:1], dtype) - denom = x.abs().mean(dim=-1, keepdim=True) + eps - y = ln_rule(x, denom) - (grad,) = torch.autograd.grad(y, x, grad_outputs=torch.ones_like(x)) - assert torch.isfinite(grad).all() - torch.testing.assert_close(grad, torch.full_like(x, 1.0 / eps)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_and_backward_on_non_contiguous_input(self, dtype): - base = _sample_rows(dtype).t() - assert not base.is_contiguous() - numerator = base.clone().requires_grad_(True) - denom = numerator.abs().mean(dim=-1, keepdim=True) + 1e-6 - - y = ln_rule(numerator, denom) - assert torch.equal(y, numerator.detach() / denom.detach()) - - grad_out = torch.ones_like(numerator) - (grad,) = torch.autograd.grad(y, numerator, grad_outputs=grad_out) - torch.testing.assert_close(grad, grad_out / denom.detach()) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_batched_leading_dims(self, dtype): - numerator = _leaf(torch.randn(2, 3, 4), dtype) - denom = numerator.abs().mean(dim=-1, keepdim=True) + 1e-6 - y = ln_rule(numerator, denom) - assert y.shape == numerator.shape - assert torch.equal(y, numerator.detach() / denom.detach()) - - (grad,) = torch.autograd.grad(y, numerator, grad_outputs=torch.ones_like(numerator)) - torch.testing.assert_close(grad, torch.ones_like(numerator) / denom.detach()) - - -class TestIdentityRule: - """Forward equals the native activation; the VJP is ``grad_out * phi(x)``. - - ``phi`` has a closed form for each activation tested here: ``sigmoid`` for SiLU - and the Gaussian CDF for exact GELU. Both are, by construction, the removable- - singularity limit of ``f(x) / x`` at ``x == 0``, which is the direct oracle used - for the tanh-approximate GELU, where no simpler closed form applies. - """ - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_matches_native_silu(self, dtype): - x = _sample_rows(dtype) - assert torch.equal(identity_rule(x, F.silu), F.silu(x)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_backward_matches_sigmoid_for_silu(self, dtype): - x = _leaf(_sample_rows(dtype), dtype) - y = identity_rule(x, F.silu) - grad_out = torch.ones_like(x) - (grad,) = torch.autograd.grad(y, x, grad_outputs=grad_out) - torch.testing.assert_close(grad, grad_out * torch.sigmoid(x)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_matches_native_gelu_exact(self, dtype): - x = _sample_rows(dtype) - act_fn = partial(F.gelu, approximate="none") - assert torch.equal(identity_rule(x, act_fn), act_fn(x)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_backward_matches_gaussian_cdf_for_exact_gelu(self, dtype): - x = _leaf(_sample_rows(dtype), dtype) - act_fn = partial(F.gelu, approximate="none") - y = identity_rule(x, act_fn) - grad_out = torch.ones_like(x) - (grad,) = torch.autograd.grad(y, x, grad_outputs=grad_out) - torch.testing.assert_close(grad, grad_out * _normal_cdf(x)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_matches_native_gelu_approx(self, dtype): - x = _sample_rows(dtype) - act_fn = partial(F.gelu, approximate="tanh") - assert torch.equal(identity_rule(x, act_fn), act_fn(x)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_backward_matches_ratio_with_zero_limit_for_approx_gelu(self, dtype): - x = _leaf(_sample_rows(dtype), dtype) - act_fn = partial(F.gelu, approximate="tanh") - y = identity_rule(x, act_fn) - grad_out = torch.ones_like(x) - (grad,) = torch.autograd.grad(y, x, grad_outputs=grad_out) - - with torch.no_grad(): - safe_x = torch.where(x == 0, torch.ones_like(x), x) - expected_phi = torch.where(x == 0, torch.full_like(x, 0.5), act_fn(x.detach()) / safe_x) - assert torch.isfinite(grad).all() - torch.testing.assert_close(grad, grad_out * expected_phi) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_and_backward_on_non_contiguous_input(self, dtype): - base = _sample_rows(dtype).t() - assert not base.is_contiguous() - x = base.clone().requires_grad_(True) - - y = identity_rule(x, F.silu) - assert torch.equal(y, F.silu(x.detach())) - - grad_out = torch.ones_like(x) - (grad,) = torch.autograd.grad(y, x, grad_outputs=grad_out) - torch.testing.assert_close(grad, grad_out * torch.sigmoid(x.detach())) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_batched_leading_dims(self, dtype): - x = _leaf(torch.randn(2, 3, 4), dtype) - y = identity_rule(x, F.silu) - assert y.shape == x.shape - assert torch.equal(y, F.silu(x.detach())) - - (grad,) = torch.autograd.grad(y, x, grad_outputs=torch.ones_like(x)) - torch.testing.assert_close(grad, torch.sigmoid(x.detach())) - - -class TestHalfRule: - """Forward equals ``u * v``; the VJP halves each ordinary product-rule term.""" - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_matches_plain_product(self, dtype): - u = _sample_rows(dtype) - v = _sample_rows(dtype).flip(0) - assert torch.equal(half_rule(u, v), u * v) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_backward_halves_each_operand_gradient(self, dtype): - u = _leaf(_sample_rows(dtype), dtype) - v = _leaf(_sample_rows(dtype).flip(0), dtype) - grad_out = torch.ones_like(u) - - y = half_rule(u, v) - grad_u, grad_v = torch.autograd.grad(y, (u, v), grad_outputs=grad_out) - torch.testing.assert_close(grad_u, 0.5 * grad_out * v.detach()) - torch.testing.assert_close(grad_v, 0.5 * grad_out * u.detach()) - - # Ordinary autodiff of u * v would give the full (unhalved) product-rule - # terms, so the rule must disagree with it. - u_plain = _leaf(_sample_rows(dtype), dtype) - v_plain = _leaf(_sample_rows(dtype).flip(0), dtype) - y_plain = u_plain * v_plain - grad_u_plain, grad_v_plain = torch.autograd.grad( - y_plain, (u_plain, v_plain), grad_outputs=grad_out - ) - assert not torch.allclose(grad_u_plain, grad_u) - assert not torch.allclose(grad_v_plain, grad_v) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_forward_and_backward_on_non_contiguous_input(self, dtype): - u = _sample_rows(dtype).t().clone().requires_grad_(True) - v = _sample_rows(dtype).flip(0).t().clone().requires_grad_(True) - assert not u.is_contiguous() - assert not v.is_contiguous() - - y = half_rule(u, v) - assert torch.equal(y, u.detach() * v.detach()) - - grad_out = torch.ones_like(u) - grad_u, grad_v = torch.autograd.grad(y, (u, v), grad_outputs=grad_out) - torch.testing.assert_close(grad_u, 0.5 * grad_out * v.detach()) - torch.testing.assert_close(grad_v, 0.5 * grad_out * u.detach()) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_batched_leading_dims(self, dtype): - u = _leaf(torch.randn(2, 3, 4), dtype) - v = _leaf(torch.randn(2, 3, 4), dtype) - y = half_rule(u, v) - assert y.shape == u.shape - assert torch.equal(y, u.detach() * v.detach()) - - grad_out = torch.ones_like(u) - grad_u, grad_v = torch.autograd.grad(y, (u, v), grad_outputs=grad_out) - torch.testing.assert_close(grad_u, 0.5 * grad_out * v.detach()) - torch.testing.assert_close(grad_v, 0.5 * grad_out * u.detach()) - - -class _Proj: - """Minimal stand-in for a projection bridge: carries only what - ``weight_layout_in_out``/``normalize_mlp_weight`` read (``original_component``).""" - - def __init__(self, original_component): - self.original_component = original_component - - -class TestGatedMLPWeightOrientation: - """``MLPBridge``'s ``W_gate``/``W_in``/``W_out`` read the underlying projection - through ``weight_layout_in_out``/``normalize_mlp_weight``, so an orientation bug in - those helpers would silently transpose a gate/up/down weight for one backing class. - Covers both HF module classes: ``nn.Linear`` (weight stored ``[out, in]``, - transposed to TL orientation) and ``Conv1D`` (weight stored ``[in, out]``, already - TL-oriented). - """ - - @pytest.fixture(params=["nn.Linear", "Conv1D"]) - def backing_class(self, request): - return request.param - - def _make_gate_up_down(self, backing_class: str, d_model: int = 3, d_mlp: int = 5): - torch.manual_seed(0) - if backing_class == "nn.Linear": - gate_proj = nn.Linear(d_model, d_mlp) - up_proj = nn.Linear(d_model, d_mlp) - down_proj = nn.Linear(d_mlp, d_model) - else: - gate_proj = Conv1D(d_mlp, d_model) - up_proj = Conv1D(d_mlp, d_model) - down_proj = Conv1D(d_model, d_mlp) - return gate_proj, up_proj, down_proj - - def _tl_weight(self, proj: torch.nn.Module, pattern: str) -> torch.Tensor: - wrapper = _Proj(proj) - layout = weight_layout_in_out(wrapper) - return normalize_mlp_weight(proj.weight, layout, wrapper, pattern=pattern) - - def test_tl_oriented_matmul_reproduces_native_projection(self, backing_class): - gate_proj, up_proj, down_proj = self._make_gate_up_down(backing_class) - x = torch.randn(2, 3) - - w_gate = self._tl_weight(gate_proj, pattern="in") - w_in = self._tl_weight(up_proj, pattern="in") - w_out = self._tl_weight(down_proj, pattern="out") - - assert torch.allclose(x @ w_gate + gate_proj.bias, gate_proj(x), atol=1e-6) - assert torch.allclose(x @ w_in + up_proj.bias, up_proj(x), atol=1e-6) - hidden = torch.randn(2, 5) - assert torch.allclose(hidden @ w_out + down_proj.bias, down_proj(hidden), atol=1e-6) - - -class TestPinnedReferenceParity: - """Tolerant parity against ``FarnoushRJ/RelP`` pinned at - ``8219d6dc417c3fd7f318342cf61cd2a0c20b7250``. - - That repository vendors an unrelated pre-Bridge TransformerLens fork, so its - rule formulas are reimplemented here directly from the pinned commit's - component diffs rather than imported: - - - LN-rule (``transformer_lens/components/rms_norm.py``): ``x / scale.detach()``. - - Identity-rule (``transformer_lens/utilities/activation_functions.py``, - class ``ModifiedAct``): ``zp = stabilize(x); zp * (act_fn(x) / zp).detach()``, - where ``stabilize(z) = z + ((z == 0) + sign(z)) * 1e-6`` - (``transformer_lens/lrp_utils.py``). - - Half-rule (``transformer_lens/components/mlps/gated_mlp.py``): - ``z = u * v; z / 2 + (z / 2).detach()``. - - The LN- and Half-rule reference formulas produce the same VJP as this module's - primitives to floating-point precision. The Identity-rule reference formula - does not: its epsilon stabilizer only approximates the paper-defined factor - away from ``x == 0``, and collapses to exactly zero at ``x == 0`` where the - paper-defined factor's removable-singularity limit is ``0.5``. - """ - - @staticmethod - def _reference_stabilize(z: torch.Tensor) -> torch.Tensor: - return z + ((z == 0).to(z.dtype) + torch.sign(z)) * 1e-6 - - @classmethod - def _reference_ln_rule_grad(cls, x: torch.Tensor, denom_fn) -> torch.Tensor: - x = x.clone().requires_grad_(True) - denom = denom_fn(x) - y = x / denom.detach() - (grad,) = torch.autograd.grad(y, x, grad_outputs=torch.ones_like(x)) - return grad - - @classmethod - def _reference_identity_rule_grad(cls, x: torch.Tensor, act_fn) -> torch.Tensor: - x = x.clone().requires_grad_(True) - z = act_fn(x) - zp = cls._reference_stabilize(x) - y = zp * (z / zp).detach() - (grad,) = torch.autograd.grad(y, x, grad_outputs=torch.ones_like(x)) - return grad - - @classmethod - def _reference_half_rule_grad(cls, u: torch.Tensor, v: torch.Tensor): - u = u.clone().requires_grad_(True) - v = v.clone().requires_grad_(True) - z = u * v - y = z / 2 + (z / 2).detach() - grad_u, grad_v = torch.autograd.grad(y, (u, v), grad_outputs=torch.ones_like(u)) - return grad_u, grad_v - - @pytest.mark.parametrize("dtype", DTYPES) - def test_ln_rule_matches_reference_grad(self, dtype): - def denom_fn(t: torch.Tensor) -> torch.Tensor: - return (t.pow(2).mean(-1, keepdim=True) + 1e-6).sqrt() - - x = _sample_rows(dtype) - - x_rule = _leaf(x, dtype) - denom_rule = denom_fn(x_rule) - y_rule = ln_rule(x_rule, denom_rule) - (grad_rule,) = torch.autograd.grad(y_rule, x_rule, grad_outputs=torch.ones_like(x_rule)) - - grad_reference = self._reference_ln_rule_grad(x, denom_fn) - torch.testing.assert_close(grad_rule, grad_reference) - - @pytest.mark.parametrize("dtype", DTYPES) - @pytest.mark.parametrize( - "act_fn", - [F.silu, partial(F.gelu, approximate="none"), partial(F.gelu, approximate="tanh")], - ids=["silu", "gelu_exact", "gelu_tanh"], - ) - def test_identity_rule_matches_reference_away_from_zero(self, dtype, act_fn): - x = _sample_rows(dtype) - nonzero_mask = x != 0 - - x_rule = _leaf(x, dtype) - y_rule = identity_rule(x_rule, act_fn) - (grad_rule,) = torch.autograd.grad(y_rule, x_rule, grad_outputs=torch.ones_like(x_rule)) - - grad_reference = self._reference_identity_rule_grad(x, act_fn) - - torch.testing.assert_close( - grad_rule[nonzero_mask], - grad_reference[nonzero_mask], - rtol=1e-4, - atol=1e-5, - ) - - @pytest.mark.parametrize("dtype", DTYPES) - @pytest.mark.parametrize( - "act_fn", - [F.silu, partial(F.gelu, approximate="none"), partial(F.gelu, approximate="tanh")], - ids=["silu", "gelu_exact", "gelu_tanh"], - ) - def test_identity_rule_exact_zero_discrepancy_is_documented(self, dtype, act_fn): - """At ``x == 0`` this module's Identity-rule uses the paper-defined - removable-singularity limit ``0.5``, while the pinned reference's epsilon - stabilizer yields exactly ``0``. Assert both values explicitly, rather than - letting a tolerance absorb the gap, so a change to either side's zero - handling is caught instead of silently passing. - """ - x = torch.zeros(3, dtype=dtype) - - x_rule = _leaf(x, dtype) - y_rule = identity_rule(x_rule, act_fn) - (grad_rule,) = torch.autograd.grad(y_rule, x_rule, grad_outputs=torch.ones_like(x_rule)) - torch.testing.assert_close(grad_rule, torch.full_like(x, 0.5)) - - grad_reference = self._reference_identity_rule_grad(x, act_fn) - torch.testing.assert_close(grad_reference, torch.zeros_like(x)) - - @pytest.mark.parametrize("dtype", DTYPES) - def test_half_rule_matches_reference_grad(self, dtype): - u = _sample_rows(dtype) - v = _sample_rows(dtype).flip(0) - - u_rule = _leaf(u, dtype) - v_rule = _leaf(v, dtype) - y_rule = half_rule(u_rule, v_rule) - grad_u_rule, grad_v_rule = torch.autograd.grad( - y_rule, (u_rule, v_rule), grad_outputs=torch.ones_like(u_rule) - ) - - grad_u_reference, grad_v_reference = self._reference_half_rule_grad(u, v) - torch.testing.assert_close(grad_u_rule, grad_u_reference) - torch.testing.assert_close(grad_v_rule, grad_v_reference) diff --git a/tests/unit/tools/test_svd_circuits.py b/tests/unit/tools/test_svd_circuits.py deleted file mode 100644 index 562ee7700f..0000000000 --- a/tests/unit/tools/test_svd_circuits.py +++ /dev/null @@ -1,1846 +0,0 @@ -"""Unit tests for per-head QK/OV singular-vector decomposition. - -Mostly model-free: most tests build synthetic weight tensors and exercise the factored SVD -and the degeneracy guard directly, so no model is loaded and no pretrained weights are -downloaded. A few tests instantiate a tiny, randomly-initialized TransformerBridge (CPU-only, -no Hub access) where a real per-head decomposition is needed for a cross-check. -""" - -import dataclasses -import math -import warnings -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest -import torch - -from transformer_lens.tools.analysis.svd_circuits import ( - _DEFAULT_N_BASELINE, - ActivationProjection, - DegenerateDirectionError, - HeadSVD, - LogitSignature, - PatchResult, - RankReportRow, - _degeneracy_blocks, - _factored_head_svd, - _make_subspace_hook, - _resolve_retained, - decompose_head, - logit_signature, - patch_along_directions, - project_activations, - vocab_readout, -) - -D_MODEL, D_HEAD = 12, 4 - - -class _StubAttn: - def __init__(self, W_Q, W_K, W_V, W_O): - self.W_Q, self.W_K, self.W_V, self.W_O = W_Q, W_K, W_V, W_O - - -class _StubBlock: - def __init__(self, attn): - self.attn = attn - - -class _StubModel: - """Model-free stand-in exposing cfg.n_layers/n_heads and blocks[i].attn.W_*. - - W_K/W_V carry ``n_kv_heads`` rows (the grouped-query layout); W_Q/W_O carry - ``n_heads`` rows, matching the per-block shapes ``decompose_head`` reads. Each - layer draws its own weights so a read from the wrong layer is visible. - """ - - def __init__( - self, - n_heads, - n_kv_heads, - n_layers=1, - d_model=D_MODEL, - d_head=D_HEAD, - seed=0, - dtype=torch.float32, - requires_grad=False, - ): - g = torch.Generator().manual_seed(seed) - self.cfg = SimpleNamespace(n_layers=n_layers, n_heads=n_heads) - self.blocks = [] - for _ in range(n_layers): - shapes = [ - (n_heads, d_model, d_head), - (n_kv_heads, d_model, d_head), - (n_kv_heads, d_model, d_head), - (n_heads, d_head, d_model), - ] - weights = [torch.randn(*shape, generator=g).to(dtype) for shape in shapes] - if requires_grad: - weights = [torch.nn.Parameter(w) for w in weights] - self.blocks.append(_StubBlock(_StubAttn(*weights))) - - -def _reconstruct(res): - return res.U @ torch.diag(res.S) @ res.V.transpose(-2, -1) - - -# --------------------------------------------------------------------------- # -# Synthetic fixtures -# --------------------------------------------------------------------------- # -def _random_ov(seed=0): - """A non-degenerate OV factoring: W_V [d_model, d_head], W_O [d_head, d_model].""" - g = torch.Generator().manual_seed(seed) - W_V = torch.randn(D_MODEL, D_HEAD, generator=g) - W_O = torch.randn(D_HEAD, D_MODEL, generator=g) - return W_V, W_O - - -def _factored_with_spectrum(spectrum, seed=0): - """Build (A, B) so that A @ B == U0 @ diag(s) @ V0.T with U0, V0 orthonormal columns. - - ``spectrum`` is a descending sequence of singular values; passing a repeated value plants - an exactly-degenerate block, so the recovered SVD has a known, controllable spectrum. - """ - s = torch.as_tensor(spectrum, dtype=torch.float32) - g = torch.Generator().manual_seed(seed) - U0, _ = torch.linalg.qr(torch.randn(D_MODEL, len(s), generator=g)) - V0, _ = torch.linalg.qr(torch.randn(D_MODEL, len(s), generator=g)) - root = s.sqrt() - A = U0 @ torch.diag(root) # [d_model, r] - B = torch.diag(root) @ V0.transpose(-2, -1) # [r, d_model] - return A, B - - -# --------------------------------------------------------------------------- # -# Oracle / correctness of the factored SVD -# --------------------------------------------------------------------------- # -def test_singular_values_match_torch_linalg_svd_on_ov(): - """The factored OV SVD reproduces the singular values of the materialized W_V @ W_O.""" - W_V, W_O = _random_ov() - S_ref = torch.linalg.svd(W_V @ W_O).S - res = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - assert isinstance(res, HeadSVD) - assert torch.allclose(res.S, S_ref[:D_HEAD], atol=1e-5) - - -def test_reconstruction_matches_materialized_matrix(): - """U @ diag(S) @ V.T rebuilds W_V @ W_O, sidestepping the U/V column-sign ambiguity.""" - W_V, W_O = _random_ov() - res = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - assert torch.allclose(_reconstruct(res), W_V @ W_O, atol=1e-4) - - -def test_singular_values_sorted_descending(): - W_V, W_O = _random_ov() - res = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - assert bool(torch.all(res.S[:-1] + 1e-6 >= res.S[1:])) - - -def test_rank_bounded_by_d_head(): - """A product of d_head-rank factors has at most d_head singular values, all reported.""" - W_V, W_O = _random_ov() - res = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - assert res.S.shape[0] == D_HEAD - assert int((res.S > 1e-6).sum()) <= D_HEAD - - -def test_qk_svd_matches_materialized(): - """The QK path factors W_Q and W_K.T and matches torch.linalg.svd(W_Q @ W_K.T).""" - g = torch.Generator().manual_seed(1) - W_Q = torch.randn(D_MODEL, D_HEAD, generator=g) - W_K = torch.randn(D_MODEL, D_HEAD, generator=g) - S_ref = torch.linalg.svd(W_Q @ W_K.transpose(-1, -2)).S - res = _factored_head_svd(W_Q, W_K.transpose(-1, -2), which="QK", layer=0, head=0, eps=1e-2) - assert torch.allclose(res.S, S_ref[:D_HEAD], atol=1e-5) - - -def test_uses_V_not_Vh_no_deprecation_warning(): - """FactoredMatrix.Vh is a deprecated alias that warns; the decomposition must read .V only.""" - W_V, W_O = _random_ov() - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - - -def test_rank_report_shape_and_ratio(): - """Each direction gets one report row; sigma_ratio is sigma normalized by the top value.""" - W_V, W_O = _random_ov() - res = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - assert len(res.rank_report) == D_HEAD - assert all(isinstance(row, RankReportRow) for row in res.rank_report) - top = float(res.S.max()) - for row in res.rank_report: - assert row.sigma_ratio == pytest.approx(row.sigma / top, rel=1e-5) - assert res.rank_report[0].sigma_ratio == pytest.approx(1.0) - - -# --------------------------------------------------------------------------- # -# Degeneracy guard: refuse per-direction attribution inside a degenerate block -# --------------------------------------------------------------------------- # -def test_degeneracy_block_flagged_on_equal_singular_values(): - """Equal singular values (5, 3, 3, 1) make directions 1 and 2 rotation-ambiguous.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert res.is_degenerate(1) and res.is_degenerate(2) - assert not res.is_degenerate(0) and not res.is_degenerate(3) - - -def test_block_of_returns_full_degenerate_run(): - """block_of returns the whole degenerate run for a member, and a singleton otherwise.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert set(res.block_of(1)) == {1, 2} == set(res.block_of(2)) - assert res.block_of(0) == [0] - - -def test_require_isolated_raises_inside_block(): - """require_isolated raises for a degenerate direction and returns None for an isolated one.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(DegenerateDirectionError): - res.require_isolated(1) - assert res.require_isolated(0) is None - - -def test_degenerate_error_message_points_to_block(): - """The refusal names the block indices and steers the caller to subspace attribution.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(DegenerateDirectionError) as excinfo: - res.require_isolated(1) - message = str(excinfo.value) - assert "1" in message and "2" in message - assert "subspace" in message.lower() - - -def test_refusal_message_names_the_rule_that_formed_the_block(): - """A gap-formed block is refused for its eps gap; a null block is refused as null.""" - gap = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="QK", layer=2, head=3, eps=1e-2 - ) - with pytest.raises( - DegenerateDirectionError, match=r"QK SVD of head L2H3.*eps=0\.01" - ) as gap_exc: - gap.require_isolated(2) - assert "null" not in str(gap_exc.value) - null = _factored_head_svd( - *_factored_with_spectrum([5.0, 2e-9, 1e-9]), which="OV", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(DegenerateDirectionError, match="numerically null"): - null.require_isolated(1) - - -def test_well_separated_spectrum_flags_nothing(): - """A well-separated spectrum (8, 4, 2, 1) has no degenerate block and no flagged row.""" - res = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert res.degenerate_blocks() == [] - assert not any(row.is_degenerate for row in res.rank_report) - - -def test_near_but_not_equal_respects_eps(): - """A relative gap of 5e-3 groups under eps=1e-2 but stays isolated under eps=1e-3.""" - spectrum = [3.0, 3.0 * (1 - 5e-3), 1.0, 0.5] - grouped = _factored_head_svd( - *_factored_with_spectrum(spectrum), which="OV", layer=0, head=0, eps=1e-2 - ) - assert grouped.is_degenerate(0) and grouped.is_degenerate(1) - separated = _factored_head_svd( - *_factored_with_spectrum(spectrum), which="OV", layer=0, head=0, eps=1e-3 - ) - assert not separated.is_degenerate(0) and not separated.is_degenerate(1) - - -def test_null_run_grouped(): - """Two directions the gap rule cannot group (8e-3 is 40x above 2e-4) form one null - block under a null_rtol wide enough to cover both.""" - res = _factored_head_svd( - *_factored_with_spectrum([1.0, 8e-3, 2e-4]), - which="OV", - layer=0, - head=0, - eps=1e-2, - null_rtol=1e-2, - ) - assert res.degenerate_blocks() == [[1, 2]] - with pytest.raises(DegenerateDirectionError): - res.require_isolated(1) - with pytest.raises(DegenerateDirectionError): - res.require_isolated(2) - - -def test_null_default_does_not_overgroup(): - """Under the default null_rtol, 8e-3 and 2e-4 are both far above the null cutoff and 40x - apart, so neither is degenerate.""" - res = _factored_head_svd( - *_factored_with_spectrum([1.0, 8e-3, 2e-4]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert not res.is_degenerate(1) - assert not res.is_degenerate(2) - - -def test_null_run_groups_true_null_tail(): - """The default null_rtol still catches a genuinely near-zero tail as a null block.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 1.0, 1e-9, 1e-9]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert res.degenerate_blocks() == [[2, 3]] - with pytest.raises(DegenerateDirectionError): - res.require_isolated(2) - with pytest.raises(DegenerateDirectionError): - res.require_isolated(3) - - -def test_lone_null_direction_is_degenerate(): - """A single null direction is refused on its own: its singular vector is an arbitrary - null-space vector even though no neighbour groups with it.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 1.0, 0.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert res.rank_report[3].is_null and res.is_degenerate(3) - assert res.degenerate_blocks() == [[3]] - with pytest.raises(DegenerateDirectionError, match="null"): - res.require_isolated(3) - assert all(res.require_isolated(i) is None for i in range(3)) - - -def test_degenerate_blocks_returns_equal_run(): - """degenerate_blocks returns the repeated-value run and nothing else.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - assert res.degenerate_blocks() == [[1, 2]] - - -def test_slow_decay_forms_one_gap_separated_block(): - """A tail decaying 0.9% per step has no eps=1e-2 gap anywhere inside it, so it is one - block, and only the well-separated top direction is isolated.""" - tail = [0.5 * (0.991**i) for i in range(7)] - spectrum = [1.0] + tail - res = _factored_head_svd( - *_factored_with_spectrum(spectrum), which="OV", layer=0, head=0, eps=1e-2 - ) - assert res.require_isolated(0) is None - assert res.degenerate_blocks() == [[1, 2, 3, 4, 5, 6, 7]] - - -def test_degeneracy_blocks_partition_all_indices(): - """Degenerate blocks plus the remaining singletons cover every direction exactly once.""" - res = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - covered = [i for block in res.degenerate_blocks() for i in block] - singletons = [i for i in range(D_HEAD) if i not in covered] - assert sorted(covered + singletons) == list(range(D_HEAD)) - assert len(covered + singletons) == D_HEAD # no direction counted twice - - -# --------------------------------------------------------------------------- # -# Degeneracy grouping on a raw spectrum (fully model-free) -# --------------------------------------------------------------------------- # -def test_degeneracy_blocks_groups_equal_run_directly(): - """_degeneracy_blocks groups a repeated value and isolates the well-separated neighbours.""" - blocks = _degeneracy_blocks(torch.tensor([5.0, 3.0, 3.0, 1.0]), eps=1e-2, null_rtol=1e-2) - assert blocks == [[0], [1, 2], [3]] - - -def test_degeneracy_blocks_on_well_separated_are_all_singletons(): - blocks = _degeneracy_blocks(torch.tensor([8.0, 4.0, 2.0, 1.0]), eps=1e-2, null_rtol=1e-2) - assert blocks == [[0], [1], [2], [3]] - - -@pytest.mark.parametrize("seed", range(5)) -@pytest.mark.parametrize("eps", [1e-2, 5e-2]) -def test_every_block_boundary_is_an_eps_gap(seed, eps): - """Blocks end exactly where the spectrum has a relative gap of at least eps, so every - boundary is eps-separated and no singleton has a neighbour within eps.""" - g = torch.Generator().manual_seed(seed) - S = torch.sort(torch.rand(64, generator=g) * 10 + 0.1, descending=True).values - S[10:20] = S[10] # a planted exactly-equal run - blocks = _degeneracy_blocks(S, eps=eps, null_rtol=0.0) - gap = lambda i, j: 1.0 - float(S[j]) / float(S[i]) # i < j - assert [i for block in blocks for i in block] == list(range(64)) - for left, right in zip(blocks, blocks[1:]): - assert gap(left[-1], right[0]) >= eps - for block in blocks: - for i, j in zip(block, block[1:]): - assert gap(i, j) < eps - - -# --------------------------------------------------------------------------- # -# decompose_head wiring and input guards (model-free, via _StubModel) -# --------------------------------------------------------------------------- # -def test_decompose_head_mha_reads_requested_head(): - """MHA stub (n_heads == n_kv_heads): each head's OV decomposes its own W_V/W_O.""" - model = _StubModel(n_heads=4, n_kv_heads=4) - attn = model.blocks[0].attn - for h in range(4): - result = decompose_head(model, layer=0, head=h, which=("OV",)) - S_ref = torch.linalg.svd(attn.W_V[h] @ attn.W_O[h]).S - assert torch.allclose(result.OV.S, S_ref[:D_HEAD], atol=1e-4) - assert torch.allclose(_reconstruct(result.OV), attn.W_V[h] @ attn.W_O[h], atol=1e-4) - - -def test_decompose_head_gqa_maps_query_to_kv_head(): - """GQA stub (4 query heads, 2 kv heads): each query head reads its own kv head, with - U/V orientation and the head identity pinned as well.""" - model = _StubModel(n_heads=4, n_kv_heads=2) - attn = model.blocks[0].attn - n_heads = model.cfg.n_heads - n_kv_heads = attn.W_K.shape[0] - for h in range(n_heads): - result = decompose_head(model, layer=0, head=h, which=("QK", "OV")) - kv_head = h // (n_heads // n_kv_heads) - ov_ref = attn.W_V[kv_head] @ attn.W_O[h] - qk_ref = attn.W_Q[h] @ attn.W_K[kv_head].transpose(-1, -2) - assert torch.allclose(result.OV.S, torch.linalg.svd(ov_ref).S[:D_HEAD], atol=1e-4) - assert torch.allclose(result.QK.S, torch.linalg.svd(qk_ref).S[:D_HEAD], atol=1e-4) - assert torch.allclose(_reconstruct(result.OV), ov_ref, atol=1e-4) - assert torch.allclose(_reconstruct(result.QK), qk_ref, atol=1e-4) - assert (result.QK.which, result.QK.layer, result.QK.head) == ("QK", 0, h) - assert (result.OV.which, result.OV.layer, result.OV.head) == ("OV", 0, h) - - -def test_decompose_head_reads_requested_layer(): - """On a two-layer stub, layer 1's decomposition matches layer 1's weights, not layer 0's.""" - model = _StubModel(n_heads=2, n_kv_heads=2, n_layers=2) - result = decompose_head(model, layer=1, head=1, which=("OV",)) - own = model.blocks[1].attn.W_V[1] @ model.blocks[1].attn.W_O[1] - other = model.blocks[0].attn.W_V[1] @ model.blocks[0].attn.W_O[1] - assert torch.allclose(_reconstruct(result.OV), own, atol=1e-4) - assert not torch.allclose(_reconstruct(result.OV), other, atol=1e-2) - assert result.OV.layer == 1 - - -def test_decompose_head_qk_transpose_wiring(): - """QK factors W_Q_h and W_K_h.T; res.QK.S matches svd(W_Q_h @ W_K_h.T).""" - model = _StubModel(n_heads=4, n_kv_heads=4) - attn = model.blocks[0].attn - result = decompose_head(model, layer=0, head=2, which=("QK",)) - S_ref = torch.linalg.svd(attn.W_Q[2] @ attn.W_K[2].transpose(-1, -2)).S - assert torch.allclose(result.QK.S, S_ref[:D_HEAD], atol=1e-4) - - -def test_decompose_head_detaches_from_model_weights(): - """The returned factors carry no autograd graph back to the model's parameters.""" - model = _StubModel(n_heads=2, n_kv_heads=2, requires_grad=True) - result = decompose_head(model, layer=0, head=0) - for res in (result.QK, result.OV): - for tensor in (res.U, res.S, res.V): - assert not tensor.requires_grad and tensor.grad_fn is None - - -def test_decompose_head_keeps_float64_and_promotes_half(): - """float64 weights stay float64 (and null_rtol follows), fp16 weights are promoted.""" - result = decompose_head(_StubModel(n_heads=2, n_kv_heads=2, dtype=torch.float64), 0, 0) - assert result.OV.S.dtype == torch.float64 - assert result.OV.null_rtol == pytest.approx(D_MODEL * torch.finfo(torch.float64).eps) - result = decompose_head(_StubModel(n_heads=2, n_kv_heads=2, dtype=torch.float16), 0, 0) - assert result.OV.S.dtype == torch.float32 - assert result.OV.null_rtol == pytest.approx(D_MODEL * torch.finfo(torch.float32).eps) - - -def test_decompose_head_which_filter(): - """which=("OV",) returns only OV; which=("QK",) returns only QK.""" - model = _StubModel(n_heads=2, n_kv_heads=2) - ov_only = decompose_head(model, layer=0, head=0, which=("OV",)) - assert ov_only.OV is not None and ov_only.QK is None - qk_only = decompose_head(model, layer=0, head=0, which=("QK",)) - assert qk_only.QK is not None and qk_only.OV is None - - -def test_decompose_head_rejects_bad_layer(): - model = _StubModel(n_heads=2, n_kv_heads=2, n_layers=1) - with pytest.raises(ValueError): - decompose_head(model, layer=1, head=0) - - -def test_decompose_head_rejects_bad_head(): - model = _StubModel(n_heads=2, n_kv_heads=2) - with pytest.raises(ValueError): - decompose_head(model, layer=0, head=2) - - -def test_decompose_head_rejects_empty_which(): - model = _StubModel(n_heads=2, n_kv_heads=2) - with pytest.raises(ValueError): - decompose_head(model, layer=0, head=0, which=()) - - -def test_decompose_head_rejects_unknown_which(): - model = _StubModel(n_heads=2, n_kv_heads=2) - with pytest.raises(ValueError, match="which entries"): - decompose_head(model, layer=0, head=0, which=("QK", "XX")) - - -def test_decompose_head_rejects_bare_string_which(): - """A bare string is refused instead of being iterated into its characters.""" - model = _StubModel(n_heads=2, n_kv_heads=2) - with pytest.raises(ValueError, match="sequence"): - decompose_head(model, layer=0, head=0, which="QK") - - -class _StubBridgeBlock: - """Bridge-like block: declares its submodules and, like a bridge block, resolves any - undeclared name through its wrapped HF module.""" - - def __init__(self, submodules, original_component): - self.submodules = submodules - self.original_component = original_component - - def __getattr__(self, name): - if name in self.__dict__.get("submodules", {}): - return self.__dict__["submodules"][name] - return getattr(self.__dict__["original_component"], name) - - -def _hf_layer_with_raw_attn(): - """A wrapped HF layer whose own ``attn`` is not a bridge attention (no W_Q/W_K/W_V/W_O).""" - return SimpleNamespace(attn=object()) - - -def test_decompose_head_refuses_block_without_attn(): - """A hybrid layer whose block declares no attn (an SSM mixer here) is refused with the layer, - the block class, its submodules and the layers that do carry attention, even though the - wrapped HF layer answers to ``attn``; the attention layer still decomposes its own weights.""" - model = _StubModel(n_heads=2, n_kv_heads=2, n_layers=2) - attn = model.blocks[1].attn - model.blocks = [ - _StubBridgeBlock({"mixer": object(), "norm": object()}, _hf_layer_with_raw_attn()), - _StubBridgeBlock({"attn": attn, "ln1": object()}, _hf_layer_with_raw_attn()), - ] - with pytest.raises(ValueError, match="attention block at layer 0") as excinfo: - decompose_head(model, layer=0, head=0) - message = str(excinfo.value) - assert "_StubBridgeBlock" in message - assert "['mixer', 'norm']" in message - assert "Layers with an `attn` submodule: [1]" in message - - ov = decompose_head(model, layer=1, head=1, which=("OV",)).OV - assert torch.allclose(_reconstruct(ov), attn.W_V[1] @ attn.W_O[1], atol=1e-4) - - -def test_decompose_head_refuses_when_no_block_has_attn(): - """A pure-SSM stack names the refused layer and reports that no layer carries attention.""" - model = _StubModel(n_heads=2, n_kv_heads=2, n_layers=2) - model.blocks = [ - _StubBridgeBlock({"mixer": object(), "norm": object()}, _hf_layer_with_raw_attn()) - for _ in range(2) - ] - with pytest.raises(ValueError, match="attention block at layer 1.*No block in this model"): - decompose_head(model, layer=1, head=0) - - -def test_decompose_head_refuses_model_without_blocks(): - """An encoder-decoder bridge has no `blocks` stack; the refusal names the requested layer - instead of surfacing the bridge's bare AttributeError.""" - model = SimpleNamespace(cfg=SimpleNamespace(n_layers=2, n_heads=2)) - with pytest.raises(ValueError, match="layer 1's head weights.*no `blocks` stack"): - decompose_head(model, layer=1, head=0) - - -# --------------------------------------------------------------------------- # -# OV output-direction convention (cross-check against SVDInterpreter) -# --------------------------------------------------------------------------- # -def _make_tiny_bridge(): - """Build a tiny GPT-2 bridge: MHA (n_heads == n_kv_heads), CPU-only, no Hub access.""" - from transformers import GPT2Config, GPT2LMHeadModel - - from transformer_lens.config.transformer_bridge_config import ( - TransformerBridgeConfig, - ) - from transformer_lens.model_bridge import TransformerBridge - from transformer_lens.model_bridge.supported_architectures.gpt2 import ( - GPT2ArchitectureAdapter, - ) - - torch.manual_seed(0) - hf_model = GPT2LMHeadModel( - GPT2Config(n_embd=32, n_layer=1, n_head=2, vocab_size=64, n_positions=32) - ).eval() - cfg = TransformerBridgeConfig( - d_model=32, - d_head=16, - n_heads=2, - n_layers=1, - n_ctx=32, - d_vocab=64, - architecture="GPT2LMHeadModel", - ) - tokenizer = MagicMock() - # A real tokenizer isn't available (no-download fixture), but to_str_tokens's return - # value is jaxtyped-checked as List[str], so batch_decode must return actual strings. - tokenizer.batch_decode = lambda tokens_list, **kwargs: [str(ids[0]) for ids in tokens_list] - return TransformerBridge(hf_model, GPT2ArchitectureAdapter(cfg), tokenizer=tokenizer) - - -@pytest.fixture(scope="module") -def tiny_bridge(): - """Module-scoped tiny bridge. Compatibility mode folds weights in place and has no - inverse, so tests that enable it must build their own bridge via ``_make_tiny_bridge`` - rather than mutate this shared instance.""" - return _make_tiny_bridge() - - -def test_ov_output_direction_matches_svd_interpreter(tiny_bridge): - """OV's write/vocab-readout direction is ``.V``, not ``.U`` - checked against the - already-shipped ``SVDInterpreter``, not just internal self-consistency, so the - assertion cannot pass under either U/V labeling by construction.""" - from transformer_lens.SVDInterpreter import SVDInterpreter - - layer, head = 0, 0 - decomposition = decompose_head(tiny_bridge, layer, head, which=("OV",)) - ov = decomposition.OV - W_U = tiny_bridge.W_U - - interpreter = SVDInterpreter(tiny_bridge) - reference = interpreter.get_singular_vectors("OV", layer, head_index=head, num_vectors=1) - - projected_via_V = ov.V[:, 0] @ W_U - assert torch.allclose(projected_via_V, reference[:, 0, 0], atol=1e-4) or torch.allclose( - projected_via_V, -reference[:, 0, 0], atol=1e-4 - ) - - projected_via_U = ov.U[:, 0] @ W_U - assert not torch.allclose(projected_via_U, reference[:, 0, 0], atol=1e-2) - assert not torch.allclose(projected_via_U, -reference[:, 0, 0], atol=1e-2) - - -def test_vocab_readout_matches_svd_interpreter_through_public_api(): - """The public vocab_readout, not just a hand-rolled V @ W_U, reproduces SVDInterpreter's - OV readout up to sign. - - The shape-only check elsewhere passes a U-for-V swap or an all-zeros return inside - vocab_readout; matching the shipped reference through the public function fails both. - Needs compatibility mode for the W_U-folding contract, so this builds its own bridge - rather than using the shared no-compat fixture. - """ - from transformer_lens.SVDInterpreter import SVDInterpreter - - model = _make_tiny_bridge() - model.enable_compatibility_mode() - layer, head = 0, 0 - ov = decompose_head(model, layer, head, which=("OV",)).OV - top = next(row.idx for row in ov.rank_report if not row.is_degenerate) - - readout = vocab_readout(model, ov, k=top + 1) - reference = SVDInterpreter(model).get_singular_vectors( - "OV", layer, head_index=head, num_vectors=top + 1 - )[:, 0, top] - - assert torch.allclose(readout[:, top], reference, atol=1e-4) or torch.allclose( - readout[:, top], -reference, atol=1e-4 - ) - - -# --------------------------------------------------------------------------- # -# Folded-LayerNorm binding: a decomposition is tied to the state it was built under -# --------------------------------------------------------------------------- # -def test_decompose_head_records_folded_ln_state(): - """Each HeadSVD records whether the model's weights carried a folded final LayerNorm - at decomposition time, for both maps and both toggle values. A fresh bridge has not - processed its weights, so the state is off; ``enable_compatibility_mode()`` is what - turns it on.""" - off = decompose_head(_make_tiny_bridge(), 0, 0, which=("QK", "OV")) - assert off.OV.folded_ln is False - assert off.QK.folded_ln is False - - on_model = _make_tiny_bridge() - on_model.enable_compatibility_mode() - on = decompose_head(on_model, 0, 0, which=("QK", "OV")) - assert on.OV.folded_ln is True - assert on.QK.folded_ln is True - - -@pytest.mark.parametrize( - "call_consumer", - [ - lambda model, ov: vocab_readout(model, ov), - lambda model, ov: logit_signature(model, ov, direction=0, tokens=torch.tensor([0])), - lambda model, ov: project_activations(model, ov, torch.tensor([[5, 63, 7, 9]])), - lambda model, ov: patch_along_directions( - model, ov, torch.tensor([[5, 63, 7, 9]]), lambda logits: float(logits.sum()), keep=[0] - ), - ], - ids=["vocab_readout", "logit_signature", "project_activations", "patch_along_directions"], -) -def test_consumers_reject_stale_folded_ln_state(call_consumer): - """A HeadSVD decomposed before enable_compatibility_mode() describes the pre-folding OV - map, so every consumer refuses it once the model's weights have been folded and points - the caller back at decompose_head. The refusal fires before any forward pass runs.""" - bridge = _make_tiny_bridge() - stale = decompose_head(bridge, 0, 0, which=("OV",)).OV - bridge.enable_compatibility_mode() - with pytest.raises(ValueError, match="decompose_head"): - call_consumer(bridge, stale) - - -# --------------------------------------------------------------------------- # -# vocab_readout / logit_signature (OV output-direction readout) -# --------------------------------------------------------------------------- # -def test_vocab_readout_shape_and_which_guard(): - """vocab_readout returns [d_vocab, k] for OV, and rejects QK input and out-of-range k.""" - ov = _factored_head_svd(*_random_ov(), which="OV", layer=0, head=0, eps=1e-2) - vocab_size = 20 - model = SimpleNamespace(W_U=torch.randn(D_MODEL, vocab_size)) - - result = vocab_readout(model, ov, k=3) - assert result.shape == (vocab_size, 3) - - qk = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="QK", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(ValueError, match="OV"): - vocab_readout(model, qk, k=1) - - with pytest.raises(ValueError, match="k must be"): - vocab_readout(model, ov, k=D_HEAD + 1) - - -@pytest.mark.parametrize( - "setup, expect_folded", - [ - (lambda bridge: bridge.enable_compatibility_mode(no_processing=True), False), - (lambda bridge: bridge.enable_compatibility_mode(fold_ln=False), False), - (lambda bridge: bridge.process_weights(fold_ln=True), True), - ], - ids=["no_processing", "fold_ln_disabled", "processed_without_flag"], -) -def test_vocab_readout_requires_folded_ln(setup, expect_folded): - """vocab_readout requires the final LayerNorm folded into W_U, which the - compatibility_mode flag alone does not imply. - - Each case separates the flag from the fold: ``no_processing`` and ``fold_ln=False`` - leave the flag set while the norms stay unfolded, and ``process_weights(fold_ln=True)`` - folds them with the flag still off. The decomposition is taken after the state change - so a refusal comes from the readout guard rather than the staleness guard. - """ - bridge = _make_tiny_bridge() - setup(bridge) - ov = decompose_head(bridge, 0, 0, which=("OV",)).OV - - if expect_folded: - readout = vocab_readout(bridge, ov, k=3) - assert readout.shape == (bridge.cfg.d_vocab, 3) - assert torch.isfinite(readout).all() - else: - with pytest.raises(ValueError, match="fold_ln") as excinfo: - vocab_readout(bridge, ov, k=3) - # The readout guard fired, not the staleness guard. - assert "decompose_head" not in str(excinfo.value) - - -def test_logit_signature_matches_manual_projection(): - """logit_signature's reconstruction matches a hand-computed S[i] * V[:, i] projection.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - vocab_size = 16 - W_U = torch.randn(D_MODEL, vocab_size) - model = SimpleNamespace(W_U=W_U) - token_ids = torch.tensor([1, 5, 9]) - - result = logit_signature(model, ov, direction=0, tokens=token_ids) - assert isinstance(result, LogitSignature) - expected = (ov.S[0] * ov.V[:, 0]) @ W_U[:, token_ids] - assert torch.allclose(result.values, expected, atol=1e-5) - assert result.direction == 0 - - -def test_logit_signature_raises_on_degenerate_direction(): - """A rotation-ambiguous direction's 'signature' is not attributable, so this must raise.""" - ov = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - model = SimpleNamespace(W_U=torch.randn(D_MODEL, 8)) - with pytest.raises(DegenerateDirectionError): - logit_signature(model, ov, direction=1, tokens=torch.tensor([0])) - - -def test_logit_signature_rejects_out_of_range_direction(): - """A direction outside [0, rank) is a clear ValueError, not a wrap-around (direction=-1 - reading the last column) or a bare IndexError (direction=rank).""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - model = SimpleNamespace(W_U=torch.randn(D_MODEL, 8)) - rank = ov.V.shape[1] - for bad in (rank, -1): - with pytest.raises(ValueError, match="out of range"): - logit_signature(model, ov, direction=bad, tokens=torch.tensor([0])) - - -def _ov_headsvd_in_dtype(dtype): - """A minimal, well-separated OV HeadSVD with U/S/V cast to ``dtype``. - - Built by casting a float32 decomposition rather than decomposing in ``dtype`` directly, - since torch.linalg.svd does not run in half precision on CPU; the readout paths under - test only read U/S/V and W_U, so the cast stands in for a genuine reduced-precision model. - """ - base = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - return HeadSVD( - which="OV", - layer=base.layer, - head=base.head, - U=base.U.to(dtype), - S=base.S.to(dtype), - V=base.V.to(dtype), - rank_report=base.rank_report, - eps=base.eps, - null_rtol=base.null_rtol, - folded_ln=False, - ) - - -@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32, torch.float64]) -def test_vocab_readout_dtype_parametrised(dtype): - """vocab_readout promotes V to W_U's dtype, so the projection matmul runs on every model - precision instead of raising a dtype mismatch on bf16, fp16, or fp64.""" - ov = _ov_headsvd_in_dtype(dtype) - vocab_size = 20 - model = SimpleNamespace(W_U=torch.randn(D_MODEL, vocab_size, dtype=dtype)) - result = vocab_readout(model, ov, k=3) - assert result.shape == (vocab_size, 3) - assert result.dtype == dtype - assert torch.isfinite(result.float()).all() - - -@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32, torch.float64]) -def test_logit_signature_dtype_parametrised(dtype): - """logit_signature casts its rank-1 reconstruction to W_U's dtype, so it returns one - finite value per token on every model precision instead of raising a dtype mismatch.""" - ov = _ov_headsvd_in_dtype(dtype) - vocab_size = 16 - model = SimpleNamespace(W_U=torch.randn(D_MODEL, vocab_size, dtype=dtype)) - token_ids = torch.tensor([1, 5, 9]) - result = logit_signature(model, ov, direction=0, tokens=token_ids) - assert result.values.shape == (token_ids.numel(),) - assert result.values.dtype == dtype - assert torch.isfinite(result.values.float()).all() - - -# --------------------------------------------------------------------------- # -# project_activations (per-position firing coefficients in the OV output basis) -# --------------------------------------------------------------------------- # -def test_project_activations_which_guard(): - """QK has no write direction to project onto, so a QK HeadSVD is refused.""" - qk = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="QK", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(ValueError, match="OV"): - project_activations(SimpleNamespace(), qk, "hello world") - - -@pytest.mark.parametrize("head", [0, 1]) -def test_project_activations_reconstructs_head_output(tiny_bridge, head): - """Coefficients recovered against V reconstruct the actual cached hook_result slice. - - Runs on both heads of the two-head bridge, so a hardcoded head-0 read cannot pass by - only ever being asked about head 0. - """ - layer = 0 - decomposition = decompose_head(tiny_bridge, layer, head, which=("OV",)) - # A raw token tensor, not a string: tiny_bridge's tokenizer is a MagicMock and cannot - # tokenize text, but a token-id tensor bypasses that path entirely. - prompt = torch.tensor([[5, 63, 7, 9]]) - - projection = project_activations(tiny_bridge, decomposition.OV, prompt) - assert isinstance(projection, ActivationProjection) - assert projection.head_svd is decomposition.OV - - previous = tiny_bridge.cfg.use_attn_result - tiny_bridge.set_use_attn_result(True) - try: - _, cache = tiny_bridge.run_with_cache(prompt) - finally: - tiny_bridge.set_use_attn_result(previous) - expected = cache[("result", layer, "attn")][0, :, head, :] - - reconstructed = projection.coefficients @ decomposition.OV.V.transpose(-2, -1) - assert torch.allclose(reconstructed, expected, atol=1e-4) - assert projection.str_tokens == tiny_bridge.to_str_tokens(prompt) - - -def test_project_activations_restores_use_attn_result(tiny_bridge): - """use_attn_result is restored to its prior value regardless of what it started as.""" - decomposition = decompose_head(tiny_bridge, 0, 0, which=("OV",)) - original = tiny_bridge.cfg.use_attn_result - try: - for initial in (False, True): - tiny_bridge.set_use_attn_result(initial) - project_activations(tiny_bridge, decomposition.OV, torch.tensor([[5, 63, 7, 9]])) - assert tiny_bridge.cfg.use_attn_result == initial - finally: - tiny_bridge.set_use_attn_result(original) - - -def test_project_activations_caches_only_the_read_hook(tiny_bridge, monkeypatch): - """Only the one hook_result being read is cached, not every hook point of the forward. - - run_with_cache retains every activation it is not told to filter, so reading one head's - output without a names_filter would keep the whole forward pass. Pin that a filter is - passed and that it selects exactly the read hook and nothing else. - """ - layer, head = 0, 0 - ov = decompose_head(tiny_bridge, layer, head, which=("OV",)).OV - captured = {} - original = tiny_bridge.run_with_cache - - def spy(*args, **kwargs): - captured["names_filter"] = kwargs.get("names_filter") - return original(*args, **kwargs) - - monkeypatch.setattr(tiny_bridge, "run_with_cache", spy) - project_activations(tiny_bridge, ov, torch.tensor([[5, 63, 7, 9]])) - - names_filter = captured["names_filter"] - assert names_filter is not None - assert names_filter(f"blocks.{layer}.attn.hook_result") - assert not names_filter(f"blocks.{layer}.attn.hook_z") - - -@pytest.mark.parametrize( - "shape", - [(2, 4), (), (1, 1, 4)], - ids=["batched_2d", "zero_dim", "three_dim"], -) -def test_project_activations_rejects_non_single_prompt_before_forward(tiny_bridge, shape): - """Any token tensor that is not a single prompt is refused before the forward pass. - - The ids are out of vocab range, so a check that ran only after the forward would surface - the embedding's IndexError instead of this refusal. A 0-D tensor additionally pins that - the guard raises ValueError rather than the bare IndexError a shape probe would give, - and a [1, 1, pos] tensor pins that the rank check survives alongside the batch check. - """ - ov = decompose_head(tiny_bridge, 0, 0, which=("OV",)).OV - bad = torch.full(shape, 10**6, dtype=torch.long) - with pytest.raises(ValueError, match="single prompt"): - project_activations(tiny_bridge, ov, bad) - - -def test_project_activations_accepts_1d_prompt(tiny_bridge): - """A 1-D [pos] token tensor is a single prompt and matches the [1, pos] result. - - patch_along_directions already accepts a bare [pos] tensor, so project_activations must - treat it identically to its [1, pos] form rather than rejecting it as non-2-D. - """ - ov = decompose_head(tiny_bridge, 0, 0, which=("OV",)).OV - flat = torch.tensor([5, 63, 7, 9]) - batched = flat.unsqueeze(0) - assert torch.allclose( - project_activations(tiny_bridge, ov, flat).coefficients, - project_activations(tiny_bridge, ov, batched).coefficients, - ) - - -def test_project_activations_requires_eval_mode(monkeypatch): - """A model left in training mode is refused before any forward pass runs. - - Dropout would perturb each forward independently, so the cached activation would not - describe the model the caller thinks it is reading. The guard must fire before the - forward, so run_with_cache is patched to fail loudly if it is reached. - """ - bridge = _make_tiny_bridge() - ov = decompose_head(bridge, 0, 0, which=("OV",)).OV - bridge.train() - - def _fail(*args, **kwargs): - raise AssertionError("run_with_cache ran before the eval-mode guard") - - monkeypatch.setattr(bridge, "run_with_cache", _fail) - with pytest.raises(ValueError, match="evaluation mode"): - project_activations(bridge, ov, torch.tensor([[5, 63, 7, 9]])) - - -def test_patch_along_directions_requires_eval_mode(monkeypatch): - """A model left in training mode is refused before any forward pass runs. - - The control draws and the patched run must be comparable, so dropout perturbing each - pass independently would gate noise rather than a verdict. The guard must fire before - the first forward, so run_with_hooks is patched to fail loudly if it is reached. - """ - bridge = _make_tiny_bridge() - ov = decompose_head(bridge, 0, 0, which=("OV",)).OV - bridge.train() - - def _fail(*args, **kwargs): - raise AssertionError("run_with_hooks ran before the eval-mode guard") - - monkeypatch.setattr(bridge, "run_with_hooks", _fail) - with pytest.raises(ValueError, match="evaluation mode"): - patch_along_directions( - bridge, ov, torch.tensor([[5, 63, 7, 9]]), lambda logits: float(logits.sum()), keep=[0] - ) - - -@pytest.mark.parametrize("head", [0, 1]) -def test_subspace_hook_leaves_sibling_head_untouched(tiny_bridge, head): - """The patch hook rewrites only its own head's slice of the [batch, pos, head, d_model] - activation: the sibling head's block is bit-for-bit unchanged while the patched head's - block moves. - - Pins _make_subspace_hook's per-head scope on the two-head bridge, which a hook that - wrote every head, or hardcoded head 0, would break. The clean hook_result comes from a - real forward pass; applying the hook directly makes the per-head write the sole variable. - """ - ov = decompose_head(tiny_bridge, 0, head, which=("OV",)).OV - other = 1 - head - prompt = torch.tensor([[5, 63, 7, 9]]) - - previous = tiny_bridge.cfg.use_attn_result - tiny_bridge.set_use_attn_result(True) - try: - _, cache = tiny_bridge.run_with_cache(prompt) - finally: - tiny_bridge.set_use_attn_result(previous) - clean = cache[("result", 0, "attn")] - - keep = [ov.rank_report[0].idx] - projector = ov.V[:, keep] @ ov.V[:, keep].transpose(-2, -1) - patched = _make_subspace_hook(head, projector)(clean, hook=SimpleNamespace(name="hook_result")) - - assert torch.equal(patched[:, :, other, :], clean[:, :, other, :]) - assert not torch.allclose(patched[:, :, head, :], clean[:, :, head, :]) - - -# --------------------------------------------------------------------------- # -# patch_along_directions (mandatory causal gate) -# --------------------------------------------------------------------------- # -class _PatchStubModel: - """Model-free stand-in for patch_along_directions' run_with_hooks/metric protocol. - - Exposes a single fixed per-head "hook_result" activation that a hook can intercept - exactly as ``run_with_hooks`` would present it (the hook receives the activation and - a hook object, and returns the replacement), then reduces it to a scalar the same - way for the unmodified, patched, and baseline calls, so the block guard and gating - arithmetic can be tested without a real forward pass. - """ - - def __init__(self, d_model, n_heads, pos=3, seed=0, device="cpu"): - g = torch.Generator().manual_seed(seed) - self.cfg = SimpleNamespace(use_attn_result=False) - self._result = torch.randn(1, pos, n_heads, d_model, generator=g).to(device) - self._readout = torch.randn(d_model, generator=g).to(device) - - def set_use_attn_result(self, value): - self.cfg.use_attn_result = value - - def _logits(self, result): - return result.sum(dim=2) @ self._readout # [batch, pos] - - def __call__(self, prompt): - return self._logits(self._result) - - def run_with_hooks(self, prompt, fwd_hooks): - activation = self._result - for name, hook_fn in fwd_hooks: - activation = hook_fn(activation, hook=SimpleNamespace(name=name)) - return self._logits(activation) - - -@pytest.mark.parametrize( - "spectrum, guarded_ids", - [([8.0, 4.0, 4.0, 1.0], [1, 2]), ([8.0, 4.0, 2.0, 0.0], [3])], - ids=["degenerate", "null"], -) -def test_raw_projections_preserve_columns_rejected_by_attribution_guards(spectrum, guarded_ids): - """Raw consumers preserve numerical columns; signature and patch guards remain distinct.""" - - class ProjectionModel(_PatchStubModel): - def run_with_cache(self, prompt, names_filter): - hook_name = "blocks.0.attn.hook_result" - assert names_filter(hook_name) - return self(prompt), {hook_name: self._result} - - def to_str_tokens(self, prompt): - return [str(i) for i in range(self._result.shape[1])] - - ov = _factored_head_svd( - *_factored_with_spectrum(spectrum), which="OV", layer=0, head=0, eps=1e-2 - ) - model = ProjectionModel(d_model=D_MODEL, n_heads=1) - model.W_U = torch.randn(D_MODEL, 16) - readout = vocab_readout(model, ov, k=len(ov.S)) - projection = project_activations(model, ov, "prompt") - - assert readout.shape == (16, len(ov.S)) - assert projection.coefficients.shape == (model._result.shape[1], len(ov.S)) - assert torch.isfinite(readout[:, guarded_ids]).all() - assert torch.isfinite(projection.coefficients[:, guarded_ids]).all() - assert model.cfg.use_attn_result is False - for idx in guarded_ids: - assert ov.rank_report[idx].is_degenerate or ov.rank_report[idx].is_null - with pytest.raises(DegenerateDirectionError): - logit_signature(model, ov, direction=idx, tokens=[0]) - - block = ov.block_of(guarded_ids[0]) - if len(block) > 1: - with pytest.raises(DegenerateDirectionError, match="split block"): - patch_along_directions( - model, ov, "prompt", lambda logits: float(logits.sum()), keep=[block[0]] - ) - result = patch_along_directions( - model, ov, "prompt", lambda logits: float(logits.sum()), keep=block, n_baseline=1 - ) - assert result.retained == block - - -def test_patch_along_directions_which_guard(): - """QK has no write direction to reconstruct onto, so a QK HeadSVD is refused.""" - qk = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="QK", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(ValueError, match="OV"): - patch_along_directions(SimpleNamespace(), qk, "prompt", lambda logits: 0.0, keep=[0]) - - -def test_patch_along_directions_requires_keep_xor_ablate(): - """Passing both keep and ablate, or neither, is refused before any model access.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - metric = lambda logits: 0.0 - with pytest.raises(ValueError, match="exactly one"): - patch_along_directions(SimpleNamespace(), ov, "prompt", metric, keep=[0], ablate=[1]) - with pytest.raises(ValueError, match="exactly one"): - patch_along_directions(SimpleNamespace(), ov, "prompt", metric) - - -def test_patch_along_directions_rejects_partial_degenerate_block(): - """keep must take a degenerate block whole or not at all; a partial slice is refused, - and the whole block plus an isolated direction is accepted.""" - ov = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - metric = lambda logits: 0.0 - with pytest.raises(DegenerateDirectionError): - patch_along_directions(SimpleNamespace(), ov, "prompt", metric, keep=[0, 1]) - - stub = _PatchStubModel(d_model=D_MODEL, n_heads=1) - result = patch_along_directions( - stub, ov, "prompt", lambda logits: float(logits.sum()), keep=[0, 1, 2] - ) - assert isinstance(result, PatchResult) - assert result.retained == [0, 1, 2] - - -def test_resolve_retained_rejects_out_of_range(): - """keep/ablate indices outside [0, rank) raise ValueError, not a silent wrap-around for a - negative index or a silent no-op for an out-of-range ablate.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - for bad in ([999], [-1]): - with pytest.raises(ValueError, match="out of range"): - _resolve_retained(ov, keep=bad, ablate=None) - with pytest.raises(ValueError, match="out of range"): - _resolve_retained(ov, keep=None, ablate=bad) - - -def test_patch_rejects_degenerate_retained_without_threshold(): - """A retained set that is empty (keep=[] or ablate over the full rank) or spans the full - rank (keep over every direction) makes the kept projector coincide with every equal-width - random control, so the gate ties by construction. Each case is refused unless the caller - passes an explicit threshold, and accepted when one is.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - rank = ov.V.shape[1] - stub = _PatchStubModel(d_model=D_MODEL, n_heads=1) - metric = lambda logits: float(logits.sum()) - cases = [ - (dict(keep=[]), []), - (dict(ablate=list(range(rank))), []), - (dict(keep=list(range(rank))), list(range(rank))), - ] - for kwargs, expected_retained in cases: - with pytest.raises(ValueError, match="threshold"): - patch_along_directions(stub, ov, "prompt", metric, **kwargs) - result = patch_along_directions( - stub, - ov, - "prompt", - metric, - threshold=0.0, - rng=torch.Generator().manual_seed(0), - **kwargs, - ) - assert isinstance(result, PatchResult) - assert result.retained == expected_retained - - -def test_patch_ablate_rejects_partial_degenerate_block(): - """ablate must take a degenerate block whole: ablating one member of block [1, 2] leaves - the other in the retained complement, splitting the block, and is refused before any model - access the same way the keep path is.""" - ov = _factored_head_svd( - *_factored_with_spectrum([5.0, 3.0, 3.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(DegenerateDirectionError): - patch_along_directions(SimpleNamespace(), ov, "prompt", lambda logits: 0.0, ablate=[1]) - - -def test_patch_ablate_reports_retained(): - """An ablate call retains the complement of the ablated set and reports it, so an ablate - path is asserted end to end rather than only the keep path.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - stub = _PatchStubModel(d_model=D_MODEL, n_heads=1) - result = patch_along_directions( - stub, - ov, - "prompt", - lambda logits: float(logits.sum()), - ablate=[1, 3], - rng=torch.Generator().manual_seed(0), - ) - assert isinstance(result, PatchResult) - assert result.retained == [0, 2] - - -def test_patch_along_directions_reports_delta_and_restores_use_attn_result(): - """delta_metric is patched minus original metric, the averaged baseline is finite, and - use_attn_result is restored to its prior value regardless of what it started as. - - The gate's outcome is not recomputed from the same result's fields here (that would hold - for any self-consistent implementation); the mode-specific outcomes are pinned under a - fixed seed by the dedicated gate-semantics tests below. - """ - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - stub = _PatchStubModel(d_model=D_MODEL, n_heads=1) - metric = lambda logits: float(logits.sum()) - for initial in (False, True): - stub.set_use_attn_result(initial) - result = patch_along_directions( - stub, ov, "prompt", metric, keep=[0], rng=torch.Generator().manual_seed(0) - ) - assert result.delta_metric == pytest.approx(result.patched_metric - result.original_metric) - assert math.isfinite(result.baseline_delta_metric) - assert isinstance(result.gated, bool) - assert stub.cfg.use_attn_result == initial - - -class _SilentResultStubModel(_PatchStubModel): - """Accepts use_attn_result but never fires hook_result: the cache comes back without it and - run_with_hooks ignores its hooks. Counts run_with_hooks calls to expose control forwards.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.run_with_hooks_calls = 0 - - def run_with_cache(self, prompt, names_filter): - return self(prompt), {} - - def run_with_hooks(self, prompt, fwd_hooks): - self.run_with_hooks_calls += 1 - return self(prompt) - - -@pytest.mark.parametrize("initial", [False, True]) -def test_project_activations_refuses_when_hook_result_never_fires(initial): - """A cache without the read hook is refused with a ValueError naming the hook, rather than - the cache's bare KeyError, and use_attn_result is restored to its prior value.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - model = _SilentResultStubModel(d_model=D_MODEL, n_heads=1) - model.set_use_attn_result(initial) - with pytest.raises(ValueError, match=r"never fired blocks\.0\.attn\.hook_result"): - project_activations(model, ov, "prompt") - assert model.cfg.use_attn_result is initial - - -@pytest.mark.parametrize("initial", [False, True]) -def test_patch_refuses_when_hook_result_never_fires_before_controls(initial): - """A patch hook that never ran leaves the forward unpatched, so the refusal must come right - after the patched forward: no control forward runs and no gate verdict is produced.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - model = _SilentResultStubModel(d_model=D_MODEL, n_heads=1) - model.set_use_attn_result(initial) - with pytest.raises(ValueError, match=r"never fired blocks\.0\.attn\.hook_result"): - patch_along_directions( - model, ov, "prompt", lambda logits: float(logits.sum()), keep=[0], n_baseline=4 - ) - assert model.run_with_hooks_calls == 1 - assert model.cfg.use_attn_result is initial - - -def _span_aligned_stub(ov, weights): - """A patch stub whose single head writes ``ov.V @ weights`` at every position and whose - readout is that same in-span vector. - - Because the head output lies entirely in ``span(ov.V)`` and the readout equals it, removing - any in-span component can only reduce the metric, so the per-draw baseline deltas share a - sign and their average does not cancel toward zero. That makes the gate outcomes analytic: - the requested subspace's delta and the averaged random-subspace delta are both exact - functions of ``weights``, so a chosen weight vector fixes which side of the gate a mode - lands on with a wide margin rather than by numerical luck. - """ - V = ov.V - head_out = V @ torch.tensor(weights, dtype=V.dtype) - stub = _PatchStubModel(d_model=V.shape[0], n_heads=1) - stub._result = torch.zeros_like(stub._result) - stub._result[:, :, 0, :] = head_out - stub._readout = head_out - return stub - - -def test_patch_ablate_weak_direction_gates_false(): - """Ablating the weakest isolated direction moves the metric less than removing an - equally-sized random in-span subspace does, so in ablate mode the gate is False.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - weak = ov.rank_report[-1].idx - assert not ov.is_degenerate(weak) - # Starve the weakest direction of energy while the strong ones carry it, so removing the - # weak one barely moves the metric relative to removing an arbitrary in-span direction. - weights = [1.0, 1.0, 1.0, 1.0] - weights[weak] = 1e-3 - stub = _span_aligned_stub(ov, weights) - metric = lambda logits: float(logits.sum()) - result = patch_along_directions( - stub, ov, "prompt", metric, ablate=[weak], rng=torch.Generator().manual_seed(0) - ) - assert result.gated is False - assert abs(result.delta_metric) < abs(result.baseline_delta_metric) - - -def test_patch_threshold_above_delta_gates_false(): - """An explicit threshold above abs(delta_metric) overrides the averaged baseline and gates - False in ablate mode, even for a direction that gates True against the baseline alone.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - strong = ov.rank_report[0].idx - assert not ov.is_degenerate(strong) - # Concentrate the head output in the direction to be ablated so removing it beats the - # random in-span baseline; the gate is then True until an explicit threshold overrides it. - weights = [1e-3, 1e-3, 1e-3, 1e-3] - weights[strong] = 1.0 - stub = _span_aligned_stub(ov, weights) - metric = lambda logits: float(logits.sum()) - default = patch_along_directions( - stub, ov, "prompt", metric, ablate=[strong], rng=torch.Generator().manual_seed(0) - ) - assert default.gated is True - raised = patch_along_directions( - stub, - ov, - "prompt", - metric, - ablate=[strong], - threshold=abs(default.delta_metric) + 1.0, - rng=torch.Generator().manual_seed(0), - ) - assert raised.gated is False - - -@pytest.mark.parametrize("mode", ["keep", "ablate"]) -def test_patch_threshold_equal_to_delta_gates_false(mode): - """Equality with an explicit threshold passes neither mode's strict comparison.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - stub = _span_aligned_stub(ov, [1.0, 0.5, 0.25, 0.125]) - metric = lambda logits: float(logits.sum()) - keep = [0] if mode == "keep" else None - ablate = [0] if mode == "ablate" else None - reference = patch_along_directions( - stub, ov, "prompt", metric, keep=keep, ablate=ablate, n_baseline=1 - ) - tied = patch_along_directions( - stub, - ov, - "prompt", - metric, - keep=keep, - ablate=ablate, - threshold=abs(reference.delta_metric), - n_baseline=1, - ) - - assert tied.delta_metric == reference.delta_metric - assert tied.gated is False - - -def test_patch_baseline_is_reproducible(): - """The averaged baseline is drawn from the passed generator, so the same seed reproduces it - bit-for-bit and a different seed gives a different average.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - stub = _span_aligned_stub(ov, [1.0, 1.0, 1.0, 1.0]) - metric = lambda logits: float(logits.sum()) - first = patch_along_directions( - stub, ov, "prompt", metric, ablate=[0], rng=torch.Generator().manual_seed(0) - ) - same_seed = patch_along_directions( - stub, ov, "prompt", metric, ablate=[0], rng=torch.Generator().manual_seed(0) - ) - other_seed = patch_along_directions( - stub, ov, "prompt", metric, ablate=[0], rng=torch.Generator().manual_seed(1) - ) - assert same_seed.baseline_delta_metric == first.baseline_delta_metric - assert other_seed.baseline_delta_metric != first.baseline_delta_metric - - -def _manual_baseline_deltas(stub, ov, width, seed, head=None): - """Replay one baseline draw loop deterministically, mirroring patch_along_directions' - control-subspace arithmetic exactly, and return the list of signed per-draw deltas. - - Kept in lockstep with the production loop (same QR draw order, same in-span projection, - same hook) so a test can recover the raw signed deltas the gate is built from and check - how they are reduced to a threshold. - """ - head = ov.head if head is None else head - V = ov.V - rank = V.shape[1] - g = torch.Generator().manual_seed(seed) - name = f"blocks.{ov.layer}.attn.hook_result" - original = float(stub(None).sum()) - deltas = [] - for _ in range(_DEFAULT_N_BASELINE): - random_rank = torch.randn(rank, rank, generator=g, dtype=V.dtype) - basis, _ = torch.linalg.qr(random_rank) - directions = V @ basis[:, :width] - projector = directions @ directions.transpose(-2, -1) - logits = stub.run_with_hooks(None, [(name, _make_subspace_hook(head, projector))]) - deltas.append(float(logits.sum()) - original) - return deltas - - -def test_patch_baseline_thresholds_on_mean_magnitude(): - """The gate threshold is the mean of the per-draw control delta magnitudes, not the - magnitude of their signed mean. The controls must mix sign for the two to differ. The - projector zeroes every out-of-span dimension identically on every draw, so put both the - head output and the readout inside span(V) to hide that fixed removal, and make them - orthogonal so the surviving control deltas (a quadratic form in the random leftover - direction) split near evenly in sign across draws. Averaging magnitudes then yields the - larger, correct threshold that does not shrink toward zero as the signs cancel.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - rank = ov.V.shape[1] - stub = _PatchStubModel(d_model=D_MODEL, n_heads=1) - # Head output along one span axis, readout along an orthogonal span axis (V's columns are - # orthonormal), so the deltas mix sign rather than sharing one. - stub._result = torch.zeros_like(stub._result) - stub._result[:, :, 0, :] = ov.V @ torch.tensor([0.0, 1.0, 0.0, 0.0], dtype=ov.V.dtype) - stub._readout = ov.V @ torch.tensor([1.0, 0.0, 0.0, 0.0], dtype=ov.V.dtype) - metric = lambda logits: float(logits.sum()) - result = patch_along_directions( - stub, ov, "prompt", metric, ablate=[0], rng=torch.Generator().manual_seed(0) - ) - deltas = _manual_baseline_deltas(stub, ov, width=rank - 1, seed=0) - mean_magnitude = sum(abs(d) for d in deltas) / len(deltas) - signed_mean_magnitude = abs(sum(deltas) / len(deltas)) - assert result.baseline_delta_metric == pytest.approx(mean_magnitude) - # The controls mix sign here, so the magnitude mean is strictly larger than the signed - # mean's magnitude; this guards against a regression back to abs(signed mean). - assert mean_magnitude > signed_mean_magnitude - - -def test_patch_baseline_matches_haar_expectation(): - """The averaged baseline matches the analytic Haar expectation of an in-span control, which - separates a correct in-span draw from a full-residual-stream one. - - On the span-aligned stub the single head writes ``h = ov.V @ weights`` at each of ``pos`` - positions and the readout is ``h``, so for a projector ``P`` the metric is ``pos * (h^T P h)`` - and each control delta is ``pos * (h^T P h - ||h||^2)``. For a random width-``w`` Haar subspace - drawn inside the rank-dim OV span, ``E[h^T P h] = (w/rank) * ||h||^2``, so - ``|E[delta]| = pos * ||h||^2 * (1 - w/rank)``. A full-stream control of the same width would - instead give ``pos * ||h||^2 * (1 - w/d_model)``. Because ``rank != d_model`` the two differ, - so a tight tolerance confirms the control is drawn in-span rather than across the full stream. - A single draw or a same-sign stub alone cannot tell the two apart; enough draws can. - """ - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - # h = V @ [1, 1, 1, 1] has ||h||^2 = 4 (V's columns are orthonormal); pos = 3 (stub default). - stub = _span_aligned_stub(ov, [1.0, 1.0, 1.0, 1.0]) - metric = lambda logits: float(logits.sum()) - result = patch_along_directions( - stub, - ov, - "prompt", - metric, - ablate=[0], - n_baseline=400, - rng=torch.Generator().manual_seed(0), - ) - # ablate=[0] retains width 3 of rank 4: in-span |E| = 3 * 4 * (1 - 3/4) = 3.0; a full-stream - # width-3 control would give 3 * 4 * (1 - 3/12) = 9.0. The 0.5 band separates the two, and - # 400 draws keep the Monte Carlo error comfortably inside it. - in_span, full_stream = 3.0, 9.0 - assert result.baseline_delta_metric == pytest.approx(in_span, abs=0.5) - assert result.baseline_delta_metric != pytest.approx(full_stream, abs=0.5) - - -def test_patch_baseline_generator_threading_reproduces_average(): - """Threading one generator through N single-draw calls reproduces a single N-draw call's - average bit-for-bit, pinning that the generator is consumed once per draw in sequence with no - per-draw reseed. On the same-sign span-aligned stub the magnitude mean equals the signed - mean's magnitude, so the equality is exact rather than approximate.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - stub = _span_aligned_stub(ov, [1.0, 1.0, 1.0, 1.0]) - metric = lambda logits: float(logits.sum()) - n_draws = _DEFAULT_N_BASELINE - shared = torch.Generator().manual_seed(0) - singles = [ - patch_along_directions( - stub, ov, "prompt", metric, ablate=[0], n_baseline=1, rng=shared - ).baseline_delta_metric - for _ in range(n_draws) - ] - combined = patch_along_directions( - stub, - ov, - "prompt", - metric, - ablate=[0], - n_baseline=n_draws, - rng=torch.Generator().manual_seed(0), - ) - assert combined.baseline_delta_metric == sum(singles) / n_draws - - -def test_patch_keep_mode_gate_semantics(): - """In keep mode the gate asks whether the retained subspace reconstructs the head: keeping - the single direction the head output lies along preserves the metric better than keeping an - equally-sized random in-span subspace, so it gates True, where the ablate-style single-sided - "moved more than baseline" test would have gated the same retained set False.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - strong = ov.rank_report[0].idx - assert not ov.is_degenerate(strong) - weights = [0.0, 0.0, 0.0, 0.0] - weights[strong] = 1.0 - stub = _span_aligned_stub(ov, weights) - metric = lambda logits: float(logits.sum()) - result = patch_along_directions( - stub, ov, "prompt", metric, keep=[strong], rng=torch.Generator().manual_seed(0) - ) - assert result.gated is True - assert abs(result.delta_metric) < abs(result.baseline_delta_metric) - assert not abs(result.delta_metric) > abs(result.baseline_delta_metric) - - -def test_patch_rejects_non_positive_n_baseline(): - """The averaged baseline needs at least one draw, so n_baseline below 1 is refused before - any model access.""" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - with pytest.raises(ValueError, match="n_baseline"): - patch_along_directions( - SimpleNamespace(), ov, "prompt", lambda logits: 0.0, keep=[0], n_baseline=0 - ) - - -@pytest.mark.skipif( - not (torch.backends.mps.is_available() or torch.cuda.is_available()), - reason="needs an MPS or CUDA device to exercise cross-device projector placement", -) -def test_patch_completes_on_accelerator_device(): - """The kept and baseline projectors are drawn on CPU, but the activation lives on the - model's device, so the hook must move the projector before the matmul. On an - accelerator the patch completes without a device-mismatch RuntimeError.""" - device = "mps" if torch.backends.mps.is_available() else "cuda" - ov = _factored_head_svd( - *_factored_with_spectrum([8.0, 4.0, 2.0, 1.0]), which="OV", layer=0, head=0, eps=1e-2 - ) - stub = _PatchStubModel(d_model=D_MODEL, n_heads=1, device=device) - metric = lambda logits: float(logits.sum()) - result = patch_along_directions( - stub, ov, "prompt", metric, keep=[0], rng=torch.Generator().manual_seed(0) - ) - assert isinstance(result, PatchResult) - assert math.isfinite(result.delta_metric) - assert math.isfinite(result.baseline_delta_metric) - - -@pytest.mark.parametrize("head", [0, 1]) -def test_patch_along_directions_discriminates_causal_direction(tiny_bridge, head): - """Ablating the head's strongest OV direction should move a metric aligned with that - direction materially more than ablating its weakest, least load-bearing direction.""" - layer = 0 - decomposition = decompose_head(tiny_bridge, layer, head, which=("OV",)) - ov = decomposition.OV - strong = ov.rank_report[0].idx - weak = ov.rank_report[-1].idx - assert not ov.is_degenerate(strong) - assert not ov.is_degenerate(weak) - - target_token = int((ov.V[:, strong] @ tiny_bridge.W_U).argmax()) - prompt = torch.tensor([[5, 63, 7, 9]]) - - def metric(logits): - return logits[0, -1, target_token].item() - - strong_result = patch_along_directions( - tiny_bridge, ov, prompt, metric, ablate=[strong], rng=torch.Generator().manual_seed(0) - ) - weak_result = patch_along_directions( - tiny_bridge, ov, prompt, metric, ablate=[weak], rng=torch.Generator().manual_seed(0) - ) - assert isinstance(strong_result, PatchResult) - assert math.isfinite(strong_result.baseline_delta_metric) - assert math.isfinite(weak_result.baseline_delta_metric) - # A wide margin, not a bare ordering: swapping the projected basis inside the patch hook - # collapses the strong-vs-weak ratio toward 1 while still ordering the two, so a bare `>` - # would pass the swap this decomposition's OV convention exists to prevent. - assert abs(strong_result.delta_metric) > 5 * abs(weak_result.delta_metric) - - # The baseline control is drawn with _make_subspace_hook(head_svd.head, ...), so its value - # depends on which head slice it patches. Mislabeling the decomposition's head, keeping the - # same V, must therefore move the baseline. A baseline hook hardcoded to head 0 would patch - # the same slice with the same projector for both, tying the two baselines and failing here. - other = 1 - head - mislabeled = dataclasses.replace(ov, head=other) - mislabeled_result = patch_along_directions( - tiny_bridge, - mislabeled, - prompt, - metric, - ablate=[strong], - rng=torch.Generator().manual_seed(0), - ) - assert strong_result.baseline_delta_metric != mislabeled_result.baseline_delta_metric - - -def test_patch_along_directions_restores_use_attn_result(tiny_bridge): - """use_attn_result is restored to its prior value regardless of what it started as.""" - decomposition = decompose_head(tiny_bridge, 0, 0, which=("OV",)) - ov = decomposition.OV - metric = lambda logits: float(logits.sum()) - original = tiny_bridge.cfg.use_attn_result - try: - for initial in (False, True): - tiny_bridge.set_use_attn_result(initial) - patch_along_directions( - tiny_bridge, ov, torch.tensor([[5, 63, 7, 9]]), metric, keep=[ov.rank_report[0].idx] - ) - assert tiny_bridge.cfg.use_attn_result == initial - finally: - tiny_bridge.set_use_attn_result(original) - - -def test_activation_tools_refuse_bridge_attention_that_never_fires_hook_result(monkeypatch): - """A real bridge whose attention accepts use_attn_result and keeps its output but skips - hook_result (an attention reimplementation missing the per-head fork) is refused by both - tools, naming the architecture and the attention class; the patch refuses after one hooked - forward, before any control.""" - bridge = _make_tiny_bridge() - ov = decompose_head(bridge, 0, 0, which=("OV",)).OV - attn = bridge.blocks[0].attn - monkeypatch.setattr( - attn, - "_compute_per_head_result", - lambda z_4d, n_heads, d_head: attn.o.original_component(z_4d.flatten(-2)), - ) - prompt = torch.tensor([[5, 63, 7, 9]]) - - with pytest.raises(ValueError, match="never fired") as excinfo: - project_activations(bridge, ov, prompt) - assert f"(GPT2LMHeadModel, {type(attn).__name__})" in str(excinfo.value) - assert bridge.cfg.use_attn_result is False - - hooked_forwards = [] - run_with_hooks = bridge.run_with_hooks - - def spy(*args, **kwargs): - hooked_forwards.append(kwargs.get("fwd_hooks")) - return run_with_hooks(*args, **kwargs) - - monkeypatch.setattr(bridge, "run_with_hooks", spy) - with pytest.raises(ValueError, match="never fired"): - patch_along_directions( - bridge, ov, prompt, lambda logits: float(logits.sum()), keep=[0], n_baseline=3 - ) - assert len(hooked_forwards) == 1 - assert bridge.cfg.use_attn_result is False - - -# --------------------------------------------------------------------------- # -# Top-k recovery through activation patching -# --------------------------------------------------------------------------- # -def _block_aligned_ladder(head_svd, *, geometric=True): - """Cumulative direction counts that never split a degenerate block. - - ``patch_along_directions`` refuses a retained set that splits a block, so a - k-ladder has to advance block by block rather than one direction at a time. - Geometric spacing keeps the sweep to roughly ``log2(rank)`` calls instead of - one per direction, which matters because each call costs ``n_baseline + 2`` - forward passes. - """ - starts = [] - last = None - for row in head_svd.rank_report: - if row.block_id != last: - starts.append(row.idx) - last = row.block_id - cumulative = starts[1:] + [len(head_svd.rank_report)] - if not geometric: - return cumulative - - ladder = [] - target = 1 - for end in cumulative: - if end >= target: - ladder.append(end) - while target <= end: - target *= 2 - if ladder[-1] != cumulative[-1]: - ladder.append(cumulative[-1]) - return ladder - - -class _RecoveryStubModel(_PatchStubModel): - """Expose identity-probe head outputs without a downstream scalar readout.""" - - def __init__(self, clean): - super().__init__(d_model=clean.shape[-1], n_heads=1, pos=clean.shape[0]) - self._result = clean[None, :, None, :] - - def _logits(self, result): - return result[:, :, 0, :] - - -def _measure_patch_recovery(ov, clean, retained): - """Measure relative output error through the public patch and capture its output.""" - model = _RecoveryStubModel(clean) - outputs = [] - - def metric(output): - outputs.append(output.detach().clone()) - return float((output - clean.unsqueeze(0)).norm() / clean.norm()) - - result = patch_along_directions( - model, - ov, - "prompt", - metric, - keep=retained, - n_baseline=1, - rng=torch.Generator().manual_seed(0), - # Empty/full-span controls coincide with the intervention and need an explicit threshold. - threshold=1e-4, - ) - assert result.original_metric == 0.0 - assert result.retained == retained - assert model.cfg.use_attn_result is False - return result, outputs[1].squeeze(0) - - -@pytest.mark.parametrize( - "spectrum", [(8.0, 4.0, 2.0, 1.0), (8.0, 4.0, 4.0, 1.0)], ids=["isolated", "block"] -) -def test_top_k_recovery_converges_monotonically(spectrum): - """Patched identity probes match the top-k map and its singular-value tail error.""" - W_V, W_O = _factored_with_spectrum(spectrum) - ov = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - clean = W_V @ W_O - ladder = [0] + _block_aligned_ladder(ov, geometric=False) - if spectrum[1] == spectrum[2]: - assert ov.block_of(1) == [1, 2] - assert ladder == [0, 1, 3, 4] - else: - assert ladder == [0, 1, 2, 3, 4] - tolerance = 64 * torch.finfo(clean.dtype).eps - - residuals = [] - print("\nk patched residual expected residual max output error") - for k in ladder: - result, patched = _measure_patch_recovery(ov, clean, list(range(k))) - expected_output = W_V[:, :k] @ W_O[:k, :] - expected_residual = float((ov.S[k:].square().sum() / ov.S.square().sum()).sqrt()) - torch.testing.assert_close(patched, expected_output, rtol=tolerance, atol=tolerance) - assert result.patched_metric == pytest.approx(expected_residual, abs=tolerance) - residuals.append(result.patched_metric) - print( - f"{k:3d} {result.patched_metric:>18.8f} {expected_residual:>19.8f} " - f"{float((patched - expected_output).abs().max()):>18.8f}" - ) - - for prev, cur in zip(residuals, residuals[1:]): - assert cur <= prev + tolerance - assert residuals[0] == pytest.approx(1.0, abs=tolerance) - assert residuals[-1] < tolerance - - -@pytest.mark.parametrize("control", ["bottom", "rotated", "noop"]) -def test_top_k_recovery_distinguishes_wrong_subspaces(control, monkeypatch): - """Same-width wrong subspaces and a no-op cannot reproduce the top-k output.""" - W_V, W_O = _factored_with_spectrum([8.0, 4.0, 2.0, 1.0]) - ov = _factored_head_svd(W_V, W_O, which="OV", layer=0, head=0, eps=1e-2) - clean = W_V @ W_O - k = 2 - expected_output = W_V[:, :k] @ W_O[:k, :] - top, top_output = _measure_patch_recovery(ov, clean, list(range(k))) - torch.testing.assert_close(top_output, expected_output) - - if control == "bottom": - wrong, wrong_output = _measure_patch_recovery( - ov, clean, list(range(len(ov.S) - k, len(ov.S))) - ) - else: - if control == "rotated": - random_basis, _ = torch.linalg.qr( - torch.randn(len(ov.S), len(ov.S), generator=torch.Generator().manual_seed(17)) - ) - directions = ov.V @ random_basis[:, :k] - projector = directions @ directions.T - else: - projector = torch.eye(clean.shape[-1], dtype=clean.dtype) - - def control_hook(head, requested_projector): - return _make_subspace_hook(head, projector) - - monkeypatch.setattr( - "transformer_lens.tools.analysis.svd_circuits._make_subspace_hook", control_hook - ) - wrong, wrong_output = _measure_patch_recovery(ov, clean, list(range(k))) - - assert not torch.allclose(wrong_output, expected_output, rtol=1e-5, atol=1e-6) - if control == "noop": - assert wrong.patched_metric == 0.0 - else: - assert wrong.patched_metric > top.patched_metric + 0.1 - - -def test_top_k_recovery_full_span_is_noop_on_bridge(tiny_bridge): - """Real Bridge hook wiring recovers full-span logits without assuming metric monotonicity.""" - ov = decompose_head(tiny_bridge, 0, 0, which=("OV",)).OV - assert ov is not None - rank = ov.V.shape[1] - partial_k = _block_aligned_ladder(ov)[0] - assert partial_k < rank - prompt = torch.tensor([[5, 63, 7, 9]]) - metric = lambda logits: float(logits[0, -1, 0] - logits[0, -1, 1]) - - partial = patch_along_directions( - tiny_bridge, ov, prompt, metric, keep=list(range(partial_k)), n_baseline=1 - ) - full = patch_along_directions( - tiny_bridge, ov, prompt, metric, keep=list(range(rank)), n_baseline=1, threshold=1e-4 - ) - assert abs(full.delta_metric) < 1e-4 - assert abs(partial.delta_metric) > 100 * abs(full.delta_metric) diff --git a/tests/unit/utilities/test_statistics.py b/tests/unit/utilities/test_statistics.py index a881890ab2..2dacb8ce9c 100644 --- a/tests/unit/utilities/test_statistics.py +++ b/tests/unit/utilities/test_statistics.py @@ -11,6 +11,7 @@ derive_generator, sign_flip_permutation_pvalue, standard_error, + success_rate_ci, ) @@ -111,3 +112,68 @@ def test_sign_flip_monte_carlo_separates_effect_from_null(): assert torch.equal(same, p_strong) with pytest.raises(ValueError, match="alternative"): sign_flip_permutation_pvalue(strong, alternative="both") + + +def test_success_rate_ci_bounds_bracket_point_estimate_and_lie_in_unit_interval(): + successes = [True, True, False, True, False, True, True, False] + result = success_rate_ci(successes) + assert result.ci_low <= result.point_estimate <= result.ci_high + assert 0.0 <= result.ci_low and result.ci_high <= 1.0 + assert result.point_estimate == pytest.approx(sum(successes) / len(successes)) + + +def test_success_rate_ci_pins_zero_of_six_upper_bound(): + # The exact interval must widen for an all-failure sample. A percentile bootstrap returns + # [0, 0] here by construction, asserting a certainty the data does not support. + result = success_rate_ci([False] * 6) + assert result.ci_low == 0.0 + assert result.ci_high == pytest.approx(0.4592581, abs=1e-6) + + +def test_success_rate_ci_pins_all_success_lower_bound(): + result = success_rate_ci([True] * 6) + assert result.ci_high == 1.0 + assert result.ci_low == pytest.approx(0.5407419, abs=1e-6) + + +def test_success_rate_ci_matches_closed_form_at_the_boundaries(): + # At k=0 and k=n the Clopper-Pearson bounds reduce to closed forms, which pins the + # bisection direction independently of the interior values. Reversing it collapses every + # interval to [0, 1]. + assert success_rate_ci([False] * 6).ci_high == pytest.approx(1 - 0.025 ** (1 / 6)) + assert success_rate_ci([True] * 6).ci_low == pytest.approx(0.025 ** (1 / 6)) + + +def test_success_rate_ci_narrows_as_the_trial_count_grows(): + # The same observed rate carries less uncertainty with more trials; a degenerate interval + # would not move. + small = success_rate_ci([False] * 6) + large = success_rate_ci([False] * 600) + assert large.ci_high < small.ci_high + + +def test_success_rate_ci_records_trial_and_success_counts(): + result = success_rate_ci([True, False, True]) + assert result.n_trials == 3 + assert result.n_successes == 2 + + +def test_success_rate_ci_is_deterministic(): + successes = [True, False, True] + assert success_rate_ci(successes) == success_rate_ci(successes) + + +def test_success_rate_ci_rejects_empty_input(): + with pytest.raises(ValueError, match="empty"): + success_rate_ci([]) + + +def test_success_rate_ci_old_analysis_path_forwards_with_a_deprecation_warning(): + with pytest.warns(DeprecationWarning, match="import it from transformer_lens.utilities"): + from transformer_lens.tools.analysis import success_rate_ci as forwarded + assert forwarded is success_rate_ci + + from transformer_lens.tools import analysis + + with pytest.raises(AttributeError): + getattr(analysis, "not_an_analysis_name") diff --git a/transformer_lens/model_bridge/_relevance_rules.py b/transformer_lens/model_bridge/_relevance_rules.py deleted file mode 100644 index 471f151690..0000000000 --- a/transformer_lens/model_bridge/_relevance_rules.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Forward-equivalent relevance-rule primitives, plus the scoped context that installs them. - -Each primitive is a ``torch.autograd.Function`` that reproduces its native forward -value exactly while replacing the backward pass with the rule's closed-form VJP. -``use_relevance_rules`` installs these rules on a model's canonical mount points only -for the duration of a ``with`` block, targeting components positionally (by mount -name, never by class) and reporting which mounts were installed versus skipped. -""" - -import dataclasses -from contextlib import contextmanager -from typing import ( - Any, - Callable, - Dict, - Iterator, - List, - Mapping, - Protocol, - Tuple, - runtime_checkable, -) - -import torch -import torch.nn as nn - - -def ln_rule_grad(grad_output: torch.Tensor, denom: torch.Tensor) -> torch.Tensor: - """Core LN-rule VJP: divide by ``denom`` without differentiating through it. - - Shared by the ``ln_rule`` primitive below and by any integration (such as - ``NormalizationBridge``) that wraps a component's own native forward call - instead of reproducing the division itself. - """ - return grad_output / denom - - -class _LNRule(torch.autograd.Function): - """LN-rule: forward is ``numerator / denom``; the VJP treats ``denom`` as constant.""" - - @staticmethod - def forward(ctx: Any, numerator: torch.Tensor, denom: torch.Tensor) -> torch.Tensor: - ctx.save_for_backward(denom) - return numerator / denom - - @staticmethod - def backward(ctx: Any, grad_output: torch.Tensor) -> Tuple[torch.Tensor, None]: - (denom,) = ctx.saved_tensors - return ln_rule_grad(grad_output, denom), None - - -def ln_rule(numerator: torch.Tensor, denom: torch.Tensor) -> torch.Tensor: - """Apply the LN-rule: native division forward, denom-as-constant backward.""" - result: torch.Tensor = _LNRule.apply(numerator, denom) - return result - - -class _IdentityRule(torch.autograd.Function): - """Identity-rule: forward is the native activation; the VJP is ``grad_out * phi(x)``. - - ``phi`` is ``f(x) / x``, the removable singularity at ``x == 0`` filled in with its - limit ``0.5``. This holds for any elementwise activation with ``f(0) == 0`` and a - well-defined derivative at zero, which covers SiLU and both GELU variants. - """ - - @staticmethod - def forward( - ctx: Any, x: torch.Tensor, act_fn: Callable[[torch.Tensor], torch.Tensor] - ) -> torch.Tensor: - y = act_fn(x) - ctx.save_for_backward(x, y) - return y - - @staticmethod - def backward(ctx: Any, grad_output: torch.Tensor) -> Tuple[torch.Tensor, None]: - x, y = ctx.saved_tensors - safe_x = torch.where(x == 0, torch.ones_like(x), x) - phi = torch.where(x == 0, torch.full_like(x, 0.5), y / safe_x) - return grad_output * phi, None - - -def identity_rule(x: torch.Tensor, act_fn: Callable[[torch.Tensor], torch.Tensor]) -> torch.Tensor: - """Apply the Identity-rule for an elementwise activation with ``f(0) == 0``.""" - result: torch.Tensor = _IdentityRule.apply(x, act_fn) - return result - - -class _HalfRule(torch.autograd.Function): - """Half-rule: forward is ``u * v``; the VJP halves each ordinary product-rule term.""" - - @staticmethod - def forward(ctx: Any, u: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - ctx.save_for_backward(u, v) - return u * v - - @staticmethod - def backward(ctx: Any, grad_output: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: - u, v = ctx.saved_tensors - return 0.5 * grad_output * v, 0.5 * grad_output * u - - -def half_rule(u: torch.Tensor, v: torch.Tensor) -> torch.Tensor: - """Apply the Half-rule: native product forward, evenly split backward.""" - result: torch.Tensor = _HalfRule.apply(u, v) - return result - - -class _ScaleGradient(torch.autograd.Function): - """Identity forward; the VJP scales the incoming gradient by a constant factor.""" - - @staticmethod - def forward(ctx: Any, x: torch.Tensor, factor: float) -> torch.Tensor: - ctx.factor = factor - return x - - @staticmethod - def backward(ctx: Any, grad_output: torch.Tensor) -> Tuple[torch.Tensor, None]: - return ctx.factor * grad_output, None - - -def scale_gradient(x: torch.Tensor, factor: float) -> torch.Tensor: - """Pass ``x`` through unchanged while scaling its gradient by ``factor``. - - The Half-rule on a product ``u * v`` halves each ordinary product-rule term, - which is the same as halving the single gradient that enters the product before - it splits. When the product is computed inside an opaque module the bridge cannot - reach term by term (its native forward is called as one unit), scaling the - gradient entering the product by ``0.5`` reproduces the Half-rule at that point - without altering the native forward value. - """ - result: torch.Tensor = _ScaleGradient.apply(x, factor) - return result - - -class RelevanceRuleConflictError(RuntimeError): - """A hook would silently break a rule-active forward/backward invariant. - - Raised instead of the ordinary warn-and-fall-back a component would use when - no rule is active, since falling back while a rule is active would compose the - rule with the hook edit and break the bit-identical-forward guarantee. - """ - - -class RelevanceRuleUnsupportedError(RuntimeError): - """A requested relevance rule cannot be installed on an otherwise-capable component. - - Raised at ``use_relevance_rules`` entry, before any forward or backward pass, when - a component reports the requested kind in its own ``_relevance_rule_unsupported_kinds`` - -- for example a gated-MLP recompute path backed by an unrecognized weight-orientation - class, or an activation form the Identity-rule does not support. Distinct from a - kind that is simply absent from ``_relevance_rule_kinds`` without being named there - (reported ``skipped``, not raised): that covers a component not implementing the - protocol at all, or one whose mount genuinely never deals with the kind (for example - normalization on a dispatch path the LN-rule does not wrap), both benign - non-applicability rather than a rule request the component was expected to honor. - """ - - -@dataclasses.dataclass(frozen=True) -class RelevanceRules: - """Which relevance rules to request for the duration of a ``use_relevance_rules`` scope. - - Each field names a rule kind. Setting it ``True`` requests that rule wherever a - component at that kind's canonical mount point implements ``_RelevanceRuleCapable``. - Unset fields (the default) leave the corresponding components untouched. - """ - - normalization: bool = False - activation: bool = False - multiplicative_gate: bool = False - attention: bool = False - - -@dataclasses.dataclass(frozen=True) -class _RelevanceRuleCoverageEntry: - """A rule kind and its canonical component path.""" - - kind: str - path: str - - -@dataclasses.dataclass(frozen=True) -class RelevanceRuleCoverage: - """Requested rule kinds installed or skipped at canonical component paths.""" - - installed: Tuple[_RelevanceRuleCoverageEntry, ...] - skipped: Tuple[_RelevanceRuleCoverageEntry, ...] - - -@runtime_checkable -class _RelevanceRuleCapable(Protocol): - """Structural contract a component must satisfy to accept a relevance rule. - - ``_relevance_rule_kinds`` names every ``RelevanceRules`` field the component - answers to at its current mount -- a gated-MLP node answers to both - "activation" (Identity-rule on its activation function) and - "multiplicative_gate" (Half-rule on its gate*up product) independently, since - either can be requested without the other. ``_enable_relevance_rule``/ - ``_disable_relevance_rule`` take the specific kind being toggled and touch - only that kind's state, without touching model configuration, so the - component's own state is the only thing that changes and only for the - scope's duration. - - A component may optionally also define ``_relevance_rule_unsupported_kinds`` - (a ``Tuple[str, ...]``, not part of this structural protocol so components that - omit it stay isinstance-compatible) naming kinds it is expected to honor at its - mount but currently cannot -- ``use_relevance_rules`` raises - ``RelevanceRuleUnsupportedError`` for those instead of reporting them skipped. - """ - - _relevance_rule_kinds: Tuple[str, ...] - - def _enable_relevance_rule(self, kind: str) -> None: - ... - - def _disable_relevance_rule(self, kind: str) -> None: - ... - - -# Canonical mount name per rule kind. Targeting is positional: a component is only -# considered for a kind when it sits at that kind's mount name, never by isinstance, -# so a same-class component mounted elsewhere (for example a q_norm sharing -# NormalizationBridge's class) is left untouched. The normalization kind lists both -# the pre-norm mounts (ln1, ln2) and the sandwich post-norm mounts (ln1_post, -# ln2_post) so the LN-rule reaches the post-attention/post-MLP norms that -# sandwich-norm architectures mount there, matching the pinned RelP reference. -_CANONICAL_MOUNTS: Mapping[str, Tuple[str, ...]] = { - "normalization": ("ln1", "ln2", "ln1_post", "ln2_post"), - "activation": ("mlp",), - "multiplicative_gate": ("mlp",), -} - - -def _iter_canonical_mount_candidates( - model: nn.Module, mount_names: Tuple[str, ...] -) -> Iterator[Tuple[str, _RelevanceRuleCapable]]: - """Yield each distinct module reachable at one of ``mount_names``, once. - - A bridge component reachable at a canonical mount name (for example - ``blocks.0.ln1``) is also reachable, under the same parent, through the - raw HF module tree the bridge wraps in place (for example - ``blocks.0._original_component.input_layernorm``) -- both names resolve to - the identical object. ``nn.Module.named_modules()`` deduplicates by object - identity and keeps only whichever path it visits first, which is the raw - HF-attribute path (registered before the canonical alias), so on a real - assembled model the canonical name is silently never seen. Walking with - ``remove_duplicate=False`` restores every path so the canonical name is - visible, and picking the fewest-dot-separated-segments path per object - (breaking a tie between two paths that both happen to end in a mount name, - such as ``mlp``, which HF's own attribute name also frequently matches) - reports the shallower, canonical-looking path rather than an internal one. - """ - best_by_id: Dict[int, Tuple[str, _RelevanceRuleCapable]] = {} - for name, module in model.named_modules(remove_duplicate=False): - if name.rsplit(".", 1)[-1] not in mount_names: - continue - existing = best_by_id.get(id(module)) - if existing is None or name.count(".") < existing[0].count("."): - best_by_id[id(module)] = (name, module) - yield from best_by_id.values() - - -def _acquire_rule(module: _RelevanceRuleCapable, kind: str) -> None: - """Enable ``module``'s ``kind`` rule only on the outermost scope that requests it. - - Refcounted per kind, not per module: a gated-MLP node can have its - "activation" rule and "multiplicative_gate" rule independently nested to - different depths, so one kind's inner exit must never disable the other. - """ - counts: Dict[str, int] = getattr(module, "_relevance_rule_refcounts", None) or {} - count = counts.get(kind, 0) - if count == 0: - module._enable_relevance_rule(kind) - counts[kind] = count + 1 - setattr(module, "_relevance_rule_refcounts", counts) - - -def _release_rule(module: _RelevanceRuleCapable, kind: str) -> None: - """Disable ``module``'s ``kind`` rule only once its innermost scope exits.""" - counts: Dict[str, int] = getattr(module, "_relevance_rule_refcounts", None) or {} - count = counts.get(kind, 0) - 1 - counts[kind] = max(count, 0) - setattr(module, "_relevance_rule_refcounts", counts) - if count <= 0: - module._disable_relevance_rule(kind) - - -@contextmanager -def use_relevance_rules(model: nn.Module, rules: RelevanceRules) -> Iterator[RelevanceRuleCoverage]: - """Install the requested relevance rules on ``model`` only for this scope. - - Targeting is positional: a component is considered for a rule kind only when it - sits at that kind's canonical mount name (never by class). A canonical mount - occupied by a component that does not implement ``_RelevanceRuleCapable``, or - whose ``_relevance_rule_kinds`` simply omits the requested kind, is reported as - skipped -- both are benign non-applicability, covering a structurally different - architecture or a mount whose current dispatch path the rule does not wrap. A - component that additionally names the requested kind in its own - ``_relevance_rule_unsupported_kinds`` raises ``RelevanceRuleUnsupportedError`` - instead: that names a kind the component is expected to honor at this mount but - cannot given its current configuration, so silently skipping it would let - analysis proceed as if the caller had never asked. Requesting a kind that has no - canonical mount at all (for example "attention", a valid ``RelevanceRules`` field - with no mount defined) raises ``ValueError`` before the install loop, since there - is no mount to target and the scope would otherwise return empty coverage as - though the request had succeeded. Scopes over the same model - are reference-counted, so an inner scope's exit never disables a rule an outer - scope still needs. No model configuration is mutated; the only state that - changes lives on the participating components, and only for the scope's - duration. - """ - requested_kinds = [ - field.name for field in dataclasses.fields(rules) if getattr(rules, field.name) - ] - - unmapped_kinds = [kind for kind in requested_kinds if kind not in _CANONICAL_MOUNTS] - if unmapped_kinds: - joined = ", ".join(repr(kind) for kind in unmapped_kinds) - raise ValueError( - f"No canonical mount is defined for relevance-rule kind(s) {joined}. " - "Such a kind has no mount to target, so it would install nothing and " - "silently return an empty coverage report instead of applying the rule." - ) - - installed: List[Tuple[str, _RelevanceRuleCapable, str]] = [] - skipped: List[_RelevanceRuleCoverageEntry] = [] - seen = set() - for kind in requested_kinds: - mount_names = _CANONICAL_MOUNTS.get(kind, ()) - for name, module in _iter_canonical_mount_candidates(model, mount_names): - entry = _RelevanceRuleCoverageEntry(kind=kind, path=name) - if entry in seen: - continue - seen.add(entry) - if isinstance(module, _RelevanceRuleCapable) and kind in module._relevance_rule_kinds: - installed.append((name, module, kind)) - continue - unsupported_kinds = getattr(module, "_relevance_rule_unsupported_kinds", ()) - if isinstance(module, _RelevanceRuleCapable) and kind in unsupported_kinds: - raise RelevanceRuleUnsupportedError( - f"{name!r} ({type(module).__name__}) cannot install the {kind!r} " - "relevance rule: unsupported configuration for this component." - ) - skipped.append(entry) - - for _, module, kind in installed: - _acquire_rule(module, kind) - try: - yield RelevanceRuleCoverage( - installed=tuple( - _RelevanceRuleCoverageEntry(kind=kind, path=name) for name, _, kind in installed - ), - skipped=tuple(skipped), - ) - finally: - for _, module, kind in installed: - _release_rule(module, kind) diff --git a/transformer_lens/model_bridge/generalized_components/gated_mlp.py b/transformer_lens/model_bridge/generalized_components/gated_mlp.py index 5f43df33c0..59dbaed4fe 100644 --- a/transformer_lens/model_bridge/generalized_components/gated_mlp.py +++ b/transformer_lens/model_bridge/generalized_components/gated_mlp.py @@ -2,16 +2,10 @@ This module contains the bridge component for gated MLP layers (e.g., LLaMA, Gemma). """ -from typing import Any, Callable, Dict, Mapping, Optional, Tuple, cast +from typing import Any, Callable, Dict, Mapping, Optional import torch -import torch.nn as nn -from transformer_lens.model_bridge._relevance_rules import ( - half_rule, - identity_rule, - scale_gradient, -) from transformer_lens.model_bridge.generalized_components.base import ( GeneralizedComponent, ) @@ -29,23 +23,6 @@ def _resolve_activation_fn_name(config: Any) -> Optional[str]: return None -_IDENTITY_RULE_UNSUPPORTED_ACTIVATIONS = {"relu", "relu2", "relu_2", "relu_squared"} - - -def identity_rule_supports_activation(config: Any) -> bool: - """Whether the config's resolved activation form is safe for the Identity-rule. - - The Identity-rule's backward multiplier is ``f(x) / x`` (the removable-singularity - limit filled in at zero) rather than the ordinary derivative -- the correct - LRP-style rule for SiLU and both GELU variants, but not for the relu family: - relu-squared's ratio reduces to ``relu(x)``, not its true derivative - ``2 * relu(x)``, and plain relu has no smooth two-sided derivative for the ratio - to represent at the removable singularity either. Both are therefore excluded - rather than silently applying a rule that does not hold for them. - """ - return _resolve_activation_fn_name(config) not in _IDENTITY_RULE_UNSUPPORTED_ACTIVATIONS - - def resolve_activation_fn(config: Any) -> Callable: """Resolve activation function from a model config. @@ -75,25 +52,6 @@ def relu_squared(x: torch.Tensor) -> torch.Tensor: return torch.nn.functional.silu -class _IdentityRuleActivation(nn.Module): - """Route a wrapped activation through the Identity-rule for a scope's duration. - - The opaque gated-MLP path keeps the HF module's own forward intact and installs - the Identity-rule by swapping the module's activation callable for this wrapper. - Its forward returns ``act_fn(x)`` unchanged, so the native forward value is - preserved, while the backward follows the Identity-rule VJP. Storing the wrapped - activation as an attribute registers it as a child module when it is itself an - ``nn.Module`` (the common ``ACT2FN`` case), so its parameters, if any, stay live. - """ - - def __init__(self, wrapped: Callable[[torch.Tensor], torch.Tensor]): - super().__init__() - self._wrapped_activation = wrapped - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return identity_rule(x, self._wrapped_activation) - - class GatedMLPBridge(MLPBridge): """Bridge component for gated MLP layers. @@ -132,190 +90,6 @@ def __init__( optional: If True, setup skips this bridge when absent (hybrid architectures). """ super().__init__(name, config, submodules=submodules or {}, optional=optional) - self._relevance_rule_activation_active = False - self._relevance_rule_gate_active = False - # Opaque-path rule installers hold their teardown state here. The activation - # wrap records (attr_name, original_value, was_child_module) so the swapped - # activation callable can be restored exactly; the gate handle is the - # forward-pre-hook that scales the gradient entering the down projection. - self._relevance_activation_wrap: Optional[Tuple[str, Any, bool]] = None - self._relevance_gate_hook_handle: Optional[Any] = None - - def _is_gated_mlp_shaped(self) -> bool: - """Whether this instance has the gate/up/down submodules a gated MLP needs. - - A container missing one of these was never wired up as a gated-MLP node at - all (a different architecture at this mount), which is benign - non-applicability rather than an unsupported configuration of a gated-MLP - node -- unlike an activation form the Identity-rule cannot honor, which - occupies exactly this node's shape but cannot be honored correctly. - """ - if self.original_component is None: - return False - gate_module = getattr(self, "gate", None) - in_module = getattr(self, "in", None) - out_module = getattr(self, "out", None) - return gate_module is not None and in_module is not None and out_module is not None - - def _find_activation_attr(self) -> Optional[str]: - """The attribute name under which the HF module holds its activation callable. - - The opaque path installs the Identity-rule by swapping this attribute, so the - activation must be reachable as a callable attribute the native forward calls - (the ``ACT2FN`` module the gated-MLP families store as ``act_fn``). Returns - ``None`` when no such attribute exists, in which case the Identity-rule cannot - be wrapped in and ``"activation"`` is reported unsupported rather than - installed as a silent no-op. - """ - component = self.original_component - if component is None: - return None - for attr in ("act_fn", "activation_fn", "act", "activation"): - if callable(getattr(component, attr, None)): - return attr - return None - - def _activation_rule_installable(self) -> bool: - """Whether the Identity-rule can be installed on this instance's activation. - - Requires both a config activation form the ratio rule is valid for (the - relu family is excluded) and, on the opaque path, an activation callable the - bridge can wrap in place. Subclasses that reconstruct the forward themselves - override this, since they call the activation directly and never wrap it. - """ - return ( - identity_rule_supports_activation(self.config) - and self._find_activation_attr() is not None - ) - - @property - def _relevance_rule_kinds(self) -> Tuple[str, ...]: - """The relevance-rule kinds this instance can currently honor. - - Empty when this is not a gated-MLP-shaped node. Otherwise always includes - ``"multiplicative_gate"`` (the Half-rule is a gradient scale at the gate*up - product and needs no weight or activation access) and includes - ``"activation"`` only when the Identity-rule can be installed on the - configured activation, so a relu-family activation, or one the bridge cannot - reach to wrap, is excluded. - """ - if not self._is_gated_mlp_shaped(): - return () - kinds: Tuple[str, ...] = ("multiplicative_gate",) - if self._activation_rule_installable(): - kinds = ("activation",) + kinds - return kinds - - @property - def _relevance_rule_unsupported_kinds(self) -> Tuple[str, ...]: - """Kinds this gated-MLP node is expected to honor but currently cannot. - - Unlike a kind simply absent from ``_relevance_rule_kinds`` because this is - not a gated-MLP-shaped node at all (benign non-applicability, reported - skipped), a gated-MLP node whose activation form or activation callable the - Identity-rule cannot honor is exactly the kind of component a caller expects - the rule to work on. Requesting ``"activation"`` there raises instead of - silently reporting the mount skipped. The Half-rule applies to every - gated-MLP node, so ``"multiplicative_gate"`` is never reported unsupported. - """ - if not self._is_gated_mlp_shaped(): - return () - if "activation" not in self._relevance_rule_kinds: - return ("activation",) - return () - - def _install_activation_rule(self) -> None: - """Swap the HF module's activation callable for the Identity-rule wrapper. - - No-op when the activation callable cannot be located; requesting the - activation rule in that case is refused earlier through - ``_relevance_rule_unsupported_kinds``. The original value and whether it was - a registered child module are recorded so teardown restores it exactly. - """ - component = self.original_component - attr = self._find_activation_attr() - if component is None or attr is None: - return - was_child_module = attr in component._modules - original = component._modules[attr] if was_child_module else getattr(component, attr, None) - # _find_activation_attr only returns an attribute whose value is callable. - wrapper = _IdentityRuleActivation(cast(Callable[[torch.Tensor], torch.Tensor], original)) - # Avoid recursively changing the original activation's training state. - wrapper.training = component.training - if not was_child_module: - component.__dict__.pop(attr, None) - component._modules[attr] = wrapper - self._relevance_activation_wrap = (attr, original, was_child_module) - - def _teardown_activation_rule(self) -> None: - """Restore the activation callable swapped in by ``_install_activation_rule``.""" - if self._relevance_activation_wrap is None: - return - attr, original, was_child_module = self._relevance_activation_wrap - component = self.original_component - if component is not None: - component._modules.pop(attr, None) - if was_child_module: - component._modules[attr] = original - else: - component.__dict__[attr] = original - self._relevance_activation_wrap = None - - def _install_gate_rule(self) -> None: - """Halve the gradient entering the down projection to reproduce the Half-rule. - - The gate*up product is the down projection's input, so a forward-pre-hook - that routes that input through ``scale_gradient(..., 0.5)`` halves the single - gradient feeding the product before it splits, which matches halving both - product-rule terms. The native forward value is unchanged, and the down - projection's own weight gradient stays ordinary because it is taken against - the unscaled downstream gradient. - """ - out_module = getattr(self, "out", None) - down_component = getattr(out_module, "original_component", None) - if down_component is None: - return - - def _scale_product_gradient( - module: nn.Module, args: Tuple[Any, ...] - ) -> Optional[Tuple[Any, ...]]: - if not args: - return None - return (scale_gradient(args[0], 0.5),) + tuple(args[1:]) - - self._relevance_gate_hook_handle = down_component.register_forward_pre_hook( - _scale_product_gradient - ) - - def _teardown_gate_rule(self) -> None: - """Remove the down-projection gradient-scale hook.""" - if self._relevance_gate_hook_handle is not None: - self._relevance_gate_hook_handle.remove() - self._relevance_gate_hook_handle = None - - def _enable_relevance_rule(self, kind: str) -> None: - """Activate the named rule and install its opaque-path hook. - - The boolean flag drives the reconstructed forward paths (compatibility mode - here, and the inline forward of subclasses that override it). The install - step additionally attaches the rule to the live HF submodules for the opaque - native forward, which a flag alone cannot alter. - """ - if kind == "activation": - self._relevance_rule_activation_active = True - self._install_activation_rule() - elif kind == "multiplicative_gate": - self._relevance_rule_gate_active = True - self._install_gate_rule() - - def _disable_relevance_rule(self, kind: str) -> None: - """Deactivate the named rule and tear down its opaque-path hook.""" - if kind == "activation": - self._teardown_activation_rule() - self._relevance_rule_activation_active = False - elif kind == "multiplicative_gate": - self._teardown_gate_rule() - self._relevance_rule_gate_active = False def forward(self, *args, **kwargs) -> torch.Tensor: """Forward pass through the gated MLP bridge. @@ -352,16 +126,8 @@ def forward(self, *args, **kwargs) -> torch.Tensor: if in_module is not None and hasattr(in_module, "hook_out"): linear_output = in_module.hook_out(linear_output) # type: ignore[misc] act_fn = resolve_activation_fn(self.config) - activated = ( - identity_rule(gate_output, act_fn) - if self._relevance_rule_activation_active - else act_fn(gate_output) - ) - hidden = ( - half_rule(activated, linear_output) - if self._relevance_rule_gate_active - else activated * linear_output - ) + activated = act_fn(gate_output) + hidden = activated * linear_output if hasattr(self, "out") and hasattr(self.out, "hook_in"): hidden = self.out.hook_in(hidden) output = torch.nn.functional.linear( @@ -380,10 +146,6 @@ def forward(self, *args, **kwargs) -> torch.Tensor: hidden_states = args[0] hidden_states = self.hook_in(hidden_states) new_args = (hidden_states,) + args[1:] - # The active relevance rules are attached to the live HF submodules (the - # activation callable and the down projection) by _enable_relevance_rule, - # so the native forward runs unchanged and its own internal hooks, gate - # multipliers, and activation sparsity all remain in the backward graph. output = self.original_component(*new_args, **kwargs) output = self.hook_out(output) return output diff --git a/transformer_lens/model_bridge/generalized_components/joint_gate_up_mlp.py b/transformer_lens/model_bridge/generalized_components/joint_gate_up_mlp.py index e4560bc3e9..e0e998a0b2 100644 --- a/transformer_lens/model_bridge/generalized_components/joint_gate_up_mlp.py +++ b/transformer_lens/model_bridge/generalized_components/joint_gate_up_mlp.py @@ -6,14 +6,12 @@ import torch -from transformer_lens.model_bridge._relevance_rules import half_rule, identity_rule from transformer_lens.model_bridge.generalized_components.base import ( GeneralizedComponent, align_offloaded_subtree, ) from transformer_lens.model_bridge.generalized_components.gated_mlp import ( GatedMLPBridge, - identity_rule_supports_activation, resolve_activation_fn, ) from transformer_lens.model_bridge.generalized_components.linear import LinearBridge @@ -175,29 +173,6 @@ def _resolve_activation_fn(self) -> Callable: return self._activation_fn return resolve_activation_fn(self.config) - def _activation_rule_installable(self) -> bool: - """The reconstructed forward calls the activation itself, so only the config - activation form gates the Identity-rule; the opaque-path requirement of a - wrappable activation callable does not apply here.""" - return identity_rule_supports_activation(self.config) - - # The reconstructed forward applies both rules inline off the boolean flags set - # by the base ``_enable_relevance_rule``/``_disable_relevance_rule``. The - # opaque-path installers must stay disabled: the gate hook lives on the shared - # down projection this forward also calls, so leaving it active would halve the - # gate*up gradient a second time on top of the inline ``half_rule``. - def _install_activation_rule(self) -> None: - return None - - def _teardown_activation_rule(self) -> None: - return None - - def _install_gate_rule(self) -> None: - return None - - def _teardown_gate_rule(self) -> None: - return None - def forward(self, *args: Any, **kwargs: Any) -> torch.Tensor: """Reconstructed gated MLP forward with individual hook access.""" # Delegate to GatedMLPBridge's processed-weights path only when ALL @@ -216,16 +191,7 @@ def forward(self, *args: Any, **kwargs: Any) -> torch.Tensor: up_output = getattr(self, "in")(hidden_states) act_fn = self._resolve_activation_fn() - activated = ( - identity_rule(gate_output, act_fn) - if self._relevance_rule_activation_active - else act_fn(gate_output) - ) - gated = ( - half_rule(activated, up_output) - if self._relevance_rule_gate_active - else activated * up_output - ) + gated = act_fn(gate_output) * up_output if hasattr(self, "out") and self.out is not None: output = self.out(gated) diff --git a/transformer_lens/model_bridge/generalized_components/normalization.py b/transformer_lens/model_bridge/generalized_components/normalization.py index 9536c552be..401332bae8 100644 --- a/transformer_lens/model_bridge/generalized_components/normalization.py +++ b/transformer_lens/model_bridge/generalized_components/normalization.py @@ -1,15 +1,11 @@ """Normalization bridge component implementation.""" import contextlib import warnings -from typing import Any, ContextManager, Dict, Optional, Tuple, cast +from typing import Any, ContextManager, Dict, Optional, cast import torch from transformer_lens.hook_points import HookPoint -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRuleConflictError, - ln_rule_grad, -) from transformer_lens.model_bridge.generalized_components.base import ( GeneralizedComponent, ) @@ -27,83 +23,6 @@ "output is reconstructed from the hooked values instead of HF's native forward. " "Output numerics may differ from the unhooked forward at float-rounding scale." ) -# While the LN-rule is active, the fallbacks above would silently compose the rule -# with the hook edit and break the bit-identical-forward guarantee, so they raise -# instead of warning. -RULE_ACTIVE_BWD_HOOK_CONFLICT = ( - "Backward hooks on hook_scale/hook_normalized are incompatible with an active " - "LN-rule on '{name}': the rule-wrapped native forward keeps these hook points " - "out of its backward graph, so a backward hook here would silently never fire." -) -RULE_ACTIVE_EDIT_HOOK_CONFLICT = ( - "A forward hook edited hook_scale/hook_normalized while the LN-rule is active on " - "'{name}': honoring the edit would require the python-norm fallback, which would " - "compose the rule with the edit and break the bit-identical-forward guarantee." -) - - -class _NativeLNRuleForward(torch.autograd.Function): - """Wrap a normalization module's own forward call in the LN-rule's VJP. - - Forward returns ``component(x)`` unchanged, so the result is bit-identical to - the native forward by construction. Backward routes the x-path gradient - through the centering op ordinarily but treats ``denom`` as a constant (the - LN-rule), while ``weight`` and ``bias`` receive their ordinary gradient since - the rule only redefines how relevance reaches the input, not parameter - training gradients. A parameter-free norm (``weight`` is ``None``, e.g. - OLMo's ``OlmoLayerNorm``) is treated as a unit scale in both directions. - """ - - @staticmethod - def forward( - ctx: Any, - x_centered: torch.Tensor, - denom: torch.Tensor, - weight: Optional[torch.Tensor], - bias: Optional[torch.Tensor], - x: torch.Tensor, - component: torch.nn.Module, - offset: bool, - input_dtype: torch.dtype, - ) -> torch.Tensor: - ctx.save_for_backward(x_centered, denom, weight) - ctx.has_bias = bias is not None - ctx.bias_requires_grad = bool(bias is not None and bias.requires_grad) - ctx.offset = offset - result = component(x) - if result.dtype != input_dtype: - result = result.to(input_dtype) - return result - - @staticmethod - def backward( - ctx: Any, grad_output: torch.Tensor - ) -> Tuple[ - torch.Tensor, - None, - Optional[torch.Tensor], - Optional[torch.Tensor], - None, - None, - None, - None, - ]: - x_centered, denom, weight = ctx.saved_tensors - if weight is None: - w_eff: torch.Tensor | float = 1.0 - else: - w_eff = (1.0 + weight) if ctx.offset else weight - reduce_dims = tuple(range(grad_output.dim() - 1)) - grad_x_centered = ln_rule_grad(grad_output * w_eff, denom) - grad_weight = ( - (grad_output * (x_centered / denom)).sum(dim=reduce_dims) - if weight is not None and weight.requires_grad - else None - ) - grad_bias = ( - grad_output.sum(dim=reduce_dims) if ctx.has_bias and ctx.bias_requires_grad else None - ) - return grad_x_centered, None, grad_weight, grad_bias, None, None, None, None class NormalizationBridge(GeneralizedComponent): @@ -145,31 +64,6 @@ def __init__( self.use_native_layernorm_autograd = use_native_layernorm_autograd self._uses_rms_norm_override = uses_rms_norm self._uses_weight_offset_override = uses_weight_offset - self._relevance_rule_active = False - - @property - def _relevance_rule_kinds(self) -> Tuple[str, ...]: - """``("normalization",)`` only when this instance actually dispatches through - the native-autograd branch the LN-rule wraps (``_hf_autograd_forward_with_hooks``); - empty otherwise, so ``use_relevance_rules`` reports an ln1/ln2 mount that - uses the plain python-norm path (for example ``LayerNormPreBridge`` / - ``RMSNormPreBridge``, or a config without ``layer_norm_folding``) as - skipped rather than silently leaving ordinary gradients in place under a - claimed "installed" rule. - """ - if self.use_native_layernorm_autograd: - return ("normalization",) - if bool(getattr(self.config, "layer_norm_folding", False)): - return ("normalization",) - return () - - def _enable_relevance_rule(self, kind: str) -> None: - """Activate the LN-rule for this instance's native-forward branch only.""" - self._relevance_rule_active = True - - def _disable_relevance_rule(self, kind: str) -> None: - """Deactivate the LN-rule, restoring today's native-forward behavior.""" - self._relevance_rule_active = False @property def uses_rms_norm(self) -> bool: @@ -285,10 +179,6 @@ def _hf_autograd_forward_with_hooks(self, x: torch.Tensor) -> torch.Tensor: _ = self.hook_normalized(x) return x if self.hook_scale.bwd_hooks or self.hook_normalized.bwd_hooks: - if self._relevance_rule_active: - raise RelevanceRuleConflictError( - RULE_ACTIVE_BWD_HOOK_CONFLICT.format(name=self.name) - ) warnings.warn(NATIVE_PATH_BWD_FALLBACK_WARNING) return self._python_norm_forward(x) has_fwd_hooks = bool(self.hook_scale.fwd_hooks or self.hook_normalized.fwd_hooks) @@ -333,64 +223,13 @@ def _hf_autograd_forward_with_hooks(self, x: torch.Tensor) -> torch.Tensor: # identity is the edit signal. Note in-place mutation of the hook value without # returning it is NOT detected — return the tensor from the hook to edit. if hooked_scale is scale and hooked_normalized is x_normalized: - if self._relevance_rule_active: - return self._native_forward_with_ln_rule(x, input_dtype) result = self.original_component(x) if result.dtype != input_dtype: result = result.to(input_dtype) return result - if self._relevance_rule_active: - raise RelevanceRuleConflictError(RULE_ACTIVE_EDIT_HOOK_CONFLICT.format(name=self.name)) warnings.warn(NATIVE_PATH_EDIT_FALLBACK_WARNING) return self._apply_weight_and_bias(hooked_normalized, input_dtype) - def _native_forward_with_ln_rule( - self, x: torch.Tensor, input_dtype: torch.dtype - ) -> torch.Tensor: - """Call the native forward unchanged while routing its backward through the LN-rule. - - Recomputes the centered numerator and denominator independently of the - hook-observation pass above (which may run under ``torch.no_grad()``), so - the recompute here stays grad-connected to ``x`` regardless of whether - forward hooks are attached. Autograd then flows from the returned tensor - back through the centering op ordinarily and, at the LN-rule Function - boundary, treats the denominator as a constant -- the same contract as - the shared ``ln_rule`` primitive, without reproducing the native kernel's - own forward numerics. - """ - component = self.original_component - assert component is not None - x_float = x.float() if x.dtype not in (torch.float32, torch.float64) else x - if not self.uses_rms_norm: - x_centered = x_float - x_float.mean(-1, keepdim=True) - else: - x_centered = x_float - eps_tensor = getattr(component, "eps", None) - if eps_tensor is None: - eps_tensor = getattr(component, "variance_epsilon", None) - if eps_tensor is None: - eps_value: float | torch.Tensor = getattr(self.config, "eps", 1e-05) - else: - eps_value = eps_tensor - variance = x_centered.pow(2).mean(-1, keepdim=True) - denom = ( - (variance + eps_value).sqrt() - if isinstance(eps_value, torch.Tensor) - else (variance + float(eps_value)).sqrt() - ) - # A parameter-free norm (OLMo's OlmoLayerNorm) exposes no usable weight; - # pass None so the rule treats the scale as 1 in both forward and backward. - try: - weight: Optional[torch.Tensor] = cast(torch.Tensor, self.weight) - except AttributeError: - weight = None - bias = getattr(component, "bias", None) if not self.uses_rms_norm else None - offset = self.uses_weight_offset - result: torch.Tensor = _NativeLNRuleForward.apply( - x_centered, denom, weight, bias, x, component, offset, input_dtype - ) - return result - class LayerNormPreBridge(NormalizationBridge): """Param-free LayerNorm (LNPre): hook_scale / hook_normalized, no weight or bias.""" diff --git a/transformer_lens/patching.py b/transformer_lens/patching.py index f375f0308e..fd400bb13b 100644 --- a/transformer_lens/patching.py +++ b/transformer_lens/patching.py @@ -4,6 +4,7 @@ the output. This implements the activation patching technique for a range of types of activation. The structure is to have a single :func:`generic_activation_patch` function that does everything, and to have a range of specialised functions for specific types of activation. +:func:`get_act_patch_direct_path` patches one head's output into a single later head's Q/K/V input. Context: @@ -50,17 +51,21 @@ from __future__ import annotations import itertools +from contextlib import contextmanager from dataclasses import dataclass from functools import partial from typing import ( + Any, Callable, Dict, + Iterator, List, Mapping, Optional, Sequence, Tuple, Union, + cast, overload, ) @@ -431,7 +436,7 @@ def generic_activation_patch( index_df *optional*: The dataframe of indices Or a :class:`PatchingResult` when ``reduce="none"``, ``baseline=True`` or a metric mapping was given. """ - # Lazy import: tools.analysis imports this module's neighbours at package import. + # Lazy import: the tools.analysis package imports this module, so top level would be circular. from transformer_lens.tools.analysis._model_state import require_eval_mode require_eval_mode(model, operation="activation patching") @@ -1214,3 +1219,213 @@ def get_act_patch_block_every( return _stack_patch_types( results, axis_order=("layer", "pos"), n_heads=0, type_names=_BLOCK_TYPES ) + + +# %% +def _check_direct_path_support(model: Any, src_layers: Sequence[int], component: str) -> None: + """Refuse missing attention, source writes other than ``z @ W_O``, and K/V on GQA/MQA.""" + # Lazy import: the tools.analysis package imports this module, so top level would be circular. + from transformer_lens.tools.analysis.attribution_patching import ( + _attention_write_obstruction, + ) + + cfg = model.cfg + src_layer = min(src_layers) + for layer in range(src_layer, cfg.n_layers): + if getattr(model.blocks[layer], "attn", None) is None: + raise ValueError( + f"blocks.{layer} has no `attn` component (an SSM, mixer, linear-attention or " + "AltUp block), and direct path patching needs one in every block from " + f"src_layer={src_layer} on." + ) + detail = _attention_write_obstruction(model, set(src_layers)) + if detail is not None: + raise ValueError( + "per-head attention outputs are normalized or scaled before the residual add on " + f"this architecture ({detail}), so a source head's residual write is not " + "hook_z @ W_O and direct path patching is unsupported here." + ) + if component == "q": + return + n_heads = cfg.n_heads + scalar_kv = getattr(cfg, "n_key_value_heads", None) + per_layer_kv = getattr(cfg, "per_layer_num_key_value_heads", None) or [] + # Only destinations are K/V-patched; the source is read via hook_z and W_O, per query head. + for layer in range(src_layer + 1, cfg.n_layers): + kv = per_layer_kv[layer] if layer < len(per_layer_kv) else None + if kv is None: + kv = scalar_kv + if kv is not None and kv != n_heads: + raise ValueError( + f"component={component!r} is unsupported here: blocks.{layer} has " + f"n_key_value_heads={kv} for n_heads={n_heads} (GQA/MQA), so per-query-head " + 'K/V semantics are undefined. component="q" is available.' + ) + + +@contextmanager +def _split_qkv_input(model: Any) -> Iterator[None]: + """Enable the per-head pre-norm Q/K/V forks, restoring the caller's flags on exit.""" + saved_split = bool(getattr(model.cfg, "use_split_qkv_input", False)) + saved_attn_in = bool(getattr(model.cfg, "use_attn_in", False)) + try: + # The two forks are mutually exclusive, so use_attn_in must go off first. + if saved_attn_in: + model.set_use_attn_in(False) + if not saved_split: + model.set_use_split_qkv_input(True) + yield + finally: + model.set_use_split_qkv_input(saved_split) + if saved_attn_in: + model.set_use_attn_in(True) + + +def _make_direct_path_hook( + delta: Float[torch.Tensor, "batch pos d_model"], dst_head: int, fired: List[bool] +) -> Callable[..., torch.Tensor]: + """Add the source head's clean-minus-corrupted write to one head's fork input.""" + + def hook_fn( + fork_input: Float[torch.Tensor, "batch pos n_heads d_model"], hook + ) -> Float[torch.Tensor, "batch pos n_heads d_model"]: + fired.append(True) + patched = fork_input.clone() + patched[:, :, dst_head] += delta + return patched + + return hook_fn + + +def get_act_patch_direct_path( + model: TransformerLensModel, + corrupted_tokens: torch.Tensor, + clean_cache: ActivationCache, + corrupted_cache: ActivationCache, + patching_metric: Callable[[torch.Tensor], torch.Tensor], + src_layer: int, + src_head: int, + component: Literal["q", "k", "v"] = "q", + verbose: bool = True, +) -> Float[torch.Tensor, "n_layers n_heads"]: + """Patch one source head's output into each later head's Q, K or V input alone. + + Direct path patching (Wang et al. 2022) isolates the edge A -> B. Head A's residual write on + each run is ``z[..., src_head, :] @ W_O[src_head]``, read from the two caches. For every head B + in a later layer, the corrupted tokens are rerun with A's clean-minus-corrupted write added, at + every position, to B's own copy of the residual (``attn.hook_{component}_input``, taken before + ``ln1``). Every other reader still sees A's corrupted output, and ``ln1`` and B's projection are + recomputed on the patched copy, so the result is the exact edge intervention, folded LayerNorm + or not. Blocks that transform the head sum before the residual add are refused, since there + ``z @ W_O`` is not A's write. + + The per-head copies exist only under ``cfg.use_split_qkv_input``, which is switched on for the + sweep (``use_attn_in`` off, since the two are exclusive) and both restored on exit. While it + is on, every attention layer builds and normalises a ``[batch, pos, n_heads, d_model]`` + residual copy for each of Q, K and V, about ``3 * n_heads`` times the usual ``ln1`` + activation memory per forward. + + Args: + model: A ``TransformerBridge`` whose attention exposes the pre-Q/K/V fork. + corrupted_tokens: Token ids for the corrupted run, ``[batch, pos]``. + clean_cache: Cache of the clean run; must hold ``blocks.{src_layer}.attn.hook_z``. + corrupted_cache: Cache of the corrupted run; must hold the same ``hook_z``. + patching_metric: Maps the patched logits to a scalar. + src_layer: Layer of the source head. + src_head: Index of the source head. + component: Destination input to patch: ``"q"`` (default), ``"k"`` or ``"v"``. + verbose: Show a progress bar. + + Returns: + ``[n_layers, n_heads]``: ``results[l, h]`` is the metric with the path into head + ``(l, h)`` patched. Rows ``l <= src_layer`` are 0.0 placeholders, not measurements. + + Raises: + ValueError: if a block from ``src_layer`` on has no attention, if the source block + normalises, scales or joins its attention output before the residual add (post-norm, + scaled-residual or parallel-mixer blocks), if ``component`` is ``"k"`` or ``"v"`` on + a GQA/MQA model, or if any module is in training mode. + NotImplementedError: if a destination layer's attention has no pre-Q/K/V fork. + """ + # Lazy import: the tools.analysis package imports this module, so top level would be circular. + from transformer_lens.tools.analysis._model_state import require_eval_mode + + _check_direct_path_support(model, [src_layer], component) + require_eval_mode(model, operation="get_act_patch_direct_path()") + + n_layers = model.cfg.n_layers + n_heads = model.cfg.n_heads + results = torch.zeros(n_layers, n_heads, device=model.cfg.device) + dst_pairs = [(dl, dh) for dl in range(src_layer + 1, n_layers) for dh in range(n_heads)] + + bridge = cast(Any, model) + W_O = bridge.blocks[src_layer].attn.W_O[src_head] + z_name = f"blocks.{src_layer}.attn.hook_z" + clean_write = clean_cache[z_name][:, :, src_head] @ W_O + delta = clean_write - corrupted_cache[z_name][:, :, src_head] @ W_O + + with _split_qkv_input(bridge): + for dst_layer, dst_head in tqdm( + dst_pairs, + desc=f"Direct path patch ({src_layer},{src_head}) → * [{component}]", + disable=not verbose, + ): + hook_name = f"blocks.{dst_layer}.attn.hook_{component}_input" + fired: List[bool] = [] + logits = model.run_with_hooks( + corrupted_tokens, + fwd_hooks=[(hook_name, _make_direct_path_hook(delta, dst_head, fired))], + ) + # A layer whose attention cannot fork never fires the hook and would return the + # unpatched metric. + if not fired: + raise NotImplementedError( + f"{hook_name} did not fire: blocks.{dst_layer}.attn has no pre-Q/K/V fork, so " + "direct path patching cannot reach that head's input." + ) + results[dst_layer, dst_head] = patching_metric(logits).item() + return results + + +def get_act_patch_direct_path_all_sources( + model: TransformerLensModel, + corrupted_tokens: torch.Tensor, + clean_cache: ActivationCache, + corrupted_cache: ActivationCache, + patching_metric: Callable[[torch.Tensor], torch.Tensor], + component: Literal["q", "k", "v"] = "q", + verbose: bool = True, +) -> Float[torch.Tensor, "n_layers n_heads n_layers n_heads"]: + """Run :func:`get_act_patch_direct_path` from every source head. + + ``results[sl, sh, dl, dh]`` is the metric with the path from head ``(sl, sh)`` into head + ``(dl, dh)`` patched, and 0.0 where ``dl <= sl``. One forward per (source, later destination) + pair, so it suits small models or targeted sub-sweeps. + """ + # Lazy import: the tools.analysis package imports this module, so top level would be circular. + from transformer_lens.tools.analysis._model_state import require_eval_mode + + _check_direct_path_support(model, range(model.cfg.n_layers), component) + require_eval_mode(model, operation="get_act_patch_direct_path_all_sources()") + + n_layers = model.cfg.n_layers + n_heads = model.cfg.n_heads + results = torch.zeros(n_layers, n_heads, n_layers, n_heads, device=model.cfg.device) + src_pairs = [(sl, sh) for sl in range(n_layers) for sh in range(n_heads)] + for src_layer, src_head in tqdm( + src_pairs, + desc=f"Direct path patch — all sources [{component}]", + disable=not verbose, + ): + results[src_layer, src_head] = get_act_patch_direct_path( + model=model, + corrupted_tokens=corrupted_tokens, + clean_cache=clean_cache, + corrupted_cache=corrupted_cache, + patching_metric=patching_metric, + src_layer=src_layer, + src_head=src_head, + component=component, + verbose=False, + ) + return results diff --git a/transformer_lens/tools/analysis/__init__.py b/transformer_lens/tools/analysis/__init__.py index db9b450bf5..160e2977c6 100644 --- a/transformer_lens/tools/analysis/__init__.py +++ b/transformer_lens/tools/analysis/__init__.py @@ -4,6 +4,9 @@ sit on top of the hook/cache system. Model support is documented per tool; new analyses may target the ``TransformerBridge`` API exclusively. +Entry points and the classes they accept are exported here; result dataclasses stay in the +module that returns them. + Tools: - attribution_patching: Attribution patching (gradient-linearized activation patching) over residual-stream nodes and edges: typed computational graph, @@ -11,12 +14,11 @@ scores (EAP), plus forward-only ablate-outside faithfulness with corrupt or held-out mean replacements and circuit-relative class diagnostics. Integrated gradients (EAP-IG) are not implemented. - - backward_lens: GPT-2 MLP weight-gradient factors projected into vocabulary - space with explicit raw-gradient sign semantics. - direct_logit_attribution: Direct Logit Attribution (DLA) over components, layers, or attention heads. - - direct_path_patching: Direct path patching for head-to-head circuit - analysis. + - get_act_patch_direct_path, get_act_patch_direct_path_all_sources: + re-exported from ``transformer_lens.patching``; exact direct path + patching for head-to-head circuit analysis. - interchange: Interchange interventions on linear subspaces: set a base's activation inside a subspace to its value on a source input, with difference-of-means, probe and random bases, a norm-matched placebo, @@ -26,60 +28,34 @@ the output vocabulary basis, with loading of published lens artifacts, native fitting, readouts, interventions, J-space sparse decomposition, and anchored coordinate patching (offline and dynamic/hooked). - - jacobian_lens_causal_swap_benchmark: A causal coordinate-swap benchmark for - ``coordinate_patch_hooks``, with baseline-capability filtering, - displacement-matched random-atom controls, exact Clopper-Pearson confidence - intervals, and a versioned, fingerprinted JSON artifact schema. Its - ``_cli`` sibling is the artifact-generation entry point and is deliberately - not imported here, so ``python -m`` runs it once. - logit_lens: One-call logit lens over the accumulated residual stream and the ``logit_readout`` kernel that reads any ``d_model`` vector through the real final norm, unembedding and adapter output transform, chunked over the vocabulary with target ranks, top-k and vocabulary subsets. - projection_kernel: Basis-invariant subspace overlap and TransformerBridge attention-head OQ/OK/OV affinity. - - relevance_lens: The Relevance lens (R-lens) -- a Jacobian lens whose - per-layer transport matrices are estimated with scoped Layer-wise - Relevance Propagation rules on residual-stream norms and gated MLPs, - reusing the Jacobian lens readout, vocabulary vectors, sparse - decomposition, and interventions unchanged. - sparse_probing: Leakage-safe k-sparse binary probes over supplied activation tensors, with train-only selection and raw null controls. - - svd_circuits: Per-head QK/OV singular-vector decomposition with a - degeneracy guard, OV vocab and logit readout, per-position activation - projection onto singular directions, and a mandatory causal patch gate - that reconstructs a head's output onto a chosen singular subspace. """ +import warnings +from typing import TYPE_CHECKING, Any + +from transformer_lens.patching import ( + get_act_patch_direct_path, + get_act_patch_direct_path_all_sources, +) from transformer_lens.tools.analysis.attribution_patching import ( - AttributionResult, EdgeAttributionConfig, FaithfulnessConfig, - FaithfulnessResult, Node, attribution_patch, faithfulness, ) -from transformer_lens.tools.analysis.backward_lens import ( - BackwardLens, - BackwardLensLayerResult, - BackwardLensMatrixResult, - BackwardLensResult, - LinearGradientFactors, - ProjectedFactor, - VocabularyRanking, - WeightLayout, -) from transformer_lens.tools.analysis.direct_logit_attribution import ( - DirectLogitAttribution, direct_logit_attribution, ) -from transformer_lens.tools.analysis.direct_path_patching import ( - get_act_patch_direct_path, - get_act_patch_direct_path_all_sources, -) from transformer_lens.tools.analysis.interchange import ( - DASResult, LearnedSubspace, NormMatchedPlacebo, SubspaceInterchange, @@ -91,130 +67,43 @@ random_basis, run_with_intervention, ) -from transformer_lens.tools.analysis.jacobian_lens import ( - JacobianLens, - JacobianLensReadout, -) -from transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark import ( - BenchmarkCorpus, - FunctionSpec, - corpus_definition, - load_artifact, - run_causal_swap_benchmark, - select_displacement_matched_control_token, - success_rate_ci, -) +from transformer_lens.tools.analysis.jacobian_lens import JacobianLens from transformer_lens.tools.analysis.jacobian_lens_coordinate_patch import ( - CoordinatePatch, solve_coordinate_patch, - solve_coordinate_patch_positions, ) from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( JSpaceDecomposition, - JSpaceOccupancy, - JSpaceVarianceProfile, - estimate_occupancy, get_sparse_decomposition, ) -from transformer_lens.tools.analysis.logit_lens import ( - LogitLensResult, - LogitReadout, - logit_lens, - logit_readout, -) +from transformer_lens.tools.analysis.logit_lens import logit_lens, logit_readout from transformer_lens.tools.analysis.projection_kernel import ( - AttentionHeadRef, - HeadAffinityPair, - HeadAffinityResult, - ProjectionKernelResult, - RandomSubspaceReference, SubspaceBasis, attention_head_subspace_affinity, orthonormal_subspace, projection_kernel, random_projection_kernel_moments, ) -from transformer_lens.tools.analysis.relevance_lens import RelevanceLens from transformer_lens.tools.analysis.sparse_probing import ( - SparseProbeControl, SparseProbeConvergenceError, - SparseProbeMetrics, - SparseProbeRejection, - SparseProbeResult, - SparseProbeSweep, fit_sparse_probe, sweep_sparse_probe, ) -from transformer_lens.tools.analysis.svd_circuits import ( - ActivationProjection, - DegenerateDirectionError, - HeadDecomposition, - HeadSVD, - LogitSignature, - PatchResult, - RankReportRow, - decompose_head, - logit_signature, - patch_along_directions, - project_activations, - vocab_readout, -) __all__ = [ - "ActivationProjection", - "AttentionHeadRef", - "AttributionResult", - "BackwardLens", - "BackwardLensLayerResult", - "BackwardLensMatrixResult", - "BackwardLensResult", - "BenchmarkCorpus", - "CoordinatePatch", - "DASResult", - "DegenerateDirectionError", - "DirectLogitAttribution", "EdgeAttributionConfig", "FaithfulnessConfig", - "FaithfulnessResult", - "FunctionSpec", - "HeadAffinityPair", - "HeadAffinityResult", - "HeadDecomposition", - "HeadSVD", "JSpaceDecomposition", - "JSpaceOccupancy", - "JSpaceVarianceProfile", "JacobianLens", - "JacobianLensReadout", "LearnedSubspace", - "LinearGradientFactors", - "LogitLensResult", - "LogitReadout", - "LogitSignature", "Node", "NormMatchedPlacebo", - "PatchResult", - "ProjectedFactor", - "ProjectionKernelResult", - "RandomSubspaceReference", - "RankReportRow", - "RelevanceLens", - "SparseProbeControl", "SparseProbeConvergenceError", - "SparseProbeMetrics", - "SparseProbeRejection", - "SparseProbeResult", - "SparseProbeSweep", "SubspaceBasis", "SubspaceInterchange", - "VocabularyRanking", - "WeightLayout", "attention_head_subspace_affinity", "attribution_patch", "collect_activations", - "decompose_head", "direct_logit_attribution", - "estimate_occupancy", "faithfulness", "fit_das", "fit_sparse_probe", @@ -222,24 +111,34 @@ "get_act_patch_direct_path_all_sources", "get_sparse_decomposition", "intervened_logits", - "load_artifact", "logit_lens", "logit_readout", - "logit_signature", "mean_difference_basis", "orthonormal_subspace", - "patch_along_directions", "probe_basis", - "project_activations", "projection_kernel", "random_basis", "random_projection_kernel_moments", - "run_causal_swap_benchmark", "run_with_intervention", - "select_displacement_matched_control_token", "solve_coordinate_patch", - "solve_coordinate_patch_positions", - "success_rate_ci", "sweep_sparse_probe", - "vocab_readout", ] + +# Moved to transformer_lens.utilities with unchanged results. +_MOVED_TO_UTILITIES = ("success_rate_ci",) + +if TYPE_CHECKING: + from transformer_lens.utilities import success_rate_ci +else: + # Hidden from type checkers, which would otherwise type every unknown name here as Any. + def __getattr__(name: str) -> Any: + if name not in _MOVED_TO_UTILITIES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + warnings.warn( + f"{__name__}.{name} is deprecated; import it from transformer_lens.utilities.", + DeprecationWarning, + stacklevel=2, + ) + from transformer_lens import utilities + + return getattr(utilities, name) diff --git a/transformer_lens/tools/analysis/_model_state.py b/transformer_lens/tools/analysis/_model_state.py index 4ef787e756..4644ce1233 100644 --- a/transformer_lens/tools/analysis/_model_state.py +++ b/transformer_lens/tools/analysis/_model_state.py @@ -1,9 +1,14 @@ -"""Shared model-state validation for analysis tools.""" +"""Shared model-state and argument validation for analysis tools.""" -from typing import Any +from numbers import Integral +from typing import Any, List, Optional import torch +# Block variants with no attn_out + mlp_out split and no resid_mid hook. +_HYBRID_VARIANT_NAMES = ("mamba", "ssm", "mixer", "linear_attn") +_ID_DTYPES = (torch.int64, torch.int32, torch.int16, torch.int8, torch.uint8) + def require_eval_mode(model: Any, *, operation: str) -> None: """Reject training state anywhere in a wrapped model without mutating it.""" @@ -29,3 +34,75 @@ def require_eval_mode(model: Any, *, operation: str) -> None: f"mode; found training mode at {preview}. Call model.eval() before running " "the analysis." ) + + +def hybrid_block_types(model: Any) -> List[str]: + """Sorted distinct ``model.layer_types()`` entries naming an SSM or linear-attention block.""" + layer_types = model.layer_types() if hasattr(model, "layer_types") else [] + return sorted( + {t for t in layer_types if any(part in _HYBRID_VARIANT_NAMES for part in t.split("+"))} + ) + + +def compute_dtype(dtype: torch.dtype) -> torch.dtype: + """Keep float64; run every other dtype in float32, which all backends' linalg support.""" + return torch.float64 if dtype == torch.float64 else torch.float32 + + +def require_int( + value: Any, + name: str, + *, + minimum: int = 1, + maximum: Optional[int] = None, + message: Optional[str] = None, +) -> int: + """Return ``value`` as an int in ``[minimum, maximum]``; bools are refused as integers.""" + if isinstance(value, bool) or not isinstance(value, Integral): + raise ValueError(message or f"{name} must be an integer, got {value!r}") + if value < minimum or (maximum is not None and value > maximum): + if maximum is not None: + expected = f"between {minimum} and {maximum}" + elif minimum == 1: + expected = "a positive integer" + elif minimum == 0: + expected = "a nonnegative integer" + else: + expected = f"an integer at least {minimum}" + raise ValueError(message or f"{name} must be {expected}, got {value!r}") + return int(value) + + +# Typed Any so the checks below, not a runtime type-hint checker, name a bad token. +def to_token_ids(model: Any, tokens: Any) -> List[int]: + """Resolve one token, or a sequence of them, into in-vocabulary token ids. + + A token is a single-token string or an integer id: a Python or numpy integer, or a 0-d + integer tensor. A bare token and a one-element sequence holding it resolve alike. + """ + if isinstance(tokens, (str, Integral)) or ( + isinstance(tokens, torch.Tensor) and tokens.ndim == 0 + ): + tokens = [tokens] + ids: List[int] = [] + for token in tokens: + if isinstance(token, torch.Tensor) and token.ndim == 0 and token.dtype in _ID_DTYPES: + token = token.item() + if isinstance(token, str): + ids.append(int(model.to_single_token(token))) + elif isinstance(token, bool): + raise ValueError(f"token {token!r} must be a string or integer token id, not bool") + elif isinstance(token, Integral): + ids.append(int(token)) + else: + # int() would silently truncate a float id. + raise TypeError( + f"tokens must be strings or integer token ids, got {type(token).__name__}" + ) + if not ids: + raise ValueError("tokens must contain at least one token") + d_vocab = model.W_U.shape[1] + invalid = [token_id for token_id in ids if not 0 <= token_id < d_vocab] + if invalid: + raise ValueError(f"token ids {invalid} out of range for vocabulary size {d_vocab}") + return ids diff --git a/transformer_lens/tools/analysis/attribution_patching.py b/transformer_lens/tools/analysis/attribution_patching.py index 96645efa2f..bb672cea6e 100644 --- a/transformer_lens/tools/analysis/attribution_patching.py +++ b/transformer_lens/tools/analysis/attribution_patching.py @@ -551,24 +551,24 @@ def _non_additive_attention_error(detail: str) -> ValueError: ) -def _check_attention_write_structure(model: Any) -> None: - """Refuse blocks that declare a norm, scale or parallel mixer between head sum and residual.""" +def _attention_write_obstruction( + model: Any, layers: Optional[Collection[int]] = None +) -> Optional[str]: + """Describe the first block declaring a norm, scale or parallel mixer after its head sum.""" for layer, block in enumerate(getattr(model, "blocks", ())): + if layers is not None and layer not in layers: + continue submodules = getattr(block, "submodules", None) if not isinstance(submodules, dict) or "attn" not in submodules: continue scale = getattr(block, "residual_contribution_scale", 1.0) if scale != 1.0: - raise _non_additive_attention_error( - f"blocks.{layer} scales its attention output by {scale}" - ) + return f"blocks.{layer} scales its attention output by {scale}" # Falcon-H1 scales attention and adds a parallel Mamba output inline, after # attn.hook_out, so the cached head-sum check cannot see either. mixers = [name for name in VARIANT_SUBMODULE_NAMES if name != "attn" and name in submodules] if mixers: - raise _non_additive_attention_error( - f"blocks.{layer} shares that add with a parallel {mixers[0]} mixer" - ) + return f"blocks.{layer} shares that add with a parallel {mixers[0]} mixer" aliases = getattr(block, "hook_aliases", None) target = aliases.get("hook_attn_out") if isinstance(aliases, dict) else None candidates = [target] if isinstance(target, str) else list(target or []) @@ -578,10 +578,16 @@ def _check_attention_write_structure(model: Any) -> None: if owner not in submodules: continue if owner != "attn": - raise _non_additive_attention_error( - f"blocks.{layer}.hook_attn_out reads {candidate}, after the head sum" - ) + return f"blocks.{layer}.hook_attn_out reads {candidate}, after the head sum" break + return None + + +def _check_attention_write_structure(model: Any) -> None: + """Refuse blocks that declare a norm, scale or parallel mixer between head sum and residual.""" + detail = _attention_write_obstruction(model) + if detail is not None: + raise _non_additive_attention_error(detail) def _check_kv_heads_match_query_heads(model: Any) -> None: @@ -1294,6 +1300,28 @@ def _ablate_edges( return logits +def _validate_pair( + clean: torch.Tensor, corrupt: torch.Tensor, *, operation: str, method: str +) -> None: + """Require 2D clean/corrupt token tensors holding equal numbers of equal-length pairs.""" + if clean.ndim != 2 or corrupt.ndim != 2: + raise ValueError( + f"{operation} expects 2D [batch, seq] token tensors, got clean " + f"{tuple(clean.shape)} and corrupt {tuple(corrupt.shape)}" + ) + if clean.shape[0] != corrupt.shape[0]: + raise ValueError( + "clean and corrupt must hold the same number of prompt pairs, got " + f"{clean.shape[0]} and {corrupt.shape[0]}" + ) + if clean.shape[1] != corrupt.shape[1]: + raise ValueError( + "each clean/corrupt pair must tokenize to the same length; got clean " + f"length {clean.shape[1]} and corrupt length {corrupt.shape[1]}. " + f"{method} aligns activations position-by-position." + ) + + def attribution_patch( model: Any, clean: torch.Tensor, @@ -1359,23 +1387,7 @@ def attribution_patch( a head's residual contribution, or if an attention layer's K/V head count differs from ``n_heads`` (GQA/MQA). """ - if clean.ndim != 2 or corrupt.ndim != 2: - raise ValueError( - "attribution_patch expects 2D [batch, seq] token tensors, got clean " - f"{tuple(clean.shape)} and corrupt {tuple(corrupt.shape)}" - ) - if clean.shape[0] != corrupt.shape[0]: - raise ValueError( - "clean and corrupt must hold the same number of prompt pairs, got " - f"{clean.shape[0]} and {corrupt.shape[0]}" - ) - if clean.shape[1] != corrupt.shape[1]: - raise ValueError( - "each clean/corrupt pair must tokenize to the same length; got clean " - f"length {clean.shape[1]} and corrupt length {corrupt.shape[1]}. " - "Attribution patching aligns activations position-by-position." - ) - + _validate_pair(clean, corrupt, operation="attribution_patch", method="Attribution patching") require_eval_mode(model, operation="attribution_patch()") batch = int(clean.shape[0]) @@ -1567,28 +1579,13 @@ def faithfulness( residual add (post-norm, scaled-residual or parallel-mixer blocks), or an attention layer's K/V head count differs from ``n_heads`` (GQA/MQA). """ - if clean.ndim != 2 or corrupt.ndim != 2: - raise ValueError( - "faithfulness expects 2D [batch, seq] token tensors, got clean " - f"{tuple(clean.shape)} and corrupt {tuple(corrupt.shape)}" - ) - if clean.shape[0] != corrupt.shape[0]: - raise ValueError( - "clean and corrupt must hold the same number of prompt pairs, got " - f"{clean.shape[0]} and {corrupt.shape[0]}" - ) + _validate_pair(clean, corrupt, operation="faithfulness", method="Faithfulness") if clean.shape[0] != 1: raise ValueError( "faithfulness measures one clean/corrupt pair at a time, since the " f"metric reads a single example; got {clean.shape[0]} pairs. Loop over " "pairs and aggregate the recovered fractions yourself." ) - if clean.shape[1] != corrupt.shape[1]: - raise ValueError( - "each clean/corrupt pair must tokenize to the same length; got clean " - f"length {clean.shape[1]} and corrupt length {corrupt.shape[1]}. " - "Faithfulness aligns activations position-by-position." - ) if config.ablation == "mean": if mean_tokens is None: diff --git a/transformer_lens/tools/analysis/backward_lens.py b/transformer_lens/tools/analysis/backward_lens.py deleted file mode 100644 index bbfbaae923..0000000000 --- a/transformer_lens/tools/analysis/backward_lens.py +++ /dev/null @@ -1,1005 +0,0 @@ -"""Backward Lens gradient-factor capture and vocabulary projection. - -The Backward Lens represents a linear weight gradient as a sum of token-position -outer products and projects residual-width factors into the model vocabulary. -The public API supports raw dense-MLP decoder-only ``TransformerBridge`` models -(for example GPT-2 and Pythia/GPT-NeoX), reading each MLP projection's weight -layout from the Bridge component rather than the model class. -""" - -from __future__ import annotations - -from collections.abc import Callable, Iterator, Sequence -from contextlib import contextmanager -from dataclasses import dataclass -from typing import Any, Literal, cast - -import torch -import torch.nn.functional as F -from jaxtyping import Bool, Float, Int - -from transformer_lens.tools.analysis._model_state import require_eval_mode - -WeightLayout = Literal["in_out", "out_in"] -ProjectedFactor = Literal["forward_inputs", "output_gradients"] -DEFAULT_TOP_K = 10 - - -@dataclass(frozen=True) -class LinearGradientFactors: - """Detached factors and reconstruction for one linear weight gradient. - - ``forward_inputs`` and ``output_gradients`` have shapes ``[position, in]`` - and ``[position, out]``. ``output_gradients`` and ``weight_gradient`` preserve - the raw ``d(loss)/d(tensor)`` sign; they are not negated into update directions. - Gradient tensors use the requested storage layout. All tensors are cloned to - CPU in float32 so the result owns no autograd graph. - """ - - forward_inputs: Float[torch.Tensor, "position in_features"] - output_gradients: Float[torch.Tensor, "position out_features"] - weight_gradient: Float[torch.Tensor, "weight_dim_0 weight_dim_1"] - reconstructed_gradient: Float[torch.Tensor, "weight_dim_0 weight_dim_1"] - absolute_reconstruction_error: float - relative_reconstruction_error: float - weight_layout: WeightLayout - - -@dataclass(frozen=True) -class VocabularyRanking: - """Owned CPU copies of signed vocabulary rankings with shape ``[..., k]``. - - ``values`` preserves the floating dtype and sign of ``logits``; ``indices`` - has dtype ``torch.int64``. Both tensors are detached. - """ - - values: Float[torch.Tensor, "*leading k"] - indices: Int[torch.Tensor, "*leading k"] - - -@dataclass(frozen=True) -class BackwardLensMatrixResult: - """Factors and vocabulary readouts for one dense MLP weight matrix. - - ``factors`` contains the full linear factorization. ``projected_factor`` says - whether its residual-width ``forward_inputs`` or raw-gradient - ``output_gradients`` were decoded. ``factor_norms`` and ``zero_norm_mask`` - have shape ``[position]`` with float32 and bool dtypes. Largest and smallest - signed rankings are always retained. Full ``vocabulary_logits`` are present - only when explicitly requested. Normalized rankings and optional full logits - are present when the Normalized Logit Lens is requested. Every retained tensor - is a detached CPU-owned value; gradient descent subtracts raw gradients. - """ - - factors: LinearGradientFactors - projected_factor: ProjectedFactor - factor_norms: Float[torch.Tensor, "position"] - zero_norm_mask: Bool[torch.Tensor, "position"] - vocabulary_size: int - target_token_id: int - top_ranking: VocabularyRanking - bottom_ranking: VocabularyRanking - target_largest_ranks: Int[torch.Tensor, "position"] - target_smallest_ranks: Int[torch.Tensor, "position"] - normalized_top_ranking: VocabularyRanking | None = None - normalized_bottom_ranking: VocabularyRanking | None = None - normalized_target_largest_ranks: Int[torch.Tensor, "position"] | None = None - normalized_target_smallest_ranks: Int[torch.Tensor, "position"] | None = None - vocabulary_logits: Float[torch.Tensor, "position d_vocab"] | None = None - normalized_vocabulary_logits: Float[torch.Tensor, "position d_vocab"] | None = None - - def logits(self, *, normalized: bool = False) -> Float[torch.Tensor, "position d_vocab"]: - """Return opted-in raw or Normalized Logit Lens full logits.""" - if not isinstance(normalized, bool): - raise TypeError("normalized must be a bool") - if normalized and self.normalized_top_ranking is None: - raise ValueError("normalized logits were not requested during analysis") - logits = self.normalized_vocabulary_logits if normalized else self.vocabulary_logits - if logits is None: - kind = "normalized " if normalized else "" - raise ValueError( - f"full {kind}logits were not retained; call " - "BackwardLens.analyze(..., return_full_logits=True)" - ) - return logits - - def _retained_ranking(self, *, normalized: bool, largest: bool) -> VocabularyRanking: - if not isinstance(normalized, bool): - raise TypeError("normalized must be a bool") - if normalized: - ranking = self.normalized_top_ranking if largest else self.normalized_bottom_ranking - if ranking is None: - raise ValueError("normalized logits were not requested during analysis") - return ranking - return self.top_ranking if largest else self.bottom_ranking - - def top(self, *, k: int, normalized: bool = False) -> VocabularyRanking: - """Return up to the retained largest signed logits and token ids.""" - return _slice_vocabulary_ranking( - self._retained_ranking(normalized=normalized, largest=True), k=k - ) - - def bottom(self, *, k: int, normalized: bool = False) -> VocabularyRanking: - """Return up to the retained smallest signed logits and token ids.""" - return _slice_vocabulary_ranking( - self._retained_ranking(normalized=normalized, largest=False), k=k - ) - - def top_tokens(self, tokenizer: Any, *, k: int, normalized: bool = False) -> list[list[str]]: - """Decode the largest-``k`` vocabulary ids for every position.""" - return _decode_vocabulary_ranking(self.top(k=k, normalized=normalized), tokenizer) - - def bottom_tokens(self, tokenizer: Any, *, k: int, normalized: bool = False) -> list[list[str]]: - """Decode the smallest-``k`` vocabulary ids for every position.""" - return _decode_vocabulary_ranking(self.bottom(k=k, normalized=normalized), tokenizer) - - def target_ranks( - self, - target_token_id: int, - *, - largest: bool, - normalized: bool = False, - ) -> Int[torch.Tensor, "position"]: - """Return zero-based target ranks per position in the requested ordering. - - ``largest=True`` gives rank zero to the largest logit. ``largest=False`` - gives rank zero to the smallest, which is the useful raw-gradient - convention for the second MLP matrix because gradient descent subtracts it. - Ties receive the same competition rank. The analyzed target's ranks are - always retained; other token ids require opted-in full logits. - """ - if isinstance(target_token_id, bool) or not isinstance(target_token_id, int): - raise TypeError("target_token_id must be an integer") - if not 0 <= target_token_id < self.vocabulary_size: - raise ValueError( - f"target_token_id must be in [0, {self.vocabulary_size - 1}]; " - f"got {target_token_id}" - ) - if not isinstance(largest, bool): - raise TypeError("largest must be a bool") - if not isinstance(normalized, bool): - raise TypeError("normalized must be a bool") - if normalized: - ranks = ( - self.normalized_target_largest_ranks - if largest - else self.normalized_target_smallest_ranks - ) - if ranks is None: - raise ValueError("normalized logits were not requested during analysis") - else: - ranks = self.target_largest_ranks if largest else self.target_smallest_ranks - if target_token_id == self.target_token_id: - return ranks.clone() - return _target_vocabulary_ranks( - self.logits(normalized=normalized), - target_token_id=target_token_id, - largest=largest, - ) - - def gradient_descent_target_ranks( - self, target_token_id: int, *, normalized: bool = False - ) -> Int[torch.Tensor, "position"]: - """Return ascending raw-gradient target ranks (rank zero is smallest).""" - return self.target_ranks(target_token_id, largest=False, normalized=normalized) - - -@dataclass(frozen=True) -class BackwardLensLayerResult: - """Vocabulary-facing input/output MLP matrix results for one indexed layer.""" - - layer: int - input_projection: BackwardLensMatrixResult - output_projection: BackwardLensMatrixResult - - -@dataclass(frozen=True) -class BackwardLensResult: - """Detached result of one :meth:`BackwardLens.analyze` call. - - ``prompt`` and ``target_token`` echo the analyzed inputs; ``target_token_id`` - is the single vocabulary id the target text encodes to. ``loss`` is the raw - scalar cross-entropy of the final-position next-token prediction against the - target; it preserves the ``d(loss)/d(...)`` sign convention and is not negated. - ``prompt_token_ids`` is an owned CPU int64 tensor with shape ``[position]``; - every residual-width factor in ``layers`` is aligned to these same positions. - Position zero is a prepended BOS only when the model and tokenizer configuration - requests one. ``layers`` preserves requested order. Maximum errors summarize - both matrices over every requested layer. - ``includes_normalized_logits`` records whether the Normalized Logit Lens was - computed. ``includes_full_logits`` records whether full vocabulary tensors - were retained in addition to bounded rankings. No model or tokenizer reference - is retained. - """ - - prompt: str - prompt_token_ids: Int[torch.Tensor, "position"] - target_token: str - target_token_id: int - loss: float - layers: tuple[BackwardLensLayerResult, ...] - max_absolute_reconstruction_error: float - max_relative_reconstruction_error: float - includes_normalized_logits: bool - includes_full_logits: bool - - def layer(self, layer: int) -> BackwardLensLayerResult: - """Return one requested layer result or raise ``KeyError``.""" - for result in self.layers: - if result.layer == layer: - return result - raise KeyError(f"layer {layer} was not analyzed") - - -@dataclass(frozen=True) -class _MLPLayerGradientFactors: - """Detached gradient factors for both MLP projections in one layer.""" - - layer: int - input_projection: LinearGradientFactors - output_projection: LinearGradientFactors - - -@dataclass(frozen=True) -class _DenseMLPGradientCapture: - """Private capture result for one dense-MLP next-token loss. - - Tensor fields are detached, owned CPU copies. - """ - - prompt_token_ids: Int[torch.Tensor, "1 position"] - target_token_id: int - loss: float - layers: tuple[_MLPLayerGradientFactors, ...] - - -def _validate_floating_matrix(name: str, tensor: Any) -> Float[torch.Tensor, "rows columns"]: - if not isinstance(tensor, torch.Tensor): - raise TypeError(f"{name} must be a torch.Tensor") - if tensor.ndim != 2: - raise ValueError(f"{name} must be rank 2; got shape {tuple(tensor.shape)}") - if 0 in tensor.shape: - raise ValueError(f"{name} must have no empty dimensions; got shape {tuple(tensor.shape)}") - if not tensor.is_floating_point(): - raise TypeError(f"{name} must have a floating dtype; got {tensor.dtype}") - if not bool(torch.isfinite(tensor).all()): - raise ValueError(f"{name} must contain only finite values") - return cast(Float[torch.Tensor, "rows columns"], tensor) - - -def _reconstruction_errors( - reference: Float[torch.Tensor, "rows columns"], - reconstruction: Float[torch.Tensor, "rows columns"], -) -> tuple[float, float]: - """Return max absolute and symmetric scale-aware relative errors. - - The relative error is ``||reference - reconstruction||_F`` divided by the - maximum of the two input Frobenius norms and float32 epsilon. This remains - finite when one or both gradients are zero. - """ - difference = reference - reconstruction - absolute = float(difference.abs().max()) - scale = torch.maximum(reference.norm(), reconstruction.norm()).clamp_min( - torch.finfo(reference.dtype).eps - ) - relative = float(difference.norm() / scale) - return absolute, relative - - -def _to_detached_float32( - name: str, tensor: Float[torch.Tensor, "rows columns"] -) -> Float[torch.Tensor, "rows columns"]: - """Detach and convert a validated tensor, rejecting float32 overflow.""" - converted = tensor.detach().float() - if not bool(torch.isfinite(converted).all()): - raise ValueError(f"{name} must remain finite when converted to float32") - return converted - - -def _build_linear_gradient_factors( - forward_inputs: Any, - output_gradients: Any, - weight_gradient: Any, - *, - weight_layout: Any, -) -> LinearGradientFactors: - """Reconstruct a weight gradient from aligned token-position factors. - - Args: - forward_inputs: Linear inputs with shape ``[position, in_features]``. - output_gradients: Loss gradients with respect to linear outputs, shape - ``[position, out_features]``. - weight_gradient: Independently computed gradient in ``weight_layout``. - weight_layout: ``"in_out"`` for GPT-2 ``Conv1D`` storage or - ``"out_in"`` for ``torch.nn.Linear`` storage. - - Returns: - Detached factors, the independent gradient, its reconstruction, and - reconstruction errors, all on CPU with float32 tensor values. - """ - validated_inputs = _validate_floating_matrix("forward_inputs", forward_inputs) - validated_gradients = _validate_floating_matrix("output_gradients", output_gradients) - validated_weight = _validate_floating_matrix("weight_gradient", weight_gradient) - if weight_layout not in ("in_out", "out_in"): - raise ValueError("weight_layout must be 'in_out' or 'out_in'") - validated_layout = cast(WeightLayout, weight_layout) - if validated_inputs.shape[0] != validated_gradients.shape[0]: - raise ValueError( - "forward_inputs and output_gradients must have the same number of positions; " - f"got {validated_inputs.shape[0]} and {validated_gradients.shape[0]}" - ) - devices = {validated_inputs.device, validated_gradients.device, validated_weight.device} - if len(devices) != 1: - raise ValueError( - "forward_inputs, output_gradients, and weight_gradient must share a device" - ) - - inputs = _to_detached_float32("forward_inputs", validated_inputs) - gradients = _to_detached_float32("output_gradients", validated_gradients) - canonical = inputs.T @ gradients - reconstruction = canonical if validated_layout == "in_out" else canonical.T - reference = _to_detached_float32("weight_gradient", validated_weight) - if not bool(torch.isfinite(reconstruction).all()): - raise ValueError("the float32 outer-product reconstruction must contain only finite values") - if reference.shape != reconstruction.shape: - raise ValueError( - f"weight_gradient shape {tuple(reference.shape)} does not match the " - f"{validated_layout} reconstruction shape {tuple(reconstruction.shape)}" - ) - absolute, relative = _reconstruction_errors(reference, reconstruction) - return LinearGradientFactors( - forward_inputs=inputs.cpu().clone(), - output_gradients=gradients.cpu().clone(), - weight_gradient=reference.cpu().clone(), - reconstructed_gradient=reconstruction.cpu().clone(), - absolute_reconstruction_error=absolute, - relative_reconstruction_error=relative, - weight_layout=validated_layout, - ) - - -def _rank_vocabulary_logits(logits: Any, *, k: Any, largest: Any) -> VocabularyRanking: - """Return largest or smallest vocabulary logits and token ids per row. - - Ordering among exactly tied logits is intentionally unspecified and follows - :func:`torch.topk`. - """ - if not isinstance(logits, torch.Tensor): - raise TypeError("logits must be a torch.Tensor") - if logits.ndim not in (1, 2) or logits.shape[-1] == 0: - raise ValueError( - f"logits must have shape [vocab] or [position, vocab]; got {tuple(logits.shape)}" - ) - if not logits.is_floating_point(): - raise TypeError(f"logits must have a floating dtype; got {logits.dtype}") - if not bool(torch.isfinite(logits).all()): - raise ValueError("logits must contain only finite values") - if not isinstance(largest, bool): - raise TypeError(f"largest must be a bool; got {type(largest).__name__}") - if isinstance(k, bool) or not isinstance(k, int) or not 1 <= k <= logits.shape[-1]: - raise ValueError(f"k must be in [1, {logits.shape[-1]}]; got {k!r}") - validated_logits = cast(Float[torch.Tensor, "*leading d_vocab"], logits) - ranked = torch.topk(validated_logits.detach(), k=k, dim=-1, largest=largest, sorted=True) - return VocabularyRanking( - values=ranked.values.cpu().clone(), indices=ranked.indices.cpu().clone() - ) - - -def _slice_vocabulary_ranking(ranking: VocabularyRanking, *, k: int) -> VocabularyRanking: - """Return an owned prefix of an already sorted vocabulary ranking.""" - retained = ranking.indices.shape[-1] - if isinstance(k, bool) or not isinstance(k, int) or not 1 <= k <= retained: - raise ValueError(f"k must be in [1, retained top_k={retained}]; got {k!r}") - return VocabularyRanking( - values=ranking.values[..., :k].clone(), - indices=ranking.indices[..., :k].clone(), - ) - - -def _target_vocabulary_ranks( - logits: Float[torch.Tensor, "position d_vocab"], *, target_token_id: int, largest: bool -) -> Int[torch.Tensor, "position"]: - """Return zero-based competition ranks for one vocabulary id per row.""" - target = logits[:, target_token_id].unsqueeze(-1) - comparisons = logits > target if largest else logits < target - return comparisons.sum(dim=-1, dtype=torch.int64).cpu().clone() - - -def _decode_vocabulary_ranking(ranking: VocabularyRanking, tokenizer: Any) -> list[list[str]]: - """Decode a two-dimensional vocabulary ranking without retaining a tokenizer.""" - decode = getattr(tokenizer, "decode", None) - if not callable(decode): - raise TypeError("tokenizer must provide a callable decode method") - if ranking.indices.ndim != 2: - raise ValueError("decoded matrix rankings must have shape [position, k]") - return [[str(decode([token_id])) for token_id in row.tolist()] for row in ranking.indices] - - -@torch.no_grad() -def _project_residual_factors( - model: Any, factors: Float[torch.Tensor, "position d_model"] -) -> Float[torch.Tensor, "position d_vocab"]: - """Apply fresh final normalization and unembedding to residual-width rows.""" - _validate_floating_matrix("factors", factors) - if factors.shape[-1] != int(model.cfg.d_model): - raise ValueError( - f"factors must have width d_model={model.cfg.d_model}; got {factors.shape[-1]}" - ) - unembed_weight = model.W_U - if not isinstance(unembed_weight, torch.Tensor) or unembed_weight.ndim != 2: - raise ValueError("the GPT-2 Bridge must expose a rank-2 unembedding weight") - batched = ( - factors.detach().to(device=unembed_weight.device, dtype=unembed_weight.dtype).unsqueeze(0) - ) - logits = model.unembed(model.ln_final(batched)).squeeze(0) - if logits.ndim != 2 or logits.shape != (factors.shape[0], unembed_weight.shape[1]): - raise RuntimeError( - "final normalization and unembedding must return [position, d_vocab]; " - f"got {tuple(logits.shape)}" - ) - projected = logits.detach().float() - if not bool(torch.isfinite(projected).all()): - raise ValueError("vocabulary projection must remain finite in float32") - return projected.clone() - - -def _factor_norms_and_normalized_rows( - factors: Float[torch.Tensor, "position width"], -) -> tuple[ - Float[torch.Tensor, "position"], - Bool[torch.Tensor, "position"], - Float[torch.Tensor, "position width"], -]: - """Return original L2 norms, exact-zero mask, and safely unit-normalized rows.""" - _validate_floating_matrix("factors", factors) - rows = _to_detached_float32("factors", factors).cpu().clone() - norms = rows.norm(dim=-1) - zero_mask = norms == 0 - denominators = torch.where(zero_mask, torch.ones_like(norms), norms) - normalized = rows / denominators.unsqueeze(-1) - return norms.clone(), zero_mask.clone(), normalized - - -def _build_matrix_result( - model: Any, - factors: LinearGradientFactors, - *, - projected_factor: ProjectedFactor, - include_normalized_logits: bool, - target_token_id: int, - top_k: int, - return_full_logits: bool, -) -> BackwardLensMatrixResult: - if projected_factor not in ("forward_inputs", "output_gradients"): - raise ValueError("projected_factor must be 'forward_inputs' or 'output_gradients'") - if not isinstance(include_normalized_logits, bool): - raise TypeError("include_normalized_logits must be a bool") - if not isinstance(return_full_logits, bool): - raise TypeError("return_full_logits must be a bool") - rows = ( - factors.forward_inputs if projected_factor == "forward_inputs" else factors.output_gradients - ) - norms, zero_mask, normalized_rows = _factor_norms_and_normalized_rows(rows) - raw_logits = _project_residual_factors(model, rows) - normalized_logits = ( - _project_residual_factors(model, normalized_rows) if include_normalized_logits else None - ) - top_ranking = _rank_vocabulary_logits(raw_logits, k=top_k, largest=True) - bottom_ranking = _rank_vocabulary_logits(raw_logits, k=top_k, largest=False) - target_largest_ranks = _target_vocabulary_ranks( - raw_logits, target_token_id=target_token_id, largest=True - ) - target_smallest_ranks = _target_vocabulary_ranks( - raw_logits, target_token_id=target_token_id, largest=False - ) - normalized_top_ranking = None - normalized_bottom_ranking = None - normalized_target_largest_ranks = None - normalized_target_smallest_ranks = None - if normalized_logits is not None: - normalized_top_ranking = _rank_vocabulary_logits(normalized_logits, k=top_k, largest=True) - normalized_bottom_ranking = _rank_vocabulary_logits( - normalized_logits, k=top_k, largest=False - ) - normalized_target_largest_ranks = _target_vocabulary_ranks( - normalized_logits, target_token_id=target_token_id, largest=True - ) - normalized_target_smallest_ranks = _target_vocabulary_ranks( - normalized_logits, target_token_id=target_token_id, largest=False - ) - return BackwardLensMatrixResult( - factors=factors, - projected_factor=projected_factor, - factor_norms=norms, - zero_norm_mask=zero_mask, - vocabulary_size=raw_logits.shape[-1], - target_token_id=target_token_id, - top_ranking=top_ranking, - bottom_ranking=bottom_ranking, - target_largest_ranks=target_largest_ranks, - target_smallest_ranks=target_smallest_ranks, - normalized_top_ranking=normalized_top_ranking, - normalized_bottom_ranking=normalized_bottom_ranking, - normalized_target_largest_ranks=normalized_target_largest_ranks, - normalized_target_smallest_ranks=normalized_target_smallest_ranks, - vocabulary_logits=raw_logits.cpu().clone() if return_full_logits else None, - normalized_vocabulary_logits=( - normalized_logits.cpu().clone() - if return_full_logits and normalized_logits is not None - else None - ), - ) - - -def _validate_requested_layers(model: Any, layers: Sequence[int]) -> tuple[int, ...]: - if isinstance(layers, (str, bytes)) or not isinstance(layers, Sequence): - raise TypeError("layers must be a sequence of integer layer indices") - requested = tuple(layers) - if not requested: - raise ValueError("layers must contain at least one layer index") - for layer in requested: - if isinstance(layer, bool) or not isinstance(layer, int): - raise TypeError(f"each layer must be an integer; got {layer!r}") - if len(set(requested)) != len(requested): - raise ValueError("layers must not contain duplicate indices") - n_layers = int(model.cfg.n_layers) - invalid = [layer for layer in requested if not 0 <= layer < n_layers] - if invalid: - raise ValueError(f"layers must be in [0, {n_layers - 1}]; got {invalid}") - return requested - - -def _require_raw_dense_mlp_bridge(model: Any) -> None: - """Require the raw dense-MLP Bridge capabilities used by gradient capture. - - The architecture is not constrained by class; support is decided per - projection from the Bridge weight-layout oracle in projection discovery. - """ - from transformer_lens.model_bridge import TransformerBridge - - if not isinstance(model, TransformerBridge): - raise TypeError( - "Backward Lens supports TransformerBridge only; load the model with " - "TransformerBridge.boot_transformers(...)." - ) - if getattr(model, "compatibility_mode", False): - raise ValueError( - "Backward Lens requires a raw TransformerBridge; compatibility mode is enabled" - ) - if getattr(model, "_weights_processed", False): - raise ValueError( - "Backward Lens requires original model weights; this Bridge processed its weights" - ) - if int(model.cfg.n_devices) > 1: - raise ValueError( - "Backward Lens requires a single-device TransformerBridge because the MLP " - "projections, final normalization, and unembed must be co-located; " - f"device-map dispatch with cfg.n_devices={model.cfg.n_devices} is not supported" - ) - if bool(getattr(model.cfg, "gated_mlp", False)): - raise NotImplementedError("Backward Lens currently requires dense, non-gated MLPs") - if model.tokenizer is None: - raise ValueError("Backward Lens requires a TransformerBridge with a tokenizer") - for component in ("blocks", "ln_final", "unembed"): - if not hasattr(model, component): - raise ValueError(f"Backward Lens requires the standard {component} component") - _require_unconverted_mlp_blocks(model.blocks) - - -def _require_unconverted_mlp_blocks(blocks: Any) -> None: - """Refuse stacks with no MLP or whose MLP projection hooks fire through a conversion.""" - block_types: set[str] = set() - has_mlp = False - for layer, block in enumerate(blocks): - block_types.add(type(block).__name__) - mlp = getattr(block, "mlp", None) - if mlp is None: - continue - has_mlp = True - for name, key in (("input", "in"), ("output", "out")): - projection = getattr(mlp, key, None) - for hook_name in ("hook_in", "hook_out"): - conversion = getattr(getattr(projection, hook_name, None), "hook_conversion", None) - if conversion is not None: - # The capture's forward hooks sit on the raw HookPoint modules, so the - # conversion that gives hook consumers [batch, position, width] never runs. - raise ValueError( - f"layer {layer} {name} projection {hook_name} carries a " - f"{type(conversion).__name__} hook_conversion; Backward Lens reads " - "the raw projection tensors, which bypass it, so flattened MLP " - "regions such as OPT's [batch * position, width] are not supported" - ) - if not has_mlp: - raise ValueError( - "Backward Lens analyzes dense MLP weights, but no block has an mlp submodule " - f"(block types: {', '.join(sorted(block_types))})" - ) - - -@dataclass(frozen=True) -class _MLPLinear: - """One validated dense-MLP linear projection with its resolved weight layout.""" - - projection: Any - weight_layout: WeightLayout - - -def _get_dense_mlp_projections( - model: Any, layers: tuple[int, ...] -) -> dict[int, tuple[_MLPLinear, _MLPLinear]]: - """Return validated live dense-MLP input/output projection bridges. - - Each projection's storage orientation is resolved from the Bridge weight-layout - oracle, so Conv1D ``[in, out]`` and ``torch.nn.Linear`` ``[out, in]`` weights are - both accepted without inspecting the model class. Projections whose wrapped - module the oracle cannot orient are rejected. - """ - from transformer_lens.hook_points import HookPoint - from transformer_lens.model_bridge.generalized_components import ( - LinearBridge, - MLPBridge, - ) - from transformer_lens.model_bridge.generalized_components.mlp import ( - weight_layout_in_out, - ) - - d_model = int(model.cfg.d_model) - d_mlp = int(model.cfg.d_mlp) - # Feature counts are fixed by the MLP role; storage order follows the layout. - # Input maps d_model -> d_mlp, output maps d_mlp -> d_model. - feature_pairs = ((d_model, d_mlp), (d_mlp, d_model)) - projections: dict[int, tuple[_MLPLinear, _MLPLinear]] = {} - for layer in layers: - block = model.blocks[layer] - mlp = getattr(block, "mlp", None) - if mlp is None: - raise ValueError( - f"layer {layer} has no MLP to analyze: its {type(block).__name__} " - "has no mlp submodule" - ) - if not isinstance(mlp, MLPBridge) or getattr(mlp, "gate", None) is not None: - raise ValueError(f"layer {layer} must have a dense, non-gated MLPBridge") - pair = (getattr(mlp, "in", None), getattr(mlp, "out", None)) - records: list[_MLPLinear] = [] - for name, projection, feature_pair in zip( - ("input", "output"), pair, feature_pairs, strict=True - ): - if not isinstance(projection, LinearBridge): - raise ValueError(f"layer {layer} {name} projection must be a LinearBridge") - layout_flag = weight_layout_in_out(projection) - if layout_flag is None: - raise ValueError( - f"layer {layer} {name} projection has an unknown weight layout; " - "Backward Lens supports Conv1D or torch.nn.Linear MLP projections" - ) - weight_layout: WeightLayout = "in_out" if layout_flag else "out_in" - in_features, out_features = feature_pair - expected_shape = ( - (in_features, out_features) - if weight_layout == "in_out" - else (out_features, in_features) - ) - weight = getattr(projection.original_component, "weight", None) - if not isinstance(weight, torch.nn.Parameter): - raise ValueError( - f"layer {layer} {name} original weight must be a trainable Parameter" - ) - if not weight.is_floating_point() or tuple(weight.shape) != expected_shape: - raise ValueError( - f"layer {layer} {name} weight must have shape {expected_shape} " - f"and floating dtype; got {tuple(weight.shape)} and {weight.dtype}" - ) - if not weight.requires_grad: - raise ValueError( - f"layer {layer} {name} original weight must be a trainable Parameter" - ) - if not isinstance(projection.hook_in, HookPoint) or not isinstance( - projection.hook_out, HookPoint - ): - raise ValueError(f"layer {layer} {name} projection is missing Bridge hook points") - records.append(_MLPLinear(projection=projection, weight_layout=weight_layout)) - projections[layer] = (records[0], records[1]) - return projections - - -def _capture_once( - captured: dict[tuple[int, str, str], torch.Tensor], key: tuple[int, str, str] -) -> Callable[[torch.nn.Module, tuple[Any, ...], Any], None]: - """Build a non-modifying PyTorch hook that records one tensor.""" - - def capture(_module: torch.nn.Module, _inputs: tuple[Any, ...], output: Any) -> None: - if key in captured: - raise RuntimeError(f"Backward Lens hook {key} fired more than once") - if not isinstance(output, torch.Tensor): - raise RuntimeError(f"Backward Lens hook {key} returned a non-tensor output") - captured[key] = output - - return capture - - -@contextmanager -def _capture_projection_tensors( - projections: dict[int, tuple[_MLPLinear, _MLPLinear]], -) -> Iterator[dict[tuple[int, str, str], torch.Tensor]]: - """Capture exact linear boundaries while preserving every pre-existing hook.""" - captured: dict[tuple[int, str, str], torch.Tensor] = {} - handles: list[Any] = [] - try: - for layer, pair in projections.items(): - for name, record in zip(("input", "output"), pair, strict=True): - projection = record.projection - input_key = (layer, name, "forward_input") - output_key = (layer, name, "output") - # Existing hook_in edits must run first so this is the actual linear input. - handles.append( - projection.hook_in.register_forward_hook(_capture_once(captured, input_key)) - ) - # Capture the raw linear output before any existing hook_out edits. - handles.append( - projection.hook_out.register_forward_hook( - _capture_once(captured, output_key), prepend=True - ) - ) - yield captured - finally: - for handle in reversed(handles): - handle.remove() - - -def _single_batch_matrix(name: str, tensor: Any) -> Float[torch.Tensor, "position width"]: - if not isinstance(tensor, torch.Tensor): - raise RuntimeError(f"{name} must be a torch.Tensor") - if tensor.ndim != 3 or tensor.shape[0] != 1: - raise RuntimeError( - f"{name} must have shape [1, position, width]; got {tuple(tensor.shape)}" - ) - matrix = tensor[0] - if not matrix.is_floating_point(): - raise RuntimeError(f"{name} must have a floating dtype; got {matrix.dtype}") - return cast(Float[torch.Tensor, "position width"], matrix) - - -@contextmanager -def _preserve_model_rng(model: Any) -> Iterator[None]: - """Preserve CPU and every CUDA/MPS RNG used by the wrapped model.""" - parameter_devices = {parameter.device for parameter in model.original_model.parameters()} - cuda_devices = sorted( - { - device.index - for device in parameter_devices - if device.type == "cuda" and device.index is not None - } - ) - uses_mps = any(device.type == "mps" for device in parameter_devices) - mps_state = torch.mps.get_rng_state() if uses_mps else None - try: - with torch.random.fork_rng(devices=cuda_devices): - yield - finally: - if mps_state is not None: - torch.mps.set_rng_state(mps_state) - - -def _capture_dense_mlp_gradient_factors( - model: Any, - prompt: str, - target_token: str, - layers: Sequence[int], - *, - operation: str | None = None, -) -> _DenseMLPGradientCapture: - """Capture exact dense-MLP weight-gradient factors for one next-token loss. - - The analysis performs one grad-enabled forward and exactly one - :func:`torch.autograd.grad` call. It does not call ``backward``, touch - parameter ``.grad`` buffers, change training state, or remove caller hooks. - With ``operation`` set, training mode is refused under that name once the - inputs validate and before the forward. - """ - if torch.is_inference_mode_enabled(): - raise ValueError( - "Backward Lens cannot capture gradients inside torch.inference_mode(); " - "exit inference_mode before running the analysis" - ) - _require_raw_dense_mlp_bridge(model) - requested_layers = _validate_requested_layers(model, layers) - projections = _get_dense_mlp_projections(model, requested_layers) - if not isinstance(prompt, str): - raise TypeError("prompt must be a string") - if prompt == "": - raise ValueError("prompt must not be empty") - if not isinstance(target_token, str): - raise TypeError("target_token must be a string") - - prompt_tokens = model.to_tokens(prompt, truncate=False) - if prompt_tokens.ndim != 2 or prompt_tokens.shape[0] != 1 or prompt_tokens.shape[1] == 0: - raise ValueError("prompt must tokenize to one non-empty sequence") - prompt_token_count = int(prompt_tokens.shape[1]) - context_size = int(model.cfg.n_ctx) - if prompt_token_count > context_size: - raise ValueError( - f"prompt token count {prompt_token_count} exceeds model context limit " - f"n_ctx={context_size}" - ) - target_tokens = model.to_tokens(target_token, prepend_bos=False) - if target_tokens.ndim != 2 or tuple(target_tokens.shape) != (1, 1): - count = int(target_tokens.numel()) - raise ValueError( - "target_token must encode to exactly one token without BOS; " f"got {count} tokens" - ) - target_token_id = int(target_tokens.item()) - if operation is not None: - require_eval_mode(model, operation=operation) - input_device = next(model.original_model.parameters()).device - prompt_tokens = prompt_tokens.to(input_device) - weights = [ - record.projection.original_component.weight - for layer in requested_layers - for record in projections[layer] - ] - with _preserve_model_rng(model), torch.enable_grad(): - with _capture_projection_tensors(projections) as captured: - logits = model(prompt_tokens) - if not isinstance(logits, torch.Tensor) or logits.ndim != 3 or logits.shape[0] != 1: - raise RuntimeError("the Bridge must return logits with shape [1, position, vocab]") - target = torch.tensor([target_token_id], device=logits.device) - loss = F.cross_entropy(logits[:, -1, :], target) - if not bool(torch.isfinite(loss)): - raise ValueError("the next-token loss must be finite") - outputs = [ - captured[(layer, name, "output")] - for layer in requested_layers - for name in ("input", "output") - ] - gradients = torch.autograd.grad(loss, (*outputs, *weights), allow_unused=False) - - output_gradients = gradients[: len(outputs)] - weight_gradients = gradients[len(outputs) :] - layer_results = [] - for index, layer in enumerate(requested_layers): - input_offset = 2 * index - input_record, output_record = projections[layer] - input_factors = _build_linear_gradient_factors( - _single_batch_matrix( - f"layer {layer} input projection input", - captured[(layer, "input", "forward_input")], - ), - _single_batch_matrix( - f"layer {layer} input projection gradient", output_gradients[input_offset] - ), - weight_gradients[input_offset], - weight_layout=input_record.weight_layout, - ) - output_factors = _build_linear_gradient_factors( - _single_batch_matrix( - f"layer {layer} output projection input", - captured[(layer, "output", "forward_input")], - ), - _single_batch_matrix( - f"layer {layer} output projection gradient", - output_gradients[input_offset + 1], - ), - weight_gradients[input_offset + 1], - weight_layout=output_record.weight_layout, - ) - layer_results.append( - _MLPLayerGradientFactors( - layer=layer, - input_projection=input_factors, - output_projection=output_factors, - ) - ) - return _DenseMLPGradientCapture( - prompt_token_ids=prompt_tokens.detach().cpu().clone(), - target_token_id=target_token_id, - loss=float(loss.detach()), - layers=tuple(layer_results), - ) - - -class BackwardLens: - """Analyze dense MLP weight gradients in the output vocabulary basis. - - The analyzer accepts a fresh, raw dense-MLP :class:`TransformerBridge` such as - GPT-2 or Pythia/GPT-NeoX. Results retain no model or tokenizer reference and - contain detached CPU-owned tensors. Raw backward signals are loss gradients; - gradient descent subtracts them. - """ - - def __init__(self, model: Any): - """Validate and retain the raw dense-MLP Bridge used for analyses.""" - _require_raw_dense_mlp_bridge(model) - self._model = model - - def analyze( - self, - prompt: str, - target_token: str, - layers: Sequence[int], - *, - normalized: bool = False, - top_k: int = DEFAULT_TOP_K, - return_full_logits: bool = False, - ) -> BackwardLensResult: - """Analyze one final-position, one-token target loss. - - Args: - prompt: Non-empty unbatched prompt text. - target_token: Text encoding to exactly one token without BOS. - layers: Unique layer indices in desired result order. - normalized: Also project unit-normalized nonzero factors using the - Normalized Logit Lens. Raw projections are always returned. - top_k: Number of largest and smallest values and token ids retained - per matrix and position. Defaults to 10. - return_full_logits: Also retain full vocabulary tensors on CPU. - Defaults to ``False`` to keep result size bounded. - - Returns: - Detached gradient factors, bounded vocabulary rankings, norms, - reconstruction errors, target metadata, and optional full logits. - """ - if not isinstance(normalized, bool): - raise TypeError("normalized must be a bool") - if isinstance(top_k, bool) or not isinstance(top_k, int): - raise TypeError("top_k must be an integer") - vocabulary_size = int(self._model.cfg.d_vocab) - if not 1 <= top_k <= vocabulary_size: - raise ValueError(f"top_k must be in [1, {vocabulary_size}]; got {top_k!r}") - if not isinstance(return_full_logits, bool): - raise TypeError("return_full_logits must be a bool") - capture = _capture_dense_mlp_gradient_factors( - self._model, prompt, target_token, layers, operation="BackwardLens.analyze()" - ) - layer_results: list[BackwardLensLayerResult] = [] - absolute_errors: list[float] = [] - relative_errors: list[float] = [] - for layer in capture.layers: - input_result = _build_matrix_result( - self._model, - layer.input_projection, - projected_factor="forward_inputs", - include_normalized_logits=normalized, - target_token_id=capture.target_token_id, - top_k=top_k, - return_full_logits=return_full_logits, - ) - output_result = _build_matrix_result( - self._model, - layer.output_projection, - projected_factor="output_gradients", - include_normalized_logits=normalized, - target_token_id=capture.target_token_id, - top_k=top_k, - return_full_logits=return_full_logits, - ) - layer_results.append( - BackwardLensLayerResult( - layer=layer.layer, - input_projection=input_result, - output_projection=output_result, - ) - ) - absolute_errors.extend( - ( - layer.input_projection.absolute_reconstruction_error, - layer.output_projection.absolute_reconstruction_error, - ) - ) - relative_errors.extend( - ( - layer.input_projection.relative_reconstruction_error, - layer.output_projection.relative_reconstruction_error, - ) - ) - return BackwardLensResult( - prompt=prompt, - prompt_token_ids=capture.prompt_token_ids[0].clone(), - target_token=target_token, - target_token_id=capture.target_token_id, - loss=capture.loss, - layers=tuple(layer_results), - max_absolute_reconstruction_error=max(absolute_errors), - max_relative_reconstruction_error=max(relative_errors), - includes_normalized_logits=normalized, - includes_full_logits=return_full_logits, - ) diff --git a/transformer_lens/tools/analysis/direct_logit_attribution.py b/transformer_lens/tools/analysis/direct_logit_attribution.py index 7b72a638f5..d1cfc92a02 100644 --- a/transformer_lens/tools/analysis/direct_logit_attribution.py +++ b/transformer_lens/tools/analysis/direct_logit_attribution.py @@ -45,7 +45,10 @@ from jaxtyping import Float from transformer_lens.ActivationCache import ActivationCache -from transformer_lens.tools.analysis._model_state import require_eval_mode +from transformer_lens.tools.analysis._model_state import ( + hybrid_block_types, + require_eval_mode, +) from transformer_lens.tools.analysis.logit_lens import ( _final_norm, _require_direct_readout, @@ -55,22 +58,19 @@ # Token-like inputs accepted for the correct/incorrect answers, mirroring # ActivationCache.logit_attrs. -TokenInput = Union[ +AnswerTokens = Union[ str, int, torch.Tensor, ] +# Former name, kept so existing imports keep working. +TokenInput = AnswerTokens # Which structural unit the residual stream is decomposed into. Unit = str # one of: "component", "layer", "head" _VALID_UNITS = ("component", "layer", "head") -# Block variants that lack the attn_out + mlp_out structure decompose_resid expects. -# When TransformerBridge.layer_types() reports any of these we refuse early — the -# downstream decompose_resid would otherwise raise a confusing KeyError. -_HYBRID_VARIANT_NAMES = ("mamba", "ssm", "mixer", "linear_attn") - @dataclass class DirectLogitAttribution: @@ -181,8 +181,8 @@ def _validate_bridge_compatibility(model) -> bool: "with fold_ln=True and no_processing=False." ) - layer_types = model.layer_types() - hybrid = [lt for lt in layer_types if any(p in _HYBRID_VARIANT_NAMES for p in lt.split("+"))] + # Refused up front: decompose_resid would otherwise fail with a confusing KeyError. + hybrid = hybrid_block_types(model) if hybrid: raise NotImplementedError( f"DLA does not yet support hybrid architectures (found block types {hybrid}). " @@ -195,8 +195,8 @@ def _validate_bridge_compatibility(model) -> bool: def direct_logit_attribution( model, input: Union[str, List[str], torch.Tensor, None] = None, - answer_tokens: Optional[TokenInput] = None, - incorrect_tokens: Optional[TokenInput] = None, + answer_tokens: Optional[AnswerTokens] = None, + incorrect_tokens: Optional[AnswerTokens] = None, *, unit: Unit = "component", pos: SliceInput = -1, diff --git a/transformer_lens/tools/analysis/direct_path_patching.py b/transformer_lens/tools/analysis/direct_path_patching.py index c9646c2dc2..430864cc27 100644 --- a/transformer_lens/tools/analysis/direct_path_patching.py +++ b/transformer_lens/tools/analysis/direct_path_patching.py @@ -1,364 +1,29 @@ -"""Direct Path Patching. - -Implements direct path patching — a finer-grained variant of activation patching -introduced for circuit analysis. - -Background ----------- -Standard activation patching (see patching.py) replaces an activation at a given -layer/position with its value from a clean run, and measures how much the model's -output shifts. But patching the *residual stream* affects ALL downstream components, -making it hard to isolate the direct information flow between two specific heads. - -Direct path patching isolates the path A → B: it patches *only* the contribution of -source head A (at layer src_layer) into the input of destination head B (at layer -dst_layer > src_layer), leaving every other component's view of A's output unchanged. - -The linear approximation used here (following Neel Nanda's description in issue #111) -is: - - delta_resid = clean_A_result - corrupted_A_result # [batch, pos, d_model] - delta_q = (delta_resid / ln1_scale) @ W_Q[hb] # [batch, pos, d_head] - patched_q = corrupted_q + delta_q - -This is an approximation, not exact even with LayerNorm folded: it freezes the -corrupted run's ln1 scale, whereas the true scale shifts with the patched -residual. The error grows with how much ``delta_resid`` changes the residual -norm at the destination layer. - -Usage ------ - # 1. Cache clean and corrupted activations - _, clean_cache = model.run_with_cache(clean_tokens) - _, corrupted_cache = model.run_with_cache(corrupted_tokens) - - # 2. Define your metric (same as activation patching) - def metric(logits): - return logit_diff(logits, ...) - - # 3. Sweep all (dst_layer, dst_head) pairs for a fixed source head - results = get_act_patch_direct_path( - model, corrupted_tokens, clean_cache, corrupted_cache, - metric, src_layer=9, src_head=9, - component="q", # patch into Q; also supports "k", "v" - ) - # results.shape == (n_layers, n_heads) - # results[dst_layer, dst_head] = metric when A→B path is patched - -References ----------- -- Neel Nanda, TransformerLens issue #111 (2022) -- Wang et al., "Interpretability in the Wild: a Circuit for Indirect Object - Identification in GPT-2 small" (2022) -""" - -from __future__ import annotations +"""Deprecated location of direct path patching, now in :mod:`transformer_lens.patching`.""" import warnings -from typing import Any, Callable, Literal, cast - -import torch -from jaxtyping import Float -from tqdm.auto import tqdm - -from transformer_lens.ActivationCache import ActivationCache -from transformer_lens.model_protocol import ( - TransformerLensModel, - TransformerLensModelWithWeights, -) -from transformer_lens.tools.analysis._model_state import require_eval_mode - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - - -def _check_fold_ln(model: Any) -> None: - """Warn if the model's LayerNorm weights have not been folded in. - - TransformerBridge wraps the original HuggingFace module, which stores the - learned scale as ``.weight``; legacy TransformerLens components store it as - ``.w``. We check both so the guard works for either layout. - """ - try: - ln1 = model.blocks[0].ln1 # type: ignore[index] - # .weight → TransformerBridge (wraps HF module); .w → legacy TL components - w = getattr(ln1, "w", None) - if w is None: - w = getattr(ln1, "weight", None) - if w is not None and getattr(getattr(model, "cfg", None), "rmsnorm_uses_offset", False): - # Offset-RMS models (Gemma family) store w as an offset from 1: - # the effective scale is (1 + w) and the folded identity is 0. - w = 1.0 + w - if w is not None and not torch.allclose(w, torch.ones_like(w), atol=1e-3): - warnings.warn( - "get_act_patch_direct_path is most accurate when LayerNorm parameters " - "are folded into the weight matrices: call " - "model.process_weights(fold_ln=True) on the TransformerBridge. " - "Results may be inaccurate with unfolded LayerNorm.", - UserWarning, - stacklevel=3, - ) - except (AttributeError, TypeError): - pass # non-standard model — cannot inspect LN weights, proceed - - -def _check_direct_path_support(model: Any, src_layer: int, component: str) -> None: - """Refuse blocks without attention, and K/V patches where query heads share K/V heads.""" - cfg = model.cfg - for layer in range(src_layer, cfg.n_layers): - if getattr(model.blocks[layer], "attn", None) is None: - raise ValueError( - f"blocks.{layer} has no `attn` component (an SSM, mixer, linear-attention or " - "AltUp block), and direct path patching needs one in every block from " - f"src_layer={src_layer} on." - ) - if component == "q": - return - n_heads = cfg.n_heads - scalar_kv = getattr(cfg, "n_key_value_heads", None) - per_layer_kv = getattr(cfg, "per_layer_num_key_value_heads", None) or [] - # Only destinations are K/V-patched; the source is read via hook_z and W_O, per query head. - for layer in range(src_layer + 1, cfg.n_layers): - kv = per_layer_kv[layer] if layer < len(per_layer_kv) else None - if kv is None: - kv = scalar_kv - if kv is not None and kv != n_heads: - raise ValueError( - f"component={component!r} is unsupported here: blocks.{layer} has " - f"n_key_value_heads={kv} for n_heads={n_heads} (GQA/MQA), so per-query-head " - 'K/V semantics are undefined. component="q" is available.' - ) - - -# --------------------------------------------------------------------------- -# Core hook factory -# --------------------------------------------------------------------------- - - -def _make_direct_path_hook( - delta_resid: Float[torch.Tensor, "batch pos d_model"], - dst_head: int, - W_component: Float[torch.Tensor, "d_model d_head"], - ln_scale_name: str, - corrupted_cache: ActivationCache, - component: Literal["q", "k", "v"], -) -> Callable: - """Return a hook function that adds the linearised delta to one head's Q, K, or V. - - Parameters - ---------- - delta_resid: - (clean_A_result - corrupted_A_result), shape [batch, pos, d_model]. - dst_head: - Index of the destination attention head to patch. - W_component: - The weight matrix for the component being patched: - W_Q[dst_head], W_K[dst_head], or W_V[dst_head]. - Shape [d_model, d_head]. - ln_scale_name: - Cache key for the layer-norm scale at the destination layer, - e.g. "blocks.3.ln1.hook_scale". - corrupted_cache: - Cache from the corrupted forward pass (used to look up ln1 scale). - component: - One of "q", "k", "v" — determines which QKV tensor is hooked. - """ - - def hook_fn( - value: Float[torch.Tensor, "batch pos n_heads d_head"], - hook, # HookPoint, unused but required by TransformerLens - ) -> Float[torch.Tensor, "batch pos n_heads d_head"]: - # ln scale: [batch, pos, 1] - ln_scale = corrupted_cache[ln_scale_name] # [batch, pos, 1] - - # Linearised delta in query/key/value space - # delta_resid: [batch, pos, d_model] - # W_component: [d_model, d_head] - delta = (delta_resid / ln_scale) @ W_component # [batch, pos, d_head] +from typing import Any - if value.requires_grad: - value = value.clone() - value[:, :, dst_head, :] = value[:, :, dst_head, :] + delta - return value +from transformer_lens import patching - return hook_fn +_MOVED = ("get_act_patch_direct_path", "get_act_patch_direct_path_all_sources") +# Star-import resolves these through __getattr__, so it binds the moved names and warns. +__all__ = list(_MOVED) -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- +def __getattr__(name: str) -> Any: + if name not in _MOVED: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + # FutureWarning is shown by default: the results changed, not only the import path. + warnings.warn( + f"{__name__}.{name} is deprecated; import it from transformer_lens.patching. It now " + "computes the exact edge patch instead of a frozen-LayerNorm-scale approximation, so " + "its results differ from earlier releases.", + FutureWarning, + stacklevel=2, + ) + return getattr(patching, name) -def get_act_patch_direct_path( - model: TransformerLensModel, - corrupted_tokens: torch.Tensor, - clean_cache: ActivationCache, - corrupted_cache: ActivationCache, - patching_metric: Callable[[torch.Tensor], torch.Tensor], - src_layer: int, - src_head: int, - component: Literal["q", "k", "v"] = "q", - verbose: bool = True, -) -> Float[torch.Tensor, "n_layers n_heads"]: - """Sweep direct path patches from one source head to all downstream heads. - - For every destination head B = (dst_layer, dst_head) where dst_layer > src_layer, - patch the contribution of source head A = (src_layer, src_head) into B's query - (or key / value) input, and record the patching metric. - - The patch is a linear approximation: - - delta_resid = clean_A_result - corrupted_A_result [batch, pos, d_model] - delta_B_comp = (delta_resid / ln1_scale) @ W_comp[dst_head] - - where W_comp is W_Q, W_K, or W_V according to `component`. - - Parameters - ---------- - model: - Any TransformerLens model (e.g. TransformerBridge). - corrupted_tokens: - Token IDs for the corrupted input, shape [batch, seq_len]. - clean_cache: - Cached activations from the clean (unpatched) run. - corrupted_cache: - Cached activations from the corrupted run (needed for ln1 scale). - patching_metric: - A function mapping the model's logits tensor to a scalar. - src_layer: - Layer index of the source attention head. - src_head: - Head index of the source attention head. - component: - Which input to patch at the destination head — "q" (default), "k", or "v"; - "k" and "v" raise ValueError on GQA/MQA models. - verbose: - Whether to show a tqdm progress bar. - - Returns - ------- - results : Float[Tensor, "n_layers n_heads"] - results[dst_layer, dst_head] is the patching metric when the direct path - A → B is patched in. Entries for dst_layer <= src_layer are left as 0.0 - (no causal path from A to those layers). - """ - _check_direct_path_support(model, src_layer, component) - require_eval_mode(model, operation="get_act_patch_direct_path()") - _check_fold_ln(model) - - n_layers = model.cfg.n_layers - n_heads = model.cfg.n_heads - - results = torch.zeros(n_layers, n_heads, device=model.cfg.device) - - # Residual stream delta from source head A. - # - # hook_result (per-head residual contribution) requires cfg.use_hook_result=True - # and is not in the default cache. We compute it instead from hook_z and W_O, - # which are always available: - # result_h = z[:, :, h, :] @ W_O[h] shape [batch, pos, d_model] - src_z_name = f"blocks.{src_layer}.attn.hook_z" - # Static-only view of the weights surface (runtime checks use the base protocol: - # nn.Module submodules are invisible to getattr_static-based isinstance). - weights_model = cast(TransformerLensModelWithWeights, model) - W_O = weights_model.blocks[src_layer].attn.W_O # [n_heads, d_head, d_model] - - def _head_result(cache, h): - z = cache[src_z_name][:, :, h, :] # [batch, pos, d_head] - return z @ W_O[h] # [batch, pos, d_model] - - delta_resid = _head_result(clean_cache, src_head) - _head_result(corrupted_cache, src_head) - # shape: [batch, pos, d_model] - - # Weight matrix for the component being patched - _comp_map = { - "q": lambda attn: attn.W_Q, # [n_heads, d_model, d_head] - "k": lambda attn: attn.W_K, - "v": lambda attn: attn.W_V, - } - _hook_name_map = { - "q": lambda lb: f"blocks.{lb}.attn.hook_q", - "k": lambda lb: f"blocks.{lb}.attn.hook_k", - "v": lambda lb: f"blocks.{lb}.attn.hook_v", - } - W_all = _comp_map[component] # callable: attn → [n_heads, d_model, d_head] - hook_name_fn = _hook_name_map[component] - - dst_pairs = [(lb, hb) for lb in range(src_layer + 1, n_layers) for hb in range(n_heads)] - - for dst_layer, dst_head in tqdm( - dst_pairs, - desc=f"Direct path patch ({src_layer},{src_head}) → * [{component}]", - disable=not verbose, - ): - ln_scale_name = f"blocks.{dst_layer}.ln1.hook_scale" - W_comp = W_all(weights_model.blocks[dst_layer].attn)[dst_head] # [d_model, d_head] - - hook_fn = _make_direct_path_hook( - delta_resid=delta_resid, - dst_head=dst_head, - W_component=W_comp, - ln_scale_name=ln_scale_name, - corrupted_cache=corrupted_cache, - component=component, - ) - - patched_logits = model.run_with_hooks( - corrupted_tokens, - fwd_hooks=[(hook_name_fn(dst_layer), hook_fn)], - ) - - results[dst_layer, dst_head] = patching_metric(patched_logits).item() - - return results - - -def get_act_patch_direct_path_all_sources( - model: TransformerLensModel, - corrupted_tokens: torch.Tensor, - clean_cache: ActivationCache, - corrupted_cache: ActivationCache, - patching_metric: Callable[[torch.Tensor], torch.Tensor], - component: Literal["q", "k", "v"] = "q", - verbose: bool = True, -) -> Float[torch.Tensor, "n_layers n_heads n_layers n_heads"]: - """Full sweep: all (src_layer, src_head) → (dst_layer, dst_head) direct paths. - - Returns a 4-D tensor of shape [n_layers, n_heads, n_layers, n_heads]. - result[sl, sh, dl, dh] = patching metric when head (sl,sh)'s output is - patched directly into head (dl,dh)'s query/key/value input. - - Entries where dl <= sl are 0 (no causal path). - - This runs O(n_layers * n_heads * n_layers * n_heads) forward passes and is - intended for small models or targeted sub-sweeps. For large models prefer - calling get_act_patch_direct_path per source head. - """ - _check_direct_path_support(model, 0, component) - require_eval_mode(model, operation="get_act_patch_direct_path_all_sources()") - _check_fold_ln(model) - - n_layers = model.cfg.n_layers - n_heads = model.cfg.n_heads - results = torch.zeros(n_layers, n_heads, n_layers, n_heads, device=model.cfg.device) - - src_pairs = [(sl, sh) for sl in range(n_layers) for sh in range(n_heads)] - for src_layer, src_head in tqdm( - src_pairs, - desc=f"Direct path patch — all sources [{component}]", - disable=not verbose, - ): - results[src_layer, src_head] = get_act_patch_direct_path( - model=model, - corrupted_tokens=corrupted_tokens, - clean_cache=clean_cache, - corrupted_cache=corrupted_cache, - patching_metric=patching_metric, - src_layer=src_layer, - src_head=src_head, - component=component, - verbose=False, - ) - return results +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_MOVED)) diff --git a/transformer_lens/tools/analysis/jacobian_lens.py b/transformer_lens/tools/analysis/jacobian_lens.py index f29e59f2b8..f0034d1a04 100644 --- a/transformer_lens/tools/analysis/jacobian_lens.py +++ b/transformer_lens/tools/analysis/jacobian_lens.py @@ -83,7 +83,7 @@ from tqdm.auto import tqdm from transformer_lens.ActivationCache import ActivationCache -from transformer_lens.tools.analysis._model_state import require_eval_mode +from transformer_lens.tools.analysis._model_state import require_eval_mode, to_token_ids from transformer_lens.tools.analysis.jacobian_lens_coordinate_patch import ( CoordinatePatch, solve_coordinate_patch, @@ -98,9 +98,12 @@ estimate_occupancy, get_sparse_decomposition, ) +from transformer_lens.tools.analysis.logit_lens import logit_readout from transformer_lens.utilities.hf_utils import call_hf_with_retry -TokenInput = Union[str, int] +ConceptToken = Union[str, int] +# Former name, kept so existing imports keep working. +TokenInput = ConceptToken _LensT = TypeVar("_LensT", bound="JacobianLens") # Backward-provider seam for the fit drive loop. A provider takes the target @@ -146,8 +149,8 @@ def _resolve_registry_entry(name_or_path: str) -> Optional[Tuple[str, str]]: generic Hub download path. """ registry = _load_registry() - if name_or_path in registry: - entry = registry[name_or_path] + entry = registry.get(name_or_path) + if isinstance(entry, dict): return entry["repo_id"], entry["filename"] for entry in registry.values(): if isinstance(entry, dict) and name_or_path in entry.get("aliases", []): @@ -155,6 +158,11 @@ def _resolve_registry_entry(name_or_path: str) -> Optional[Tuple[str, str]]: return None +def _registry_revision() -> str: + """Hub commit that every registry entry is downloaded at unless a caller pins another.""" + return _load_registry()["revision"] + + # Fitting excludes early positions (attention sinks with atypical residual statistics) # and the final position (no next-token target), matching the reference implementation. DEFAULT_SKIP_FIRST_POSITIONS = 16 @@ -266,11 +274,6 @@ class JacobianLens: from the reference implementation load with empty metadata. """ - # Whether from_pretrained() resolves short model names through the bundled - # artifact registry. An estimator whose artifacts live in a different - # registry sets this False and accepts only explicit paths or Hub repo ids. - _uses_artifact_registry: bool = True - def __init__( self, jacobians: Dict[int, Float[torch.Tensor, "d_model d_model"]], @@ -313,18 +316,6 @@ def __repr__(self) -> str: f"d_model={self.d_model}, n_prompts={self.n_prompts})" ) - @classmethod - def _validate_artifact_metadata(cls, path: str, metadata: Any) -> None: - """Allow legacy artifacts without estimator provenance.""" - - @classmethod - def _validate_checkpoint_payload(cls, path: str, payload: Dict[str, Any]) -> None: - """Allow conversion of reference Jacobian running-sum checkpoints.""" - - @classmethod - def _validate_merge_inputs(cls, lenses: Sequence["JacobianLens"]) -> None: - """Allow generic lenses whose recorded provenance agrees.""" - # ------------------------------------------------------------------ # # persistence # # ------------------------------------------------------------------ # @@ -410,7 +401,6 @@ def load(cls: Type[_LensT], path: str) -> _LensT: """ payload = torch.load(path, map_location="cpu", weights_only=True) if "J" in payload: - cls._validate_artifact_metadata(path, payload.get("metadata")) return cls( {int(layer): matrix for layer, matrix in payload["J"].items()}, n_prompts=int(payload.get("n_prompts", 0)), @@ -418,7 +408,6 @@ def load(cls: Type[_LensT], path: str) -> _LensT: metadata=payload.get("metadata"), ) if "jacobian_sum" in payload: - cls._validate_checkpoint_payload(path, payload) return cls._from_checkpoint_payload(path, payload) raise ValueError( f"{path} does not look like a Jacobian lens artifact or fit checkpoint. " @@ -508,11 +497,10 @@ def from_pretrained( 3. **Registry short name or HF model ID** — if *name_or_path* matches a key or alias in the bundled ``jacobian_lens_registry.json`` (e.g. ``"gemma-2-2b"`` or ``"google/gemma-2-2b"``), the corresponding - artifact in ``neuronpedia/jacobian-lens`` is fetched automatically. + artifact in ``neuronpedia/jacobian-lens`` is fetched automatically, + at the commit the registry pins unless *revision* is given. The *filename* argument is ignored in this case because the registry - already encodes the correct subpath. Subclasses that set - ``_uses_artifact_registry = False`` skip this step and refuse a bare - short name, since the registry holds only Jacobian lens artifacts. + already encodes the correct subpath. 4. **Explicit Hub repo** — otherwise *name_or_path* is treated as a Hub repo id and *filename* is used as-is, preserving full backward compatibility (e.g. ``from_pretrained("neuronpedia/jacobian-lens", @@ -526,9 +514,10 @@ def from_pretrained( filename: File (or subpath) inside a local directory or an explicit Hub repo. Ignored when *name_or_path* resolves via the registry. - revision: Optional Hub revision (branch, tag, or commit) to pin. - When omitted, the Hub repository's mutable default branch is - followed; pin a commit hash for reproducible analyses. + revision: Optional Hub revision (branch, tag, or commit). When + omitted, registry names resolve at the registry's pinned commit + and an explicit Hub repo follows its mutable default branch; + pin a commit hash for reproducible analyses of the latter. model: If given, :meth:`validate_model` is called so dimension or weight-processing mismatches fail here rather than at first use. @@ -559,18 +548,10 @@ def from_pretrained( else: from huggingface_hub import hf_hub_download - if cls._uses_artifact_registry: - resolved = _resolve_registry_entry(name_or_path) - else: - if "/" not in name_or_path: - raise ValueError( - f"{cls.__name__} does not resolve short names from the " - "Jacobian lens registry; pass an explicit path or a Hub " - f"repo id (owner/name) instead of {name_or_path!r}." - ) - resolved = None + resolved = _resolve_registry_entry(name_or_path) if resolved is not None: repo_id, resolved_filename = resolved + revision = _registry_revision() if revision is None else revision else: repo_id, resolved_filename = name_or_path, filename @@ -594,9 +575,8 @@ def merge(cls, lenses: Sequence["JacobianLens"]) -> "JacobianLens": parallelized across processes or machines and merged afterwards. Provenance must match across shards (apart from ``n_prompts``), so a merge cannot silently relabel matrices fitted with different models, - corpora, dtypes, or recorded estimator settings. Estimator-specific - subclasses also validate that the inputs belong to their estimator. - The merged count replaces the per-shard count. + corpora, dtypes, or recorded estimator settings. The merged count + replaces the per-shard count. Args: lenses: Lenses that agree exactly on ``source_layers`` and @@ -619,7 +599,6 @@ def merge(cls, lenses: Sequence["JacobianLens"]) -> "JacobianLens": first = lenses[0] for lens in lenses: _validate_metadata(lens.metadata) - cls._validate_merge_inputs(lenses) first_provenance = { key: value for key, value in first.metadata.items() if key != "n_prompts" } @@ -847,7 +826,8 @@ def readout( ) residual = activation[0, norm_positions, :] transported = self.transport(residual, layer) if use_jacobian else residual.float() - layer_logits = _unembed(model, transported) + # fp32 only after the output transform: the model soft-caps in W_U's dtype. + layer_logits = logit_readout(model, transported, apply_ln=True).values.float() layer_topk = layer_logits.topk(top_k, dim=-1) lens_topk_values[layer] = layer_topk.values.cpu() lens_topk_indices[layer] = layer_topk.indices.cpu() @@ -873,7 +853,7 @@ def readout( def lens_vectors( self, model: Any, - tokens: Union[TokenInput, Sequence[TokenInput]], + tokens: Union[ConceptToken, Sequence[ConceptToken]], layer: int, ) -> Float[torch.Tensor, "n d_model"]: """Residual-stream directions for vocabulary tokens at a layer. @@ -893,7 +873,7 @@ def lens_vectors( """ self.validate_model(model) layer = _normalize_layer(layer, model.cfg.n_layers) - token_ids = _to_token_ids(model, tokens) + token_ids = to_token_ids(model, tokens) unembed_columns = model.W_U[:, token_ids].float() # [d_model, n] matrix = self._matrix_on(layer, unembed_columns.device) return (matrix.T @ unembed_columns).T @@ -1002,8 +982,8 @@ def coordinate_patch( model: Any, activation_or_prompt: Union[torch.Tensor, str], layer: int, - source_token: TokenInput, - target_token: TokenInput, + source_token: ConceptToken, + target_token: ConceptToken, *, position: Optional[int] = None, decomposition: Optional[JSpaceDecomposition] = None, @@ -1042,7 +1022,7 @@ def coordinate_patch( model, activation_or_prompt, layer, position, operation="coordinate_patch" ) dictionary = self.lens_vector_dictionary(model, resolved_layer) - source_id, target_id = _to_token_ids(model, [source_token, target_token]) + source_id, target_id = to_token_ids(model, [source_token, target_token]) return solve_coordinate_patch( activation.float().to(dictionary.device), dictionary, @@ -1132,6 +1112,11 @@ def occupancy( ``position``) exactly as :meth:`decompose`, builds the cached full-vocabulary dictionary via :meth:`lens_vector_dictionary`, and calls :func:`estimate_occupancy`. + Each control dictionary is a ``[d_vocab, d_model]`` draw made on the CPU so a seed gives + the same result on every device. On an accelerator those draws dominate the run time + (seconds per control for a large vocabulary), so lower ``num_control_dictionaries`` when + speed matters more than control precision. + Args: model: A raw ``TransformerBridge``. activation_or_prompt: An activation vector, or a prompt (string / token tensor). @@ -1286,7 +1271,7 @@ def fraction_of_variance( def steering_hooks( self, model: Any, - token: TokenInput, + token: ConceptToken, layers: Sequence[int], *, alpha: float = 4.0, @@ -1327,6 +1312,7 @@ def steering_hooks( def transform( selected: Float[torch.Tensor, "batch pos d_model"], + positions: List[int], unit: torch.Tensor = unit, device_units: Dict[torch.device, torch.Tensor] = device_units, ) -> Float[torch.Tensor, "batch pos d_model"]: @@ -1345,7 +1331,7 @@ def transform( def ablation_hooks( self, model: Any, - tokens: Union[TokenInput, Sequence[TokenInput]], + tokens: Union[ConceptToken, Sequence[ConceptToken]], layers: Sequence[int], *, positions: Optional[Sequence[int]] = None, @@ -1374,6 +1360,7 @@ def ablation_hooks( def transform( selected: Float[torch.Tensor, "batch pos d_model"], + positions: List[int], units: torch.Tensor = units, device_units: Dict[torch.device, torch.Tensor] = device_units, ) -> Float[torch.Tensor, "batch pos d_model"]: @@ -1395,50 +1382,73 @@ def transform( def swap_hooks( self, model: Any, - source_token: TokenInput, - target_token: TokenInput, + source_token: ConceptToken, + target_token: ConceptToken, layers: Sequence[int], *, alpha: float = 1.0, positions: Optional[Sequence[int]] = None, + clamp_to: Optional[Union[ActivationCache, Mapping[str, torch.Tensor]]] = None, ) -> List[Tuple[str, Any]]: - """Hooks that swap two concepts' *live* coordinates in lens space. + """Hooks that exchange two concepts' coordinates in lens space. The paper's patching-in-lens-coordinates intervention: with ``V = [v_s, v_t]`` and lens coordinates ``c = V⁺ h`` (pseudoinverse), - the update is ``h <- h + alpha * V (sigma(c) - c)`` where ``sigma`` - exchanges the two coordinates. The component of ``h`` orthogonal to - ``span{v_s, v_t}`` is untouched. ``alpha=2`` is the paper's - "double-strength" swap. - - This transform re-reads ``c`` from the activation seen by every hook. - It is therefore an involution when applied repeatedly in a subspace - whose coordinates are preserved between layers: a second application - can undo the first. For the paper's multi-layer clamp protocol, use - :meth:`swap_clamp_hooks` with activations cached from the clean run. + the update is ``h <- h + alpha * V (c_target - c)``. The component of + ``h`` orthogonal to ``span{v_s, v_t}`` is untouched. ``alpha=2`` is the + paper's "double-strength" swap. + + Without ``clamp_to``, ``c_target = sigma(c)`` exchanges the two *live* + coordinates, re-read from the activation seen by every hook. It is + therefore an involution when applied repeatedly in a subspace whose + coordinates are preserved between layers: a second application can undo + the first. + + With ``clamp_to`` (the paper's multi-layer clamp protocol), ``c_target`` + is the exchange of each layer's *clean-run* coordinates, fixed when the + hooks are built, so at ``alpha=1`` the update is idempotent at each + layer: ``h <- h + V (c_target - V⁺h)``. Args: model: The model the hooks will run on. source_token: The concept to remove (e.g. ``" France"``). target_token: The concept to install (e.g. ``" China"``). layers: Layers to intervene at. - alpha: Swap strength. + alpha: Swap strength; with ``clamp_to``, the fraction of the way to + the clamped coordinates. positions: Chunk-local positions to swap (negative indices allowed - and normalized on every hook invocation). Defaults to all. + and normalized on every hook invocation, and against the clean + activations when clamping). Defaults to all. + clamp_to: Activations from an unmodified ``run_with_cache`` at each + requested layer's ``blocks.{layer}.hook_out`` name. Either the + ``ActivationCache`` it returns by default or the plain dict from + ``return_cache_object=False`` is accepted. Returns: ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. """ self.validate_model(model) - source_id, target_id = _to_token_ids(model, [source_token, target_token]) - if source_id == target_id: - raise ValueError( - "source_token and target_token resolve to the same token id; " - "a coordinate swap would be a silent no-op" - ) + source_id, target_id = _distinct_ids( + model, + source_token, + target_token, + operation="swap" if clamp_to is None else "clamp", + ) hooks = [] for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: + hook_name = _resid_post_hook_name(layer) + clean_selected: Optional[torch.Tensor] = None + if clamp_to is not None: + if hook_name not in clamp_to: + raise ValueError(f"clamp_to is missing activation {hook_name!r}") + clean = clamp_to[hook_name] + _validate_residual_activation( + clean, d_model=model.cfg.d_model, hook_name=f"clamp_to[{hook_name!r}]" + ) + normalized = _normalize_positions(positions, clean.shape[1]) + clean_selected = clean if positions is None else clean[:, normalized, :] + vectors = self.lens_vectors(model, [source_id, target_id], layer) units = _unit_rows(vectors, layer=layer) _diagnose_intervention_pair( @@ -1446,133 +1456,73 @@ def swap_hooks( ) basis = vectors.T # [d, 2] pinv = torch.linalg.pinv(basis) # [2, d] + clamped = None + if clean_selected is not None: + clamped = (clean_selected.float() @ pinv.T)[..., [1, 0]] device_basis: Dict[torch.device, torch.Tensor] = {} device_pinv: Dict[torch.device, torch.Tensor] = {} + device_clamped: Dict[torch.device, torch.Tensor] = {} def transform( selected: Float[torch.Tensor, "batch pos d_model"], + positions: List[int], basis: torch.Tensor = basis, pinv: torch.Tensor = pinv, + clamped: Optional[torch.Tensor] = clamped, device_basis: Dict[torch.device, torch.Tensor] = device_basis, device_pinv: Dict[torch.device, torch.Tensor] = device_pinv, + device_clamped: Dict[torch.device, torch.Tensor] = device_clamped, ) -> Float[torch.Tensor, "batch pos d_model"]: local_basis = _cached_on_device(basis, device_basis, selected.device) local_pinv = _cached_on_device(pinv, device_pinv, selected.device) coords = selected.float() @ local_pinv.T # [..., 2] - delta = alpha * ((coords[..., [1, 0]] - coords) @ local_basis.T) + if clamped is None: + target = coords[..., [1, 0]] + else: + target = _cached_on_device(clamped, device_clamped, selected.device) + if target.shape[1] != selected.shape[1] or target.shape[0] not in ( + 1, + selected.shape[0], + ): + raise ValueError( + "clamp_to activation shape is incompatible with the live " + f"activation: target coordinates have shape {tuple(target.shape)}, " + f"live activation has shape {tuple(selected.shape)}" + ) + delta = alpha * ((target - coords) @ local_basis.T) return selected.float() + delta hooks.append( - ( - _resid_post_hook_name(layer), - _make_intervention_hook(transform, positions, model.cfg.d_model), - ) + (hook_name, _make_intervention_hook(transform, positions, model.cfg.d_model)) ) return hooks def swap_clamp_hooks( self, model: Any, - source_token: TokenInput, - target_token: TokenInput, + source_token: ConceptToken, + target_token: ConceptToken, layers: Sequence[int], clean_cache: Union[ActivationCache, Mapping[str, torch.Tensor]], *, positions: Optional[Sequence[int]] = None, ) -> List[Tuple[str, Any]]: - """Hooks that clamp lens coordinates to their clean-run exchange. - - For each layer, this projects the corresponding activation from - ``clean_cache`` into that layer's lens basis, exchanges its source and - target coordinates once, and holds the live activation at that fixed - target. Unlike :meth:`swap_hooks`, the update is idempotent at each - layer: ``h <- h + V (c_target - V⁺h)``. - - Args: - model: The model the hooks will run on. - source_token: The concept to remove (e.g. ``" France"``). - target_token: The concept to install (e.g. ``" China"``). - layers: Layers to intervene at. - clean_cache: Activations from an unmodified ``run_with_cache`` at - each requested layer's ``blocks.{layer}.hook_out`` name. Either - the ``ActivationCache`` it returns by default or the plain dict - from ``return_cache_object=False`` is accepted. - positions: Chunk-local positions to clamp (negative indices allowed - and normalized against the clean activations). Defaults to all. - - Returns: - ``[(hook_name, fn), ...]`` for ``model.hooks(fwd_hooks=...)``. - """ - self.validate_model(model) - source_id, target_id = _to_token_ids(model, [source_token, target_token]) - if source_id == target_id: - raise ValueError( - "source_token and target_token resolve to the same token id; " - "a coordinate clamp would be a silent no-op" - ) - - hooks = [] - for layer in [_normalize_layer(layer, model.cfg.n_layers) for layer in layers]: - hook_name = _resid_post_hook_name(layer) - if hook_name not in clean_cache: - raise ValueError(f"clean_cache is missing activation {hook_name!r}") - clean = clean_cache[hook_name] - _validate_residual_activation( - clean, d_model=model.cfg.d_model, hook_name=f"clean_cache[{hook_name!r}]" - ) - normalized = _normalize_positions(positions, clean.shape[1]) - clean_selected = clean if positions is None else clean[:, normalized, :] - - vectors = self.lens_vectors(model, [source_id, target_id], layer) - units = _unit_rows(vectors, layer=layer) - _diagnose_intervention_pair( - units, description=f"swap vectors at layer {layer}", stacklevel=3 - ) - basis = vectors.T # [d, 2] - pinv = torch.linalg.pinv(basis) # [2, d] - target_coords = (clean_selected.float() @ pinv.T)[..., [1, 0]] - device_basis: Dict[torch.device, torch.Tensor] = {} - device_pinv: Dict[torch.device, torch.Tensor] = {} - device_targets: Dict[torch.device, torch.Tensor] = {} - - def transform( - selected: Float[torch.Tensor, "batch pos d_model"], - basis: torch.Tensor = basis, - pinv: torch.Tensor = pinv, - target_coords: torch.Tensor = target_coords, - device_basis: Dict[torch.device, torch.Tensor] = device_basis, - device_pinv: Dict[torch.device, torch.Tensor] = device_pinv, - device_targets: Dict[torch.device, torch.Tensor] = device_targets, - ) -> Float[torch.Tensor, "batch pos d_model"]: - local_basis = _cached_on_device(basis, device_basis, selected.device) - local_pinv = _cached_on_device(pinv, device_pinv, selected.device) - local_targets = _cached_on_device(target_coords, device_targets, selected.device) - if local_targets.shape[1] != selected.shape[1] or local_targets.shape[0] not in ( - 1, - selected.shape[0], - ): - raise ValueError( - "clean_cache activation shape is incompatible with the live activation: " - f"target coordinates have shape {tuple(local_targets.shape)}, " - f"live activation has shape {tuple(selected.shape)}" - ) - coords = selected.float() @ local_pinv.T - delta = (local_targets - coords) @ local_basis.T - return selected.float() + delta - - hooks.append( - ( - hook_name, - _make_intervention_hook(transform, positions, model.cfg.d_model), - ) - ) - return hooks + """Deprecated: use :meth:`swap_hooks` with ``clamp_to=clean_cache``.""" + warnings.warn( + "JacobianLens.swap_clamp_hooks is deprecated; use swap_hooks(model, source_token, " + "target_token, layers, clamp_to=clean_cache), which builds the same hooks.", + DeprecationWarning, + stacklevel=2, + ) + return self.swap_hooks( + model, source_token, target_token, layers, positions=positions, clamp_to=clean_cache + ) def coordinate_patch_hooks( self, model: Any, - source_token: TokenInput, - target_token: TokenInput, + source_token: ConceptToken, + target_token: ConceptToken, layers: Sequence[int], *, positions: Sequence[int], @@ -1633,12 +1583,7 @@ def coordinate_patch_hooks( if not positions: raise ValueError("positions must contain at least one index") resolved_layers = [_normalize_layer(layer, model.cfg.n_layers) for layer in layers] - source_id, target_id = _to_token_ids(model, [source_token, target_token]) - if source_id == target_id: - raise ValueError( - "source_token and target_token resolve to the same token id; " - "a coordinate patch would be a silent no-op" - ) + source_id, target_id = _distinct_ids(model, source_token, target_token, operation="patch") warnings.warn( f"coordinate_patch_hooks installs {len(resolved_layers)} layer(s) x " f"{len(positions)} position(s) of coordinate-patch hooks; every (batch, position) " @@ -1648,27 +1593,20 @@ def coordinate_patch_hooks( stacklevel=2, ) - requested = tuple(positions) hooks = [] for layer in resolved_layers: dictionary = self.lens_vector_dictionary(model, layer) - def hook_fn( - activation: Float[torch.Tensor, "batch pos d_model"], - hook: Any, + def transform( + selected: Float[torch.Tensor, "batch pos d_model"], + positions: List[int], layer: int = layer, dictionary: torch.Tensor = dictionary, ) -> Float[torch.Tensor, "batch pos d_model"]: - hook_name = getattr(hook, "name", "intervention hook") - _validate_residual_activation( - activation, d_model=model.cfg.d_model, hook_name=hook_name - ) - normalized = _normalize_positions(requested, activation.shape[1]) - selected = activation[:, normalized, :].float().to(dictionary.device) patched, _ = solve_coordinate_patch_positions( - selected, + selected.float().to(dictionary.device), dictionary, - normalized, + positions, source_id, target_id, layer=layer, @@ -1678,13 +1616,14 @@ def hook_fn( alpha=alpha, algorithm=algorithm, ) - output = activation.clone() - output[:, normalized, :] = patched.to( - device=activation.device, dtype=activation.dtype - ) - return output + return patched - hooks.append((_resid_post_hook_name(layer), hook_fn)) + hooks.append( + ( + _resid_post_hook_name(layer), + _make_intervention_hook(transform, positions, model.cfg.d_model), + ) + ) return hooks # ------------------------------------------------------------------ # @@ -1792,7 +1731,7 @@ def fit( @dataclass(frozen=True) class _FitOptions: - """Resolved options shared by transport-matrix estimators.""" + """Validated options for one fit.""" source_layers: Tuple[int, ...] target_layer: int @@ -1815,9 +1754,8 @@ def _prepare_fit( max_seq_len: int, skip_first_positions: int, metadata: Optional[Dict[str, Any]], - estimator_metadata_keys: Sequence[str] = (), ) -> _FitOptions: - """Validate the shared fit contract before tokenization or rule installation.""" + """Validate the shared fit contract before any tokenization.""" _require_raw_bridge(model, estimator=operation) require_eval_mode(model, operation=f"{operation}.fit()") if not isinstance(corpus, str) or not corpus.strip(): @@ -1848,15 +1786,8 @@ def _prepare_fit( max_seq_len=max_seq_len, skip_first_positions=skip_first_positions, ) - # Preflight metadata before fitting supplies counts and estimator-specific values. - _build_fit_metadata( - model, - options, - estimator=estimator, - n_prompts=0, - metadata=metadata, - estimator_metadata=dict.fromkeys(estimator_metadata_keys), - ) + # Preflight so bad provenance fails before the fit, not after it. + _build_fit_metadata(model, options, estimator=estimator, n_prompts=0, metadata=metadata) if options.fit_dtype in (torch.float16, torch.bfloat16): warnings.warn( f"fitting in {options.fit_dtype} accumulates transport gradients at reduced " @@ -1874,9 +1805,8 @@ def _build_fit_metadata( estimator: str, n_prompts: int, metadata: Optional[Dict[str, Any]], - estimator_metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: - """Combine validated user provenance with common and estimator-specific fit fields.""" + """Combine validated user provenance with the fit's own provenance fields.""" fit_metadata: Dict[str, Any] = { "model_name": getattr(model.cfg, "model_name", None), "model_revision": _get_model_revision(model), @@ -1894,11 +1824,6 @@ def _build_fit_metadata( "skip_first_positions": options.skip_first_positions, "transformer_lens_fit": True, } - extra_metadata = dict(estimator_metadata or {}) - overlapping = sorted(set(fit_metadata).intersection(extra_metadata)) - if overlapping: - raise ValueError(f"estimator metadata cannot override common fit provenance: {overlapping}") - fit_metadata.update(extra_metadata) reserved = sorted(set(fit_metadata).intersection(metadata or {})) if reserved: raise ValueError(f"metadata cannot override fit provenance keys: {reserved}") @@ -1924,8 +1849,7 @@ def _get_model_revision(model: Any) -> Optional[str]: def _require_raw_bridge(model: Any, *, estimator: str) -> None: """Require the causal raw-Bridge contract used by fit and readout. - ``estimator`` names the calling estimator in diagnostics, since this guard - is shared by every matrix-lens estimator. + ``estimator`` names the calling class or operation in diagnostics. """ from transformer_lens.model_bridge import TransformerBridge @@ -2063,11 +1987,11 @@ def _unit_rows(vectors: torch.Tensor, *, layer: int) -> torch.Tensor: def _make_intervention_hook( - transform: Callable[[torch.Tensor], torch.Tensor], + transform: Callable[[torch.Tensor, List[int]], torch.Tensor], positions: Optional[Sequence[int]], d_model: int, ) -> Callable[..., torch.Tensor]: - """Apply a transform with shared position, dtype, and device hardening.""" + """Apply ``transform(selected, positions)`` with shared position/dtype/device hardening.""" requested = None if positions is None else tuple(positions) if requested == (): raise ValueError("positions must contain at least one index") @@ -2079,7 +2003,7 @@ def hook_fn( _validate_residual_activation(activation, d_model=d_model, hook_name=hook_name) normalized = _normalize_positions(requested, activation.shape[1]) selected = activation if requested is None else activation[:, normalized, :] - transformed = transform(selected) + transformed = transform(selected, normalized) if transformed.shape != selected.shape: raise ValueError( f"intervention returned shape {tuple(transformed.shape)}, " @@ -2095,41 +2019,17 @@ def hook_fn( return hook_fn -def _unembed( - model: Any, residual: Float[torch.Tensor, "pos d_model"] -) -> Float[torch.Tensor, "pos d_vocab"]: - """Apply the model's own final norm, unembedding, logit scale, and soft cap. - - The norm/unembed components contract on ``[batch, pos, d_model]``, so the - position rows are passed through with a singleton batch axis. The residual - is moved to the unembedding's device for sharded/multi-GPU models. - """ - unembed_weight = model.W_U - compute_dtype = unembed_weight.dtype - batched = residual.to(device=unembed_weight.device, dtype=compute_dtype).unsqueeze(0) - logits = model.unembed(model.ln_final(batched)).squeeze(0) - return model.adapter.apply_output_logits_transform(logits).float() - - -def _to_token_ids(model: Any, tokens: Union[TokenInput, Sequence[TokenInput]]) -> List[int]: - """Convert token strings / ids into a list of single-token ids.""" - if isinstance(tokens, (str, int)): - tokens = [tokens] - ids: List[int] = [] - for token in tokens: - if isinstance(token, str): - ids.append(model.to_single_token(token)) - elif isinstance(token, bool): - raise ValueError(f"token {token!r} must be a string or integer token id, not bool") - else: - ids.append(int(token)) - if not ids: - raise ValueError("tokens must contain at least one token") - d_vocab = model.W_U.shape[1] - invalid = [token_id for token_id in ids if not 0 <= token_id < d_vocab] - if invalid: - raise ValueError(f"token ids {invalid} out of range for vocabulary size {d_vocab}") - return ids +def _distinct_ids( + model: Any, source_token: ConceptToken, target_token: ConceptToken, *, operation: str +) -> Tuple[int, int]: + """Resolve a source/target pair, refusing one that names the same token twice.""" + source_id, target_id = to_token_ids(model, [source_token, target_token]) + if source_id == target_id: + raise ValueError( + "source_token and target_token resolve to the same token id; " + f"a coordinate {operation} would be a silent no-op" + ) + return source_id, target_id def _validate_residual_activation( @@ -2213,8 +2113,8 @@ def _jacobian_for_prompt( graph there. The backward pass is taken through ``backward_provider`` rather than calling - ``torch.autograd.grad`` directly, so the capture / cotangent-batching / - averaging mechanics are shared by any estimator. The ordinary J-lens path + ``torch.autograd.grad`` directly, so an alternate estimator would reuse the + capture / cotangent-batching / averaging mechanics. The ordinary J-lens path passes :func:`_ordinary_vjp`, which reproduces the original numerics exactly. """ d_model = model.cfg.d_model @@ -2287,15 +2187,14 @@ def _fit_transport_matrices( ) -> Tuple[Dict[int, Float[torch.Tensor, "d_model d_model"]], int]: """Estimator-independent J-lens drive loop. - Owns the mechanics shared by every estimator: the frozen-parameter - lifecycle, the per-prompt forward/backward accumulation (source/target - residual capture, one-hot cotangent batching, ``dim_batch`` chunking, - valid-position selection, and source-position averaging all live in - :func:`_jacobian_for_prompt`), the prompt-accumulation running sum, and the - final division into per-prompt means. Only the backward step varies: it is - taken through ``backward_provider``, so an alternate estimator reuses this - loop unchanged. Callers own input validation, provenance, and lens - construction. + Owns the fit mechanics: the frozen-parameter lifecycle, the per-prompt + forward/backward accumulation (source/target residual capture, one-hot + cotangent batching, ``dim_batch`` chunking, valid-position selection, and + source-position averaging all live in :func:`_jacobian_for_prompt`), the + prompt-accumulation running sum, and the final division into per-prompt + means. Only the backward step is pluggable: it is taken through + ``backward_provider``, so an alternate estimator would reuse this loop + unchanged. Callers own input validation, provenance, and lens construction. Args: model: A raw ``TransformerBridge`` whose parameters are frozen for the diff --git a/transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark.py b/transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark.py deleted file mode 100644 index f45662af23..0000000000 --- a/transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark.py +++ /dev/null @@ -1,831 +0,0 @@ -"""Causal coordinate-swap benchmark for ``JacobianLens.coordinate_patch_hooks``. - -Measures whether an anchored J-space coordinate edit installed live inside a forward pass -via ``coordinate_patch_hooks`` causes a directional change in model output, under three -controls: baseline-capability filtering (only intervene on prompts the model already -answers correctly), displacement-matched random-atom controls (isolate "this concept -mattered" from "any edit of similar magnitude would have mattered"), and exact -Clopper-Pearson uncertainty on every reported rate. Each trial draws its control arm under -several seeds and records every draw, so the control arm's own spread is visible rather -than folded into a single number. - -This module is layered bottom-up: the model-free prompt corpus and rank/margin metric, -baseline-capability filtering, displacement-matched control-token selection, a per-trial -runner that wires the first three together with real ``coordinate_patch_hooks`` calls -against a live model, and exact Clopper-Pearson confidence intervals plus a versioned JSON -artifact schema that fingerprints both the protocol and the result blocks and embeds the -corpus definition. Generating the frozen artifact consumed by -``demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb`` is a separate entry point: -``transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark_cli``. -""" - -from __future__ import annotations - -import hashlib -import itertools -import json -from collections import Counter -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import ( - Any, - Container, - Dict, - Iterator, - List, - Literal, - Mapping, - MutableMapping, - Optional, - Sequence, - Tuple, -) - -import torch - -from transformer_lens.tools.analysis._model_state import require_eval_mode -from transformer_lens.tools.analysis.jacobian_lens import DEFAULT_K, JacobianLens -from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( - JSpaceDecomposition, -) - - -@dataclass(frozen=True) -class FunctionSpec: - """A templated prompt function evaluated over a shared set of concepts. - - ``template`` takes a single ``{arg}`` placeholder, e.g. ``"The capital of {arg} is"``. - ``answers`` maps each concept to its answer word under this function, e.g. - ``{"France": "Paris"}``. - """ - - name: str - template: str - answers: Dict[str, str] - - -@dataclass(frozen=True) -class BenchmarkCorpus: - """A named set of concepts and the prompt functions evaluated over them.""" - - name: str - concepts: Sequence[str] - functions: Sequence[FunctionSpec] - - -@dataclass(frozen=True) -class PromptTrialSpec: - """One (function, ordered source/target concept pair) prompt instance.""" - - function: str - source: str - target: str - prompt: str - source_answer: str - target_answer: str - - -def iter_prompt_trials(corpus: BenchmarkCorpus) -> Iterator[PromptTrialSpec]: - """Yields one spec per (function, ordered source/target concept pair). - - Ordered pairs are every element of ``itertools.permutations(corpus.concepts, 2)``, the - same cross product Jacobian_Lens_Demo.ipynb's country benchmark already uses, now as - tested library code instead of a notebook cell. - """ - for function in corpus.functions: - for source, target in itertools.permutations(corpus.concepts, 2): - yield PromptTrialSpec( - function=function.name, - source=source, - target=target, - prompt=function.template.format(arg=source), - source_answer=function.answers[source], - target_answer=function.answers[target], - ) - - -def corpus_definition(corpus: BenchmarkCorpus) -> Dict[str, Any]: - """Serializes a corpus into the JSON shape embedded in the protocol manifest. - - The prompts and answers decide which prompts are baseline-capable and how every - condition is scored, so they belong inside the fingerprinted manifest rather than - travelling as a bare name. Key order is normalized so the same corpus always - serializes identically. - """ - return { - "name": corpus.name, - "concepts": list(corpus.concepts), - "functions": [ - { - "name": function.name, - "template": function.template, - "answers": dict(function.answers), - } - for function in corpus.functions - ], - } - - -@dataclass(frozen=True) -class AnswerMetrics: - """Rank/margin/tie metrics for one target token against one next-token logit vector.""" - - top1_token_id: int - target_rank: int - target_is_top1: bool - target_tied_for_top: bool - target_logit_margin: float - - -def compute_answer_metrics(logits: torch.Tensor, target_token_id: int) -> AnswerMetrics: - """Computes rank/margin/tie metrics for ``target_token_id`` against ``logits``. - - Ports Jacobian_Lens_Demo.ipynb's ``_target_metrics`` cell verbatim (arithmetic - unchanged, only renamed and restructured into a dataclass), so results stay directly - comparable with that notebook's already-reviewed success/rank definitions. - """ - if logits.ndim != 1 or logits.numel() < 2: - raise ValueError("expected one-dimensional next-token logits") - if not 0 <= target_token_id < logits.shape[0]: - raise ValueError("target token id is outside the vocabulary") - if not torch.isfinite(logits).all(): - raise ValueError("logits must be finite") - - target_logit = logits[target_token_id] - top_logit = logits.max() - top1_token_id = int(logits.argmax().item()) - top_logit_tie_count = int((logits == top_logit).sum().item()) - competitors = torch.cat((logits[:target_token_id], logits[target_token_id + 1 :])) - return AnswerMetrics( - top1_token_id=top1_token_id, - target_rank=int((logits > target_logit).sum().item()) + 1, - target_is_top1=top1_token_id == target_token_id, - target_tied_for_top=bool(target_logit == top_logit) and top_logit_tie_count > 1, - target_logit_margin=float((target_logit - competitors.max()).item()), - ) - - -@dataclass(frozen=True) -class BaselineRecord: - """A source prompt's own-answer metrics under the unperturbed baseline forward pass.""" - - function: str - source: str - prompt: str - metrics: AnswerMetrics - - -def filter_baseline_capable( - baselines: Sequence[BaselineRecord], -) -> Tuple[List[BaselineRecord], List[BaselineRecord]]: - """Splits baseline records into (capable, excluded) prompts. - - A prompt is baseline-capable when the model's own deterministic argmax already matches - the source's answer (``metrics.target_is_top1``) and that answer is not tied for the - maximum (``metrics.target_tied_for_top``); only such prompts are eligible for later - intervention trials, so an edit's effect is never measured against a prompt the - unperturbed model already gets wrong. A tied-for-top baseline is treated as not capable: - ``argmax`` returns the lowest index on an exact tie, so admitting ties would let token-id - order decide whether a prompt enters the trial set. Order-preserving in both outputs; - never mutates ``baselines``. - """ - capable = [ - record - for record in baselines - if record.metrics.target_is_top1 and not record.metrics.target_tied_for_top - ] - excluded = [ - record - for record in baselines - if not record.metrics.target_is_top1 or record.metrics.target_tied_for_top - ] - return capable, excluded - - -def _control_generator(seed: int) -> torch.Generator: - """Builds the CPU generator used to pick a control token. - - The generator is pinned to CPU rather than the dictionary's device. ``torch.randint`` - infers its output device from the generator, so a device-local generator would make the - same seed select a different control token per device and the frozen artifact would stop - being reproducible. A CPU generator also avoids the device mismatch that raises on - accelerators, where ``torch.randint`` allocates on CPU while a device-local generator - expects its own device. - """ - return torch.Generator(device="cpu").manual_seed(seed) - - -def select_displacement_matched_control_token( - dictionary: torch.Tensor, - source_token_id: int, - target_token_id: int, - excluded_ids: Container[int], - active_support: Container[int], - *, - tolerance: float = 0.1, - seed: int = 0, -) -> Optional[int]: - """Deterministically selects a displacement-matched control token id. - - The real condition perturbs the activation by ``c_src * (a_target - a_source)``, so the - edit's size is set by ``||a_target - a_source||``. Matching atom norms leaves that size - free: two atoms of equal norm can sit at very different distances from ``a_source``, and - the control edit then runs at a different magnitude from the real one. A candidate token - id ``t`` therefore qualifies when - ``abs(||a_t - a_source|| - ||a_target - a_source||) <= tolerance * ||a_target - a_source||`` - and ``t`` is not ``source_token_id``, not ``target_token_id``, not a member of - ``excluded_ids``, and not a member of ``active_support`` -- an atom already carrying the - source coordinate is not a clean control. - - One qualifying candidate is picked with a seeded CPU ``torch.Generator`` so the same - ``seed`` always yields the same control token on every device. Returns ``None`` when no - candidate qualifies; the tolerance is never silently widened and selection never falls - back to the globally nearest atom. Returning ``None`` rather than raising lets the caller - record an empty pool as its own skip status instead of aborting the sweep. - """ - if dictionary.ndim != 2: - raise ValueError( - f"dictionary must be 2-D [num_atoms, d_model], got shape {tuple(dictionary.shape)}" - ) - atoms = dictionary.float() - source_atom = atoms[source_token_id] - target_displacement = float((atoms[target_token_id] - source_atom).norm()) - displacements = (atoms - source_atom).norm(dim=1) - within_tolerance = (displacements - target_displacement).abs() <= ( - tolerance * target_displacement - ) - candidates = [ - token_id - for token_id in range(dictionary.shape[0]) - if token_id != source_token_id - and token_id != target_token_id - and token_id not in excluded_ids - and token_id not in active_support - and bool(within_tolerance[token_id]) - ] - if not candidates: - return None - generator = _control_generator(seed) - pick = int(torch.randint(len(candidates), (1,), generator=generator).item()) - return candidates[pick] - - -def _resolve_answer_token_id(model: Any, word: str) -> int: - """Resolves a concept or answer word to the token id it maps to as a continuation. - - Prepends a leading space so the id matches how the word tokenizes when it follows - other prompt text (e.g. " Paris", not "Paris"), the same convention the corpus - templates themselves rely on. - """ - return int(model.to_single_token(f" {word}")) - - -@dataclass(frozen=True) -class ControlDraw: - """One seeded control-arm draw: the token it selected and how that condition scored.""" - - seed: int - token_id: int - metrics: AnswerMetrics - - -@dataclass(frozen=True) -class TrialResult: - """One ``(function, source, target, layer)`` causal-swap trial's full record. - - ``control_token_id`` and ``control_target_metrics`` mirror the first entry of - ``control_draws`` so a single-draw reader sees the same fields it always did. The full - list carries the control arm's own spread across seeds, which one draw cannot measure. - """ - - function: str - source: str - target: str - layer: int - status: Literal["ok", "skipped_source_inactive", "skipped_no_control_token"] - baseline: AnswerMetrics - real_target_metrics: Optional[AnswerMetrics] - control_token_id: Optional[int] - control_target_metrics: Optional[AnswerMetrics] - control_draws: List[ControlDraw] - error: Optional[str] - - -def run_causal_swap_trial( - lens: JacobianLens, - model: Any, - trial_spec: PromptTrialSpec, - layer: int, - *, - decomposition_cache: Optional[MutableMapping[Tuple[int, int, int], JSpaceDecomposition]] = None, - control_tolerance: float = 0.1, - control_seeds: Sequence[int] = (0,), - alpha: float = 1.0, - k: int = DEFAULT_K, -) -> TrialResult: - """Runs one causal-swap trial: baseline, then real and control coordinate-patch conditions. - - A baseline forward pass scores the prompt's own (unperturbed) source answer. A - displacement-matched control token is then selected from ``layer``'s lens-vector - dictionary, excluding the source token, the real target token, both prompts' answer - tokens, and every atom in the layer's active support. The real and control conditions - each install ``coordinate_patch_hooks`` at ``layer`` and position ``-1``, sharing one - ``decomposition_cache`` so only the first of the two performs the vocabulary-scale - decomposition; both are scored against the target's own answer token, so a real-vs-control - gap isolates "swapping toward this concept mattered" from "any edit of this magnitude - would have mattered." - - The control arm is drawn once per entry in ``control_seeds``. A single draw leaves the - control arm's own variance unmeasured, so a reader cannot tell a real-vs-control gap from - draw variation; the per-seed results are carried on ``control_draws``. Every draw reuses - the same ``decomposition_cache``, so only the first pays the vocabulary-scale solve. - - Two conditions produce a skip rather than a result. When the source is not in the layer's - active support, the trial is recorded with ``status="skipped_source_inactive"``. When no - displacement-matched control token survives the exclusions, the trial is recorded with - ``status="skipped_no_control_token"``. Both carry a diagnostic message in ``error``. - - The active-support precondition is checked up front, against the same decomposition the - hooks consume, rather than inferred from a caught exception. ``coordinate_patch_hooks`` - raises plain ``ValueError`` for several unrelated protocol faults (a same-id source and - target, an out-of-vocabulary answer, non-finite logits) and defines no narrower subclass - to discriminate on, so catching ``ValueError`` would file those as skips and drop them - from both denominators. Every other error therefore propagates unchanged. - ``coordinate_patch_hooks``'s own ``UserWarning``s (both the per-call install notice and - any solver-side conditioning warning) are not suppressed here and propagate to the caller - unchanged. - - Args: - lens: The fitted lens. - model: The model to run trials against. - trial_spec: The prompt, source/target concepts, and their answer words. - layer: The single layer to patch at. - decomposition_cache: Shared cache passed to both the real and control - ``coordinate_patch_hooks`` calls. A fresh cache is used if omitted. - control_tolerance: Relative tolerance for the displacement-matched control token. - control_seeds: Seeds to draw the control arm under. Each seed selects its own - control token and contributes one :class:`ControlDraw`; the first seed's draw - is mirrored onto ``control_token_id`` and ``control_target_metrics``. - alpha: Interpolation strength forwarded to ``coordinate_patch_hooks``. - k: Sparse-solver upper bound forwarded to ``coordinate_patch_hooks``. - - Returns: - The trial's :class:`TrialResult`. - - Raises: - ValueError: If the source and target concepts resolve to the same token id, which - would make the coordinate patch a silent no-op, if ``control_seeds`` is empty, or - if the model is in training mode. - """ - if decomposition_cache is None: - decomposition_cache = {} - if not control_seeds: - raise ValueError("control_seeds must contain at least one seed") - tokens = model.to_tokens(trial_spec.prompt) - source_id = _resolve_answer_token_id(model, trial_spec.source) - target_id = _resolve_answer_token_id(model, trial_spec.target) - source_answer_id = _resolve_answer_token_id(model, trial_spec.source_answer) - target_answer_id = _resolve_answer_token_id(model, trial_spec.target_answer) - if source_id == target_id: - raise ValueError( - f"source and target resolve to the same token id {source_id}; a coordinate " - "patch would be a silent no-op" - ) - require_eval_mode(model, operation="run_causal_swap_trial()") - - with torch.no_grad(): - baseline_logits = model(tokens)[0, -1].float() - baseline_metrics = compute_answer_metrics(baseline_logits, source_answer_id) - - dictionary = lens.lens_vector_dictionary(model, layer) - decomposition = lens.decompose(model, trial_spec.prompt, layer=layer, position=-1, k=k) - # Seed the shared cache under the hook's own key so the first firing is a cache hit and - # the up-front solve is not repeated. The hook normalizes -1 against the activation's - # sequence length, which equals the tokenized prompt length for a single-example pass. - decomposition_cache[(layer, 0, tokens.shape[1] - 1)] = decomposition - if source_id not in decomposition.support.tolist(): - return TrialResult( - function=trial_spec.function, - source=trial_spec.source, - target=trial_spec.target, - layer=layer, - status="skipped_source_inactive", - baseline=baseline_metrics, - real_target_metrics=None, - control_token_id=None, - control_target_metrics=None, - control_draws=[], - error=( - f"source token id {source_id} is not in layer {layer}'s active support " - f"for this prompt" - ), - ) - control_token_ids: List[int] = [] - for seed in control_seeds: - token_id = select_displacement_matched_control_token( - dictionary, - source_id, - target_id, - excluded_ids={source_answer_id, target_answer_id}, - active_support=decomposition.support.tolist(), - tolerance=control_tolerance, - seed=seed, - ) - if token_id is None: - return TrialResult( - function=trial_spec.function, - source=trial_spec.source, - target=trial_spec.target, - layer=layer, - status="skipped_no_control_token", - baseline=baseline_metrics, - real_target_metrics=None, - control_token_id=None, - control_target_metrics=None, - control_draws=[], - error=( - "no displacement-matched control token within relative tolerance " - f"{control_tolerance} of ||a_target - a_source|| for seed {seed}" - ), - ) - control_token_ids.append(token_id) - - def _condition_metrics(condition_target_id: int) -> AnswerMetrics: - hooks = lens.coordinate_patch_hooks( - model, - source_id, - condition_target_id, - layers=[layer], - positions=[-1], - decomposition_cache=decomposition_cache, - k=k, - alpha=alpha, - ) - with model.hooks(fwd_hooks=hooks), torch.no_grad(): - condition_logits = model(tokens)[0, -1].float() - return compute_answer_metrics(condition_logits, target_answer_id) - - real_metrics = _condition_metrics(target_id) - control_draws = [ - ControlDraw( - seed=seed, - token_id=token_id, - metrics=_condition_metrics(token_id), - ) - for seed, token_id in zip(control_seeds, control_token_ids) - ] - - return TrialResult( - function=trial_spec.function, - source=trial_spec.source, - target=trial_spec.target, - layer=layer, - status="ok", - baseline=baseline_metrics, - real_target_metrics=real_metrics, - control_token_id=control_draws[0].token_id, - control_target_metrics=control_draws[0].metrics, - control_draws=control_draws, - error=None, - ) - - -def run_causal_swap_benchmark( - lens: JacobianLens, - model: Any, - corpus: BenchmarkCorpus, - layers: Sequence[int], - **trial_kwargs: Any, -) -> Tuple[List[TrialResult], List[BaselineRecord]]: - """Runs the full causal-swap sweep: baseline filtering, then every surviving trial. - - Each ``(function, source)`` prompt's baseline is computed once -- it does not depend on - ``layer`` -- and only prompts that survive :func:`filter_baseline_capable` proceed to - :func:`run_causal_swap_trial`, once per remaining ``layer``. Each trial gets its own fresh - ``decomposition_cache``: the cache key is ``(layer, batch_idx, position)``, which collides - across different prompts run as independent single-example forward passes, so a cache may - only be reused within one trial's real/control pair, never across trials. - - Args: - lens: The fitted lens. - model: The model to run trials against. - corpus: The prompt corpus to sweep. - layers: Layers to sweep as an independent trial dimension. - **trial_kwargs: Forwarded to :func:`run_causal_swap_trial` (``control_tolerance``, - ``control_seeds``, ``alpha``, ``k``; ``decomposition_cache`` is not accepted here - since each trial always uses its own). - - Returns: - ``(trials, excluded_baselines)``. - """ - all_specs = list(iter_prompt_trials(corpus)) - require_eval_mode(model, operation="run_causal_swap_benchmark()") - specs_by_prompt: Dict[Tuple[str, str], List[PromptTrialSpec]] = {} - baselines: List[BaselineRecord] = [] - for spec in all_specs: - prompt_key = (spec.function, spec.source) - if prompt_key not in specs_by_prompt: - specs_by_prompt[prompt_key] = [] - tokens = model.to_tokens(spec.prompt) - with torch.no_grad(): - baseline_logits = model(tokens)[0, -1].float() - source_answer_id = _resolve_answer_token_id(model, spec.source_answer) - baselines.append( - BaselineRecord( - function=spec.function, - source=spec.source, - prompt=spec.prompt, - metrics=compute_answer_metrics(baseline_logits, source_answer_id), - ) - ) - specs_by_prompt[prompt_key].append(spec) - - capable, excluded = filter_baseline_capable(baselines) - capable_prompt_keys = {(record.function, record.source) for record in capable} - - trials: List[TrialResult] = [] - for layer in layers: - for prompt_key, specs in specs_by_prompt.items(): - if prompt_key not in capable_prompt_keys: - continue - for spec in specs: - trials.append( - run_causal_swap_trial( - lens, - model, - spec, - layer, - **trial_kwargs, - ) - ) - return trials, excluded - - -@dataclass(frozen=True) -class SuccessRateInterval: - """An exact Clopper-Pearson confidence interval around a success rate. - - ``n_trials`` and ``n_successes`` are the counts the interval was computed from, so a - reader can see the denominator behind the bounds rather than inferring it. - """ - - point_estimate: float - ci_low: float - ci_high: float - n_trials: int - n_successes: int - confidence: float - - -# Bisection depth for the Clopper-Pearson bounds. Each step halves the bracket, so 200 steps -# resolve the bound far below the precision any reported rate needs. -_CLOPPER_PEARSON_STEPS = 200 - - -def _binomial_cdf(k: int, n: int, p: float) -> float: - """Exact ``P(X <= k)`` for ``X ~ Binomial(n, p)``. - - Accumulates the pmf by recurrence (``term_{i} = term_{i-1} * (n - i + 1) / i * p / (1 - p)``) - rather than forming ``comb(n, i)`` and ``p ** i`` separately, which overflow for the - trial counts this benchmark reports. - """ - if k < 0: - return 0.0 - if k >= n: - return 1.0 - if p <= 0.0: - return 1.0 - if p >= 1.0: - return 0.0 - term = (1.0 - p) ** n - total = term - odds = p / (1.0 - p) - for i in range(1, k + 1): - term *= (n - i + 1) / i * odds - total += term - return total - - -def _clopper_pearson_bound(n_successes: int, n_trials: int, tail: float, *, upper: bool) -> float: - """Solves one Clopper-Pearson bound by bisecting the exact binomial CDF. - - The lower bound solves ``P(X >= k) = tail`` and the upper bound solves ``P(X <= k) = - tail``. ``P(X >= k)`` rises with ``p`` while ``P(X <= k)`` falls, so the two bounds - bracket from opposite sides; reversing that collapses every interval to ``[0, 1]``. - """ - if upper and n_successes == n_trials: - return 1.0 - if not upper and n_successes == 0: - return 0.0 - low, high = 0.0, 1.0 - for _ in range(_CLOPPER_PEARSON_STEPS): - midpoint = (low + high) / 2.0 - if upper: - # P(X <= k) decreases in p, so a probability above the tail puts the bound below - # the midpoint. - if _binomial_cdf(n_successes, n_trials, midpoint) > tail: - low = midpoint - else: - high = midpoint - else: - # P(X >= k) increases in p, so a probability above the tail puts the bound above - # the midpoint. - if 1.0 - _binomial_cdf(n_successes - 1, n_trials, midpoint) > tail: - high = midpoint - else: - low = midpoint - return (low + high) / 2.0 - - -def success_rate_ci( - successes: Sequence[bool], - *, - confidence: float = 0.95, -) -> SuccessRateInterval: - """Computes an exact Clopper-Pearson confidence interval for a success rate. - - A percentile bootstrap cannot express uncertainty about an all-failure or all-success - sample: every resample of an all-zero vector is all zeros, so the interval collapses to - ``[0, 0]`` and reports the same certainty for one trial as for a thousand. The exact - interval instead widens as the trial count shrinks, giving ``[0.0, 0.459]`` for zero - successes out of six. - - Deterministic by construction: there is no resampling and no seed. - - Raises: - ValueError: If ``successes`` is empty. - """ - if len(successes) == 0: - raise ValueError("successes must be a non-empty sequence") - n_trials = len(successes) - n_successes = sum(1 for success in successes if success) - tail = (1.0 - confidence) / 2.0 - return SuccessRateInterval( - point_estimate=n_successes / n_trials, - ci_low=_clopper_pearson_bound(n_successes, n_trials, tail, upper=False), - ci_high=_clopper_pearson_bound(n_successes, n_trials, tail, upper=True), - n_trials=n_trials, - n_successes=n_successes, - confidence=confidence, - ) - - -_REQUIRED_MANIFEST_FIELDS = ( - "model_id", - "model_revision", - "lens_repo", - "lens_file", - "lens_revision", - "corpus_name", - "corpus", - "corpus_repo", - "corpus_path", - "corpus_revision", - "layers_swept", - "alpha", - "k", - "control_tolerance", - "control_seeds", - "success_definition", - "baseline_definition", - "rank_definition", -) - - -def build_protocol_manifest(**fields: Any) -> Dict[str, Any]: - """Assembles a protocol manifest, requiring the benchmark's fixed field set. - - Requires at least :data:`_REQUIRED_MANIFEST_FIELDS`, the same key set (and, where they - overlap, the same string values) as ``Jacobian_Lens_Demo.ipynb``'s existing - ``protocol_manifest`` cell. Beyond that cell's fields the manifest carries the full - corpus definition (``corpus``) and the repo/path/revision triple it was taken from - (``corpus_repo``, ``corpus_path``, ``corpus_revision``), so the prompts and answers - that decide capability and scoring enter the fingerprint instead of a bare name. - - Raises: - ValueError: If any required field is missing, naming the missing field(s). - """ - missing = [name for name in _REQUIRED_MANIFEST_FIELDS if name not in fields] - if missing: - raise ValueError(f"protocol manifest is missing required field(s): {', '.join(missing)}") - return dict(fields) - - -def fingerprint_manifest(manifest: Dict[str, Any]) -> str: - """Fingerprints a protocol manifest with the recipe ``Jacobian_Lens_Demo.ipynb`` uses.""" - return hashlib.sha256( - json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() - ).hexdigest() - - -def results_fingerprint(artifact: Mapping[str, Any]) -> str: - """Fingerprints an artifact's result blocks with the manifest recipe. - - ``protocol_fingerprint`` covers only the manifest, so an artifact whose trials, - excluded baselines, or confidence-interval blocks were rewritten would still load - clean and print the same provenance hash. Hashing those blocks separately catches - that tampering while leaving the manifest recipe and its stored value untouched. - """ - return hashlib.sha256( - json.dumps( - { - "trials": artifact["trials"], - "excluded_baselines": artifact["excluded_baselines"], - "real_success_ci": artifact["real_success_ci"], - "control_success_ci": artifact["control_success_ci"], - }, - sort_keys=True, - separators=(",", ":"), - ).encode() - ).hexdigest() - - -SCHEMA_VERSION = 2 - - -def serialize_artifact( - manifest: Dict[str, Any], - trials: Sequence[TrialResult], - excluded_baselines: Sequence[BaselineRecord], - real_ci: SuccessRateInterval, - control_ci: SuccessRateInterval, -) -> Dict[str, Any]: - """Assembles a JSON-serializable artifact dict from a benchmark run's results. - - ``n_independent_prompts`` records how many distinct ``(function, source)`` prompts the - pooled rate rests on. Pooling trials as independent draws overstates the evidence when - several trials share one prompt, so the count travels with the rate. - - ``layers_executed`` lists the sorted distinct layers that produced an ``ok`` trial, and - ``trial_status_counts`` counts trials per status, so a reader can see how many of the - swept layers contributed nothing and how many trials were skipped rather than executed. - """ - artifact: Dict[str, Any] = { - "schema_version": SCHEMA_VERSION, - "protocol_manifest": manifest, - "protocol_fingerprint": fingerprint_manifest(manifest), - "trials": [asdict(trial) for trial in trials], - "excluded_baselines": [asdict(record) for record in excluded_baselines], - "real_success_ci": asdict(real_ci), - "control_success_ci": asdict(control_ci), - "n_independent_prompts": len( - {(trial.function, trial.source) for trial in trials if trial.status == "ok"} - ), - "layers_executed": sorted({trial.layer for trial in trials if trial.status == "ok"}), - "trial_status_counts": dict(Counter(trial.status for trial in trials)), - } - artifact["results_fingerprint"] = results_fingerprint(artifact) - return artifact - - -_REQUIRED_ARTIFACT_FIELDS = ( - "schema_version", - "protocol_manifest", - "protocol_fingerprint", - "trials", - "excluded_baselines", - "real_success_ci", - "control_success_ci", - "n_independent_prompts", - "layers_executed", - "trial_status_counts", - "results_fingerprint", -) - - -def load_artifact(path: Path) -> Dict[str, Any]: - """Reads and validates a frozen artifact produced by :func:`serialize_artifact`. - - Validates that every required top-level key is present, that ``schema_version`` - matches :data:`SCHEMA_VERSION`, that ``protocol_fingerprint`` matches a fresh - :func:`fingerprint_manifest` of the loaded ``protocol_manifest``, and that - ``results_fingerprint`` matches a fresh :func:`results_fingerprint` of the loaded - result blocks -- catching a hand-edited or corrupted artifact rather than trusting - the stored fingerprints blindly. - - Raises: - ValueError: Naming the missing or mismatched field. - """ - artifact = json.loads(Path(path).read_text()) - missing = [name for name in _REQUIRED_ARTIFACT_FIELDS if name not in artifact] - if missing: - raise ValueError(f"artifact is missing required field(s): {', '.join(missing)}") - if artifact["schema_version"] != SCHEMA_VERSION: - raise ValueError( - f"artifact schema_version {artifact['schema_version']!r} does not match the " - f"expected {SCHEMA_VERSION!r}" - ) - expected_fingerprint = fingerprint_manifest(artifact["protocol_manifest"]) - if artifact["protocol_fingerprint"] != expected_fingerprint: - raise ValueError( - "artifact protocol_fingerprint does not match a freshly computed fingerprint of " - "its protocol_manifest (the manifest may have been hand-edited or corrupted)" - ) - expected_results_fingerprint = results_fingerprint(artifact) - if artifact["results_fingerprint"] != expected_results_fingerprint: - raise ValueError( - "artifact results_fingerprint does not match a freshly computed fingerprint of " - "its trials, excluded_baselines, and confidence-interval blocks (they may have " - "been hand-edited or corrupted)" - ) - return artifact diff --git a/transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark_cli.py b/transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark_cli.py deleted file mode 100644 index 8ff85ee0cc..0000000000 --- a/transformer_lens/tools/analysis/jacobian_lens_causal_swap_benchmark_cli.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Artifact-generation entry point for the causal coordinate-swap benchmark. - -Kept separate from -``transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark`` because the analysis -package's ``__init__`` imports that module: running it as a script would execute it twice and -``runpy`` would warn. This module is not imported by the package, so ``python -m`` runs it -once. - -Reproduce the frozen artifact with (no ``HF_TOKEN`` needed -- GPT-2 is not gated):: - - uv run python -m transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark_cli -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import List, Optional, Sequence, Tuple - -from transformer_lens.tools.analysis.jacobian_lens import DEFAULT_K, JacobianLens -from transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark import ( - BenchmarkCorpus, - FunctionSpec, - build_protocol_manifest, - corpus_definition, - run_causal_swap_benchmark, - serialize_artifact, - success_rate_ci, -) - -_COUNTRY_CORPUS = BenchmarkCorpus( - name="countries", - concepts=("France", "Canada", "China", "Egypt"), - functions=( - FunctionSpec( - name="capital", - template="The capital of {arg} is the city of", - answers={"France": "Paris", "Canada": "Ottawa", "China": "Beijing", "Egypt": "Cairo"}, - ), - FunctionSpec( - name="language", - template="Most people in {arg} speak", - answers={ - "France": "French", - "Canada": "English", - "China": "Chinese", - "Egypt": "Arabic", - }, - ), - FunctionSpec( - name="continent", - template="{arg} is a country on the continent of", - answers={"France": "Europe", "Canada": "North", "China": "Asia", "Egypt": "Africa"}, - ), - FunctionSpec( - name="currency", - template="The single-word name for the currency now used in {arg} is the", - answers={"France": "Euro", "Canada": "Dollar", "China": "Yuan", "Egypt": "Pound"}, - ), - ), -) - -_GPT2_LENS_REPO = "neuronpedia/jacobian-lens" -_GPT2_LENS_FILE = "gpt2-small/jlens/Salesforce-wikitext/gpt2_jacobian_lens.pt" -_GPT2_LENS_REVISION = "a4114d7752d11eb546e6cf372213d7e75526d3a1" -# The corpus mirrors the country config ``Jacobian_Lens_Demo.ipynb`` pins, so it reuses that -# notebook's repo/path/revision triple rather than inventing a second provenance scheme. -_CORPUS_REPO = "anthropics/jacobian-lens" -_CORPUS_PATH = "data/experiments/flexible-generalization.json" -_CORPUS_REVISION = "581d398613e5602a5af361e1c34d3a92ea82ba8e" -_DEFAULT_CONTROL_SEEDS = (0, 1, 2, 3, 4) -_DEFAULT_ARTIFACT_PATH = ( - Path(__file__).resolve().parents[3] - / "demos" - / "data" - / "jacobian_lens_causal_swap_benchmark_gpt2.json" -) - - -def _parse_seed_list(value: str) -> Tuple[int, ...]: - """Parses a comma-separated seed list for ``--control-seeds``.""" - seeds = tuple(int(part) for part in value.split(",") if part.strip()) - if not seeds: - raise ValueError("at least one control seed is required") - return seeds - - -def _build_parser() -> argparse.ArgumentParser: - """Builds the CLI argument parser.""" - parser = argparse.ArgumentParser( - description="Generates the frozen GPT-2 causal-swap benchmark artifact." - ) - parser.add_argument("--output", type=Path, default=_DEFAULT_ARTIFACT_PATH) - parser.add_argument("--control-tolerance", type=float, default=0.1) - parser.add_argument( - "--control-seeds", - type=_parse_seed_list, - default=_DEFAULT_CONTROL_SEEDS, - help="comma-separated seeds to draw the control arm under (default: %(default)s)", - ) - parser.add_argument("--alpha", type=float, default=1.0) - parser.add_argument("--k", type=int, default=DEFAULT_K) - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> None: - """Generates the frozen GPT-2 causal-swap benchmark artifact. - - Loads the published GPT-2-small lens (the same artifact - ``tests/integration/test_jacobian_lens.py`` uses), runs the reused country corpus over - every one of the lens's fitted source layers, and writes the versioned, fingerprinted JSON - artifact consumed by ``demos/Jacobian_Lens_Coordinate_Patch_Benchmark_Demo.ipynb``. - """ - import torch - - from transformer_lens.model_bridge import TransformerBridge - - args = _build_parser().parse_args(argv) - - device = "cuda" if torch.cuda.is_available() else "cpu" - model = TransformerBridge.boot_transformers("gpt2", dtype=torch.float32, device=device) - lens = JacobianLens.from_pretrained( - _GPT2_LENS_REPO, - filename=_GPT2_LENS_FILE, - revision=_GPT2_LENS_REVISION, - model=model, - ) - layers = list(lens.source_layers) - - trials, excluded = run_causal_swap_benchmark( - lens, - model, - _COUNTRY_CORPUS, - layers, - control_tolerance=args.control_tolerance, - control_seeds=args.control_seeds, - alpha=args.alpha, - k=args.k, - ) - ok_trials = [trial for trial in trials if trial.status == "ok"] - if not ok_trials: - raise RuntimeError( - "no trial survived baseline filtering, the active-support check, and " - "control-token selection" - ) - - real_successes: List[bool] = [] - control_successes: List[bool] = [] - for trial in ok_trials: - assert trial.real_target_metrics is not None - assert trial.control_target_metrics is not None - real_successes.append(trial.real_target_metrics.target_is_top1) - control_successes.append(trial.control_target_metrics.target_is_top1) - - manifest = build_protocol_manifest( - model_id="gpt2", - model_revision="n/a", - lens_repo=_GPT2_LENS_REPO, - lens_file=_GPT2_LENS_FILE, - lens_revision=_GPT2_LENS_REVISION, - corpus_name=_COUNTRY_CORPUS.name, - corpus=corpus_definition(_COUNTRY_CORPUS), - corpus_repo=_CORPUS_REPO, - corpus_path=_CORPUS_PATH, - corpus_revision=_CORPUS_REVISION, - layers_swept=layers, - alpha=args.alpha, - k=args.k, - control_tolerance=args.control_tolerance, - control_seeds=list(args.control_seeds), - success_definition="target token id equals deterministic argmax token id", - baseline_definition="source answer token id equals deterministic argmax token id", - rank_definition="1 + count(logits strictly greater than target logit)", - ) - real_ci = success_rate_ci(real_successes) - control_ci = success_rate_ci(control_successes) - artifact = serialize_artifact(manifest, trials, excluded, real_ci, control_ci) - - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") - status_counts = artifact["trial_status_counts"] - n_prompts = artifact["n_independent_prompts"] - print( - f"wrote {len(trials)} trials ({len(ok_trials)} ok over {n_prompts} independent " - f"prompt(s) at layers {artifact['layers_executed']}, " - f"{status_counts.get('skipped_source_inactive', 0)} source-inactive, " - f"{status_counts.get('skipped_no_control_token', 0)} no-control-token, " - f"{len(excluded)} excluded prompts) to {args.output}" - ) - - -if __name__ == "__main__": - main() diff --git a/transformer_lens/tools/analysis/jacobian_lens_coordinate_patch.py b/transformer_lens/tools/analysis/jacobian_lens_coordinate_patch.py index 095377ffd1..807ae50f11 100644 --- a/transformer_lens/tools/analysis/jacobian_lens_coordinate_patch.py +++ b/transformer_lens/tools/analysis/jacobian_lens_coordinate_patch.py @@ -5,11 +5,12 @@ import math import warnings from dataclasses import dataclass -from numbers import Integral, Real +from numbers import Real from typing import Dict, MutableMapping, Optional, Sequence, Tuple import torch +from transformer_lens.tools.analysis._model_state import require_int from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( _SWAP_WARN_COSINE, DEFAULT_K, @@ -139,15 +140,6 @@ def _validate_atom_rows( return atoms -def _validate_index(name: str, value: int, num_atoms: int) -> int: - if isinstance(value, bool) or not isinstance(value, Integral): - raise ValueError(f"{name} must be an integer atom index") - index = int(value) - if not 0 <= index < num_atoms: - raise ValueError(f"{name}={index} out of range for dictionary with {num_atoms} atoms") - return index - - def _close(actual: torch.Tensor, expected: torch.Tensor, condition_number: float = 1.0) -> bool: # The base tolerance certifies a well-conditioned comparison. When ``expected`` is recomputed # through an ill-conditioned span (a pinv projection), the float32 recompute error grows with @@ -387,8 +379,9 @@ def solve_coordinate_patch( outside the nonnegative pursuit frame. All three are non-fatal. """ target, dictionary = _validate_inputs(x, dictionary) - source_idx = _validate_index("source_idx", source_idx, dictionary.shape[0]) - target_idx = _validate_index("target_idx", target_idx, dictionary.shape[0]) + last_atom = dictionary.shape[0] - 1 + source_idx = require_int(source_idx, "source_idx", minimum=0, maximum=last_atom) + target_idx = require_int(target_idx, "target_idx", minimum=0, maximum=last_atom) if source_idx == target_idx: raise ValueError("source_idx and target_idx must be distinct") if mode not in ("substitute", "swap"): diff --git a/transformer_lens/tools/analysis/jacobian_lens_decomposition.py b/transformer_lens/tools/analysis/jacobian_lens_decomposition.py index af32768292..17770230a4 100644 --- a/transformer_lens/tools/analysis/jacobian_lens_decomposition.py +++ b/transformer_lens/tools/analysis/jacobian_lens_decomposition.py @@ -578,14 +578,21 @@ def estimate_occupancy( coefficient-fit residual, so their supports need not match. The occupancy is the step of maximum separation between the real and (averaged) control *cumulative* captured variance -- the point past which further atoms add no more than random directions would. Deterministic - given ``seed`` and needs no threshold. (Captured variance is a projection, hence scale-free, - so the random control atoms are simply unit-norm.) + given ``seed`` on every device and needs no threshold. (Captured variance is a projection, + hence scale-free, so the random control atoms are simply unit-norm.) + + The control dictionaries are drawn on the CPU, which is what makes a seed reproduce the same + result on every device, and then copied to ``dictionary``'s device. That draw is serial CPU + work of ``num_control_dictionaries * num_atoms * d_model`` normals, so for a large + dictionary on an accelerator it dominates the run time; lower ``num_control_dictionaries`` + to trade control precision for speed. Args: x: Target vector, shape ``[d_model]``. dictionary: Atom matrix, shape ``[num_atoms, d_model]`` (rows are atoms). max_atoms: Number of atoms to select in the real and control recurrences. - num_control_dictionaries: Number of random control dictionaries to average over. + num_control_dictionaries: Number of random control dictionaries to average over; each + is a CPU draw the size of ``dictionary``. seed: Seed for the random control dictionaries (reproducibility). Returns: @@ -632,13 +639,13 @@ def estimate_occupancy( atoms, atom_norms, target, max_atoms ) - generator = torch.Generator(device=atoms.device).manual_seed(seed) + # Drawn on CPU so a seed gives the same control dictionaries on every device. + generator = torch.Generator(device="cpu").manual_seed(seed) control_atom_norms = torch.ones(num_atoms, device=atoms.device) control_variance_runs: List[torch.Tensor] = [] for _ in range(num_control_dictionaries): - random_atoms = torch.randn( - num_atoms, d_model, generator=generator, device=atoms.device, dtype=atoms.dtype - ) + random_atoms = torch.randn(num_atoms, d_model, generator=generator, dtype=atoms.dtype) + random_atoms = random_atoms.to(atoms.device) random_atoms = random_atoms / (random_atoms * random_atoms).sum(dim=1, keepdim=True).sqrt() control_run_variance, _ = _greedy_captured_variance_gains( random_atoms, control_atom_norms, target, max_atoms diff --git a/transformer_lens/tools/analysis/jacobian_lens_registry.json b/transformer_lens/tools/analysis/jacobian_lens_registry.json index 88e004dbf3..56e63551fb 100644 --- a/transformer_lens/tools/analysis/jacobian_lens_registry.json +++ b/transformer_lens/tools/analysis/jacobian_lens_registry.json @@ -1,5 +1,6 @@ { - "_comment": "Artifact registry for pre-fitted Jacobian Lens checkpoints hosted on neuronpedia/jacobian-lens (https://huggingface.co/neuronpedia/jacobian-lens). Keys are short model names accepted by JacobianLens.from_pretrained(); aliases lists the canonical Hugging Face model IDs that also resolve to the same entry. Generated from the repository's sibling list on 2026-07-23.", + "_comment": "Artifact registry for pre-fitted Jacobian Lens checkpoints hosted on neuronpedia/jacobian-lens (https://huggingface.co/neuronpedia/jacobian-lens). Keys are short model names accepted by JacobianLens.from_pretrained(); aliases lists the canonical Hugging Face model IDs that also resolve to the same entry. Generated from the repository's sibling list on 2026-07-23. revision is the repository commit every entry is downloaded at unless from_pretrained() is given another.", + "revision": "a4114d7752d11eb546e6cf372213d7e75526d3a1", "gemma-2-2b": { "aliases": ["google/gemma-2-2b"], "repo_id": "neuronpedia/jacobian-lens", diff --git a/transformer_lens/tools/analysis/logit_lens.py b/transformer_lens/tools/analysis/logit_lens.py index 4e7d861388..95b8b3f604 100644 --- a/transformer_lens/tools/analysis/logit_lens.py +++ b/transformer_lens/tools/analysis/logit_lens.py @@ -40,7 +40,11 @@ from jaxtyping import Float, Int from transformer_lens.ActivationCache import ActivationCache -from transformer_lens.tools.analysis._model_state import require_eval_mode +from transformer_lens.tools.analysis._model_state import ( + hybrid_block_types, + require_eval_mode, + to_token_ids, +) from transformer_lens.utilities import Slice, SliceInput from transformer_lens.utilities.logits_utils import logits_to_df from transformer_lens.utilities.quantization import require_readable_weight @@ -57,9 +61,6 @@ # Normalization types for which the model has a final norm the lens must apply. _FINAL_NORM_TYPES = ("LN", "LNPre", "RMS", "RMSPre") -# Block variants without a ``resid_mid`` hook; mirrors direct_logit_attribution. -_HYBRID_VARIANT_NAMES = ("mamba", "ssm", "mixer", "linear_attn") - # Relative to the reference's per-position spread, the identity error measures 0-2e-6 in # fp32 and under 1e-2 in bf16; readouts that skip a model's transform measure 0.7-1.5. _READOUT_IDENTITY_RTOL = 0.1 @@ -275,21 +276,6 @@ def _decode_nested(ids: torch.Tensor, decode: Callable[[List[int]], str]) -> Any return [_decode_nested(row, decode) for row in ids] -def _to_token_ids(model: Any, tokens: Union[str, int, Sequence[Union[str, int]]]) -> List[int]: - """Resolve strings / ids into a list of single-token ids.""" - if isinstance(tokens, (str, int)): - tokens = [tokens] - ids: List[int] = [] - for token in tokens: - if isinstance(token, str): - ids.append(int(model.to_single_token(token))) - elif isinstance(token, int) and not isinstance(token, bool): - ids.append(token) - else: - raise TypeError(f"tokens must be strings or ints, got {type(token).__name__}") - return ids - - def _target_names(targets: Optional[TokenInput]) -> Optional[List[Optional[str]]]: """Positional record of the strings in a shared-targets argument.""" if targets is None or isinstance(targets, torch.Tensor): @@ -398,13 +384,13 @@ def _validate_selectors( if isinstance(targets, torch.Tensor): target_ids = targets.reshape(1) if targets.ndim == 0 else targets target_ids = target_ids.to(torch.long) - elif targets is not None: - target_ids = torch.tensor(_to_token_ids(model, targets), dtype=torch.long) for ids, name in ((vocab_ids, "vocab"), (target_ids, "targets")): if ids is not None and ( ids.numel() == 0 or int(ids.min()) < 0 or int(ids.max()) >= d_vocab ): raise ValueError(f"{name} ids must be within [0, {d_vocab})") + if targets is not None and not isinstance(targets, torch.Tensor): + target_ids = torch.tensor(to_token_ids(model, targets), dtype=torch.long) return vocab_ids, target_ids, d_vocab @@ -737,12 +723,11 @@ def logit_lens( chunk_size=chunk_size, ) if incl_mid: - layer_types = model.layer_types() if hasattr(model, "layer_types") else [] - hybrid = [t for t in layer_types if any(p in _HYBRID_VARIANT_NAMES for p in t.split("+"))] + hybrid = hybrid_block_types(model) if hybrid: raise ValueError( f"incl_mid=True needs a resid_mid hook on every block, but this stack has " - f"block types {sorted(set(hybrid))} without one; use incl_mid=False." + f"block types {hybrid} without one; use incl_mid=False." ) pos_slice = positions if isinstance(positions, Slice) else Slice(positions) if cache is None: diff --git a/transformer_lens/tools/analysis/projection_kernel.py b/transformer_lens/tools/analysis/projection_kernel.py index b75b7966bd..0927c1ea7f 100644 --- a/transformer_lens/tools/analysis/projection_kernel.py +++ b/transformer_lens/tools/analysis/projection_kernel.py @@ -15,6 +15,7 @@ import torch from jaxtyping import Bool, Float, Int +from transformer_lens.tools.analysis._model_state import compute_dtype, require_int from transformer_lens.tools.analysis.jacobian_lens_decomposition import ( _linalg_on_cpu_if_mps, ) @@ -133,8 +134,7 @@ class HeadAffinityResult: def top_pairs(self, k: int = 20, *, normalized: bool = False) -> List[HeadAffinityPair]: """Return the highest-scoring valid pairs with deterministic tie order.""" - if isinstance(k, bool) or not isinstance(k, int) or k < 1: - raise ValueError(f"k must be a positive integer, got {k!r}") + k = require_int(k, "k") selected_scores = self.normalized if normalized else self.scores entries: List[HeadAffinityPair] = [] @@ -178,14 +178,9 @@ def sort_key(pair: HeadAffinityPair) -> Tuple[float, int, int, int, int]: return entries[:k] -def _compute_dtype(dtype: torch.dtype) -> torch.dtype: - """Return a dtype supported by stable SVD on common PyTorch backends.""" - return torch.float64 if dtype == torch.float64 else torch.float32 - - def _validate_rtol(rtol: Optional[float], shape: Tuple[int, int], dtype: torch.dtype) -> float: if rtol is None: - return max(shape) * torch.finfo(_compute_dtype(dtype)).eps + return max(shape) * torch.finfo(compute_dtype(dtype)).eps if isinstance(rtol, bool) or not isinstance(rtol, Real): raise ValueError(f"rtol must be a finite non-negative real number, got {rtol!r}") value = float(rtol) @@ -197,11 +192,7 @@ def _validate_rtol(rtol: Optional[float], shape: Tuple[int, int], dtype: torch.d def _validate_rank(rank: Optional[int], max_rank: int) -> Optional[int]: if rank is None: return None - if isinstance(rank, bool) or not isinstance(rank, int): - raise ValueError(f"rank must be an integer or None, got {rank!r}") - if not 1 <= rank <= max_rank: - raise ValueError(f"rank must be between 1 and min(matrix.shape)={max_rank}, got {rank}") - return rank + return require_int(rank, "rank", maximum=max_rank) def orthonormal_subspace( @@ -244,9 +235,8 @@ def orthonormal_subspace( input_shape = (matrix.shape[0], matrix.shape[1]) requested_rank = _validate_rank(rank, min(input_shape)) - compute_dtype = _compute_dtype(matrix.dtype) effective_rtol = _validate_rtol(rtol, input_shape, matrix.dtype) - work = matrix.to(dtype=compute_dtype) + work = matrix.to(dtype=compute_dtype(matrix.dtype)) left, singular_values, _ = torch.linalg.svd(work, full_matrices=False) threshold = float(singular_values[0].item()) * effective_rtol measured_rank = int((singular_values > threshold).sum().item()) @@ -291,31 +281,20 @@ def _validate_subspace(value: SubspaceBasis, name: str) -> None: raise ValueError(f"{name}.singular_values must be a finite floating-point vector") if singular_values.device != basis.device: raise ValueError(f"{name}.singular_values must be on the basis device") - if ( - not isinstance(value.input_shape, tuple) - or len(value.input_shape) != 2 - or any( - isinstance(dimension, bool) or not isinstance(dimension, int) or dimension < 1 - for dimension in value.input_shape - ) - ): + if not isinstance(value.input_shape, tuple) or len(value.input_shape) != 2: raise ValueError(f"{name}.input_shape must contain two positive dimensions") + for dimension in value.input_shape: + require_int(dimension, f"{name}.input_shape dimension") if singular_values.shape[0] != min(value.input_shape): raise ValueError(f"{name}.singular_values length must equal min(input_shape)") - if ( - isinstance(value.rank, bool) - or not isinstance(value.rank, int) - or value.rank < 1 - or basis.shape[1] != value.rank - ): + if require_int(value.rank, f"{name}.rank") != basis.shape[1]: raise ValueError(f"{name}.rank must equal its positive basis width") - if ( - isinstance(value.measured_rank, bool) - or not isinstance(value.measured_rank, int) - or value.measured_rank < value.rank - or value.measured_rank > min(value.input_shape) - ): - raise ValueError(f"{name}.measured_rank must be at least its selected rank") + require_int( + value.measured_rank, + f"{name}.measured_rank", + minimum=value.rank, + maximum=min(value.input_shape), + ) if value.input_shape[0] != basis.shape[0]: raise ValueError(f"{name}.input_shape must share its basis ambient dimension") if ( @@ -395,7 +374,7 @@ def projection_kernel( ) dtype = torch.promote_types(subspace_a.basis.dtype, subspace_b.basis.dtype) - dtype = _compute_dtype(dtype) + dtype = compute_dtype(dtype) first = subspace_a.basis.to(dtype=dtype) second = subspace_b.basis.to(dtype=dtype) if check_orthonormal: @@ -430,10 +409,8 @@ def random_projection_kernel_moments(ambient_dim: int, rank: int) -> RandomSubsp These idealized descriptive moments are not calibrated p-values for trained model weights, whose head subspaces are dependent and anisotropic. """ - if isinstance(ambient_dim, bool) or not isinstance(ambient_dim, int) or ambient_dim < 2: - raise ValueError(f"ambient_dim must be an integer at least 2, got {ambient_dim!r}") - if isinstance(rank, bool) or not isinstance(rank, int) or not 1 <= rank <= ambient_dim: - raise ValueError(f"rank must be an integer between 1 and {ambient_dim}, got {rank!r}") + ambient_dim = require_int(ambient_dim, "ambient_dim", minimum=2) + rank = require_int(rank, "rank", maximum=ambient_dim) mean = rank**2 / ambient_dim variance = ( @@ -463,12 +440,7 @@ def _pairwise_projection_kernel( raise ValueError("source_bases and target_bases must share an ambient dimension") if source_bases.device != target_bases.device or source_bases.dtype != target_bases.dtype: raise ValueError("source_bases and target_bases must share a device and dtype") - if ( - isinstance(max_temp_bytes, bool) - or not isinstance(max_temp_bytes, int) - or max_temp_bytes < 1 - ): - raise ValueError("max_temp_bytes must be a positive integer") + max_temp_bytes = require_int(max_temp_bytes, "max_temp_bytes") source_count, _, source_rank = source_bases.shape target_count, _, target_rank = target_bases.shape @@ -652,7 +624,7 @@ def attention_head_subspace_affinity( dtype = matrices[0].dtype for matrix in matrices[1:]: dtype = torch.promote_types(dtype, matrix.dtype) - dtype = _compute_dtype(dtype) + dtype = compute_dtype(dtype) effective_rtol = _validate_rtol(rtol, (source_ambient, max(source_width, target_width)), dtype) requested_rank = _validate_rank(rank, min(source_ambient, source_width, target_width)) source_rank = source_width if requested_rank is None else requested_rank diff --git a/transformer_lens/tools/analysis/relevance_lens.py b/transformer_lens/tools/analysis/relevance_lens.py deleted file mode 100644 index fea2b8e790..0000000000 --- a/transformer_lens/tools/analysis/relevance_lens.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Relevance Lens (R-lens): a RelP/LRP-rule estimator for the Jacobian lens. - -The relevance lens keeps the Jacobian lens's fitting loop, transport matrices, -readout, vocabulary vectors, sparse decomposition, and interventions unchanged. -Only the backward pass differs: each per-layer transport matrix is estimated -with a scoped set of Layer-wise Relevance Propagation rules installed on the -model's residual-stream norms and gated MLPs, instead of the ordinary -vector-Jacobian product. The forward pass is bit-identical to the model's native -forward, so the two estimators differ solely in the backward semantics they -select. - -Three rules are applied, matching the RelP reference: - -1. **LN-rule** (residual-stream norms): preserve the normalization forward value - while treating its denominator as a constant in the backward pass. -2. **Identity-rule** (GELU/SiLU activations): express ``f(x) = x * phi(x)`` and - detach ``phi(x)``, so the local VJP is ``grad_out * phi(x)``. -3. **Half-rule** (multiplicative MLP gates): preserve the forward product - ``u * v`` while halving each branch's ordinary product-rule gradient. - -Linear layers and attention retain ordinary autograd. The attention-specific -AH-rule is out of scope. - -Warning: - Like the Jacobian lens, the relevance lens requires a freshly booted raw - ``TransformerBridge.boot_transformers`` model. Compatibility mode and direct - ``process_weights`` calls change the residual basis and are refused rather - than returning silently wrong readouts. - -This example bounds the fit to one layer and a short prompt; it does not produce -research-quality corpus estimates. Fitting still requires multiple backward -passes. ``boot_transformers`` uses eager attention; do not process the weights -or enable compatibility mode. - -Example:: - - import torch - - from transformer_lens.model_bridge import TransformerBridge - from transformer_lens.tools.analysis import RelevanceLens - - model = TransformerBridge.boot_transformers( - "Qwen/Qwen2-0.5B", device="cpu", dtype=torch.float32 - ) - model.eval() - prompts = [ - "The capital of France is Paris, and the capital of Germany is Berlin." - ] - lens = RelevanceLens.fit( - model, - prompts, - corpus="example:capital-cities", - source_layers=[0], - dim_batch=8, - max_seq_len=32, - skip_first_positions=2, - show_progress=False, - ) - assert lens.rule_coverage is not None - assert not lens.rule_coverage.skipped - readout = lens.readout(model, "The capital of France is", layers=[0], top_k=10) -""" - -from __future__ import annotations - -import warnings -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import torch - -from transformer_lens.model_bridge._relevance_rules import ( - RelevanceRuleCoverage, - RelevanceRules, - _RelevanceRuleCoverageEntry, - use_relevance_rules, -) -from transformer_lens.tools.analysis.jacobian_lens import ( - DEFAULT_SKIP_FIRST_POSITIONS, - JacobianLens, - _build_fit_metadata, - _fit_transport_matrices, - _ordinary_vjp, - _prepare_fit, -) - -# Estimator identity recorded on every relevance lens. Distinct from the -# ordinary Jacobian estimator so merge() refuses to combine the two. -ESTIMATOR_RELEVANCE = "relevance_lens" - -# Version of the rule semantics above. Bump when a rule's backward VJP changes, -# so artifacts fitted under different semantics cannot be merged silently. -RELEVANCE_RULE_VERSION = 1 - -# Coverage schema changes do not change backward-rule semantics. -_RULE_COVERAGE_SCHEMA_VERSION = 1 - -# The rule set the relevance estimator installs: residual-stream norms, gated -# MLP activations, and multiplicative MLP gates. Attention is deliberately -# excluded (the AH-rule is out of scope). -RELEVANCE_RULES = RelevanceRules( - normalization=True, - activation=True, - multiplicative_gate=True, -) - -# Names of the rule kinds the estimator requests, derived from the rule set so -# the request cannot drift from what the fit attempts to install. -_REQUESTED_RULE_NAMES: Tuple[str, ...] = tuple( - name - for name in ("normalization", "activation", "multiplicative_gate") - if getattr(RELEVANCE_RULES, name) -) - - -def _installed_rule_names(coverage: RelevanceRuleCoverage) -> List[str]: - """Rule kinds that actually shaped the matrices, in request order.""" - installed_kinds = {entry.kind for entry in coverage.installed} - return [kind for kind in _REQUESTED_RULE_NAMES if kind in installed_kinds] - - -def _decode_rule_coverage(recorded: Any) -> RelevanceRuleCoverage: - """Validate kind-aware coverage without inferring kinds from legacy paths.""" - if not isinstance(recorded, dict): - raise ValueError("rule_coverage must be a kind-aware coverage dictionary") - if "schema_version" not in recorded: - raise ValueError( - "rule_coverage requires schema_version and kind/path entries; " - "legacy path-only coverage is ambiguous, so refit the relevance lens" - ) - schema_version = recorded["schema_version"] - if type(schema_version) is not int or schema_version != _RULE_COVERAGE_SCHEMA_VERSION: - raise ValueError(f"unsupported rule_coverage schema_version: {schema_version!r}") - if set(recorded) != {"schema_version", "installed", "skipped"}: - raise ValueError("rule_coverage requires schema_version, installed, and skipped fields") - - def decode_entries(field: str) -> Tuple[_RelevanceRuleCoverageEntry, ...]: - raw_entries = recorded[field] - if not isinstance(raw_entries, (list, tuple)): - raise ValueError(f"rule_coverage.{field} must be a sequence of kind/path entries") - entries = [] - for raw_entry in raw_entries: - if not isinstance(raw_entry, dict) or set(raw_entry) != {"kind", "path"}: - raise ValueError(f"rule_coverage.{field} entries must contain kind and path") - kind = raw_entry["kind"] - path = raw_entry["path"] - if not isinstance(kind, str) or kind not in _REQUESTED_RULE_NAMES: - raise ValueError(f"rule_coverage.{field} records unknown rule kind {kind!r}") - if not isinstance(path, str) or not path.strip(): - raise ValueError(f"rule_coverage.{field} paths must be non-empty strings") - entries.append(_RelevanceRuleCoverageEntry(kind=kind, path=path)) - if len(set(entries)) != len(entries): - raise ValueError(f"rule_coverage.{field} contains duplicate kind/path entries") - return tuple(entries) - - coverage = RelevanceRuleCoverage( - installed=decode_entries("installed"), skipped=decode_entries("skipped") - ) - if set(coverage.installed) & set(coverage.skipped): - raise ValueError("rule_coverage cannot install and skip the same kind/path entry") - return coverage - - -def _encode_rule_coverage(coverage: RelevanceRuleCoverage) -> Dict[str, Any]: - """Encode coverage as safe, versioned artifact metadata.""" - recorded = { - "schema_version": _RULE_COVERAGE_SCHEMA_VERSION, - "installed": [{"kind": entry.kind, "path": entry.path} for entry in coverage.installed], - "skipped": [{"kind": entry.kind, "path": entry.path} for entry in coverage.skipped], - } - _decode_rule_coverage(recorded) - return recorded - - -def _coverage_from_metadata(metadata: Dict[str, Any]) -> Optional[RelevanceRuleCoverage]: - """Restore optional coverage, rejecting malformed or ambiguous records.""" - if "rule_coverage" not in metadata: - return None - return _decode_rule_coverage(metadata["rule_coverage"]) - - -def _coverage_labels(entries: Sequence[_RelevanceRuleCoverageEntry]) -> List[str]: - """Distinguish rule kinds sharing a component path in diagnostics.""" - return [f"{entry.kind}: {entry.path}" for entry in entries] - - -class RelevanceLens(JacobianLens): - """A Jacobian lens whose transport matrices use relevance-rule backward. - - Inherits the full Jacobian lens surface -- transport, readout, vocabulary - vectors and dictionary, sparse decomposition, and the steering, ablation, - and swap interventions -- unchanged. Only :meth:`fit` differs, and it - records the estimator identity and the rule configuration that produced the - matrices. Loading requires explicit ``estimator="relevance_lens"`` metadata; - unlabelled artifacts and Jacobian running-sum checkpoints are refused. Present - coverage records require schema version 1 with explicit rule kinds and paths; - ambiguous path-only coverage requires refitting. - - Attributes: - rule_coverage: Rule-kind and canonical-path pairs installed versus - skipped during the fit. - relevance_rule_version: Version of the rule semantics used. - enabled_rules: Names of the ``RelevanceRules`` fields that were enabled. - """ - - # Relevance artifacts are not published in the Jacobian lens registry, so - # short model names are refused rather than resolving to a Jacobian lens. - _uses_artifact_registry = False - - def __init__( - self, - jacobians: Dict[int, torch.Tensor], - *, - n_prompts: int, - d_model: int, - metadata: Optional[Dict[str, Any]] = None, - rule_coverage: Optional[RelevanceRuleCoverage] = None, - relevance_rule_version: int = RELEVANCE_RULE_VERSION, - enabled_rules: Sequence[str] = (), - ) -> None: - if metadata is not None and "estimator" in metadata: - self._validate_artifact_metadata("RelevanceLens constructor", metadata) - super().__init__(jacobians, n_prompts=n_prompts, d_model=d_model, metadata=metadata) - self.estimator = ESTIMATOR_RELEVANCE - # Recorded semantics take precedence over defaults when restoring an artifact. - if "relevance_rule_version" in self.metadata: - recorded_version = self.metadata["relevance_rule_version"] - if not isinstance(recorded_version, int) or isinstance(recorded_version, bool): - raise ValueError("relevance_rule_version provenance must be an integer") - relevance_rule_version = recorded_version - if "enabled_rules" in self.metadata: - recorded_rules = self.metadata["enabled_rules"] - if not isinstance(recorded_rules, (list, tuple)) or not all( - isinstance(name, str) for name in recorded_rules - ): - raise ValueError("enabled_rules provenance must be a list or tuple of strings") - enabled_rules = recorded_rules - self.relevance_rule_version = int(relevance_rule_version) - self.enabled_rules: List[str] = list(enabled_rules) - recorded_coverage = _coverage_from_metadata(self.metadata) - if rule_coverage is None: - rule_coverage = recorded_coverage - elif recorded_coverage is not None and recorded_coverage != rule_coverage: - raise ValueError("rule_coverage does not match recorded coverage provenance") - self.rule_coverage = rule_coverage - # Normalize recorded configuration so attributes and saved provenance agree. - self.metadata["estimator"] = ESTIMATOR_RELEVANCE - self.metadata["relevance_rule_version"] = self.relevance_rule_version - self.metadata["enabled_rules"] = list(self.enabled_rules) - if self.rule_coverage is not None: - self.metadata["rule_coverage"] = _encode_rule_coverage(self.rule_coverage) - - @classmethod - def _validate_artifact_metadata(cls, path: str, metadata: Any) -> None: - """Require original relevance provenance before construction can stamp it.""" - recorded_estimator = metadata.get("estimator") if isinstance(metadata, dict) else None - if not isinstance(recorded_estimator, str) or recorded_estimator != ESTIMATOR_RELEVANCE: - raise ValueError( - f"{path} requires estimator provenance {ESTIMATOR_RELEVANCE!r}; " - f"recorded estimator is {recorded_estimator!r}. " - "Unlabelled or foreign artifacts cannot be converted to relevance lenses." - ) - - @classmethod - def _validate_checkpoint_payload(cls, path: str, payload: Dict[str, Any]) -> None: - """Refuse the Jacobian-specific running-sum conversion path.""" - raise ValueError( - f"{path} is a running-sum checkpoint; RelevanceLens requires an artifact " - "with explicit relevance estimator provenance, not a Jacobian checkpoint." - ) - - @classmethod - def _validate_merge_inputs(cls, lenses: Sequence[JacobianLens]) -> None: - """Require relevance shards even when all inputs share ordinary provenance.""" - for index, lens in enumerate(lenses): - if not isinstance(lens, RelevanceLens): - raise ValueError( - "RelevanceLens.merge() requires relevance lens shards with relevance " - f"estimator provenance; shard {index} is {type(lens).__name__}." - ) - cls._validate_artifact_metadata(f"merge shard {index}", lens.metadata) - - @classmethod - def fit( - cls, - model: Any, - prompts: Sequence[str], - *, - corpus: str, - source_layers: Optional[Sequence[int]] = None, - dim_batch: int = 8, - max_seq_len: int = 128, - skip_first_positions: int = DEFAULT_SKIP_FIRST_POSITIONS, - show_progress: bool = True, - metadata: Optional[Dict[str, Any]] = None, - ) -> "RelevanceLens": - """Fit a relevance lens on a raw ``TransformerBridge``. - - Shares the Jacobian lens's estimator-independent drive loop and differs - only in the backward step: the three relevance rules are installed on - the model for the duration of the fit, so each transport matrix is - estimated with rule-modified backward semantics. The forward pass is - unchanged, and the rule scope is released before this method returns. - - Coverage is checked before any fitting work. A model that can honor no - rule is refused, since the fit would otherwise return ordinary Jacobian - matrices labelled as a relevance lens. A model that honors some rules - but skips others is fitted with a warning, and the artifact records only - the rules that actually shaped the matrices. - - Args: - model: A raw ``TransformerBridge``. Model parameters are temporarily - frozen during fitting and restored after. The model and all of - its submodules must be in evaluation mode. - prompts: Prompt strings. Prompts too short to contain a valid - position (``seq_len <= skip_first_positions + 1``) are skipped - with a warning and do not count toward ``n_prompts``. - corpus: Stable identifier for the prompt corpus or slice, recorded - in artifact provenance. - source_layers: Layers to fit. Defaults to every layer below the - final layer. Negative indices count from ``n_layers``. - dim_batch: Output dimensions per backward pass. - max_seq_len: Prompts are truncated to this many tokens. - skip_first_positions: Leading positions excluded from the source - average. - show_progress: Show a tqdm progress bar over prompts. - metadata: Extra provenance merged into :attr:`metadata`. - - Returns: - The fitted :class:`RelevanceLens`. - - Raises: - TypeError: If model is not a ``TransformerBridge``. - ValueError: On compatibility mode, training mode, invalid provenance - or layer indices, if no prompt was long enough to fit on, or if - no relevance rule could be installed on the model. - RelevanceRuleUnsupportedError: If a mount that is expected to honor - a requested rule cannot, such as a gated MLP whose activation - the Identity-rule does not hold for. - - Warns: - UserWarning: If some rule mounts were skipped, so the matrices mix - rule-modified and ordinary gradients. - """ - options = _prepare_fit( - model, - operation=cls.__name__, - estimator=ESTIMATOR_RELEVANCE, - corpus=corpus, - source_layers=source_layers, - dim_batch=dim_batch, - max_seq_len=max_seq_len, - skip_first_positions=skip_first_positions, - metadata=metadata, - estimator_metadata_keys=("relevance_rule_version", "enabled_rules", "rule_coverage"), - ) - - with use_relevance_rules(model, RELEVANCE_RULES) as coverage: - if not coverage.installed: - raise ValueError( - "no relevance rule could be installed on this model, so the " - "fit would return ordinary Jacobian matrices labelled as a " - "relevance lens. Skipped mounts: " - f"{_coverage_labels(coverage.skipped)}. The relevance estimator needs " - "residual-stream norms on the native-autograd path and gated " - "MLPs with GELU or SiLU activations; use JacobianLens for " - "models without them." - ) - if coverage.skipped: - warnings.warn( - "some relevance-rule mounts were skipped, so the transport " - "matrices mix rule-modified and ordinary gradients. Installed " - f"rules: {_installed_rule_names(coverage)}. Skipped mounts: " - f"{_coverage_labels(coverage.skipped)}.", - UserWarning, - stacklevel=2, - ) - transport_matrices, n_done = _fit_transport_matrices( - model, - prompts, - source_layers=list(options.source_layers), - dim_batch=options.dim_batch, - max_seq_len=options.max_seq_len, - skip_first_positions=options.skip_first_positions, - show_progress=show_progress, - backward_provider=_ordinary_vjp, - ) - - return cls( - transport_matrices, - n_prompts=n_done, - d_model=options.d_model, - metadata=_build_fit_metadata( - model, - options, - estimator=ESTIMATOR_RELEVANCE, - n_prompts=n_done, - metadata=metadata, - estimator_metadata={ - "relevance_rule_version": RELEVANCE_RULE_VERSION, - "enabled_rules": _installed_rule_names(coverage), - "rule_coverage": _encode_rule_coverage(coverage), - }, - ), - rule_coverage=coverage, - relevance_rule_version=RELEVANCE_RULE_VERSION, - enabled_rules=_installed_rule_names(coverage), - ) diff --git a/transformer_lens/tools/analysis/sparse_probing.py b/transformer_lens/tools/analysis/sparse_probing.py index ac1d1b6983..8e265b2633 100644 --- a/transformer_lens/tools/analysis/sparse_probing.py +++ b/transformer_lens/tools/analysis/sparse_probing.py @@ -17,6 +17,8 @@ import torch from jaxtyping import Bool, Float, Int, Integer +from transformer_lens.tools.analysis._model_state import compute_dtype, require_int + PreprocessMode = Literal["none", "standardize"] ClassWeightMode = Literal["balanced"] | None ArmMode = Literal["random_coordinate", "label_shuffle"] @@ -185,12 +187,6 @@ def _finite_positive_real(value: int | float, name: str) -> float: return result -def _positive_integer(value: int, name: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value < 1: - raise ValueError(f"{name} must be a positive integer, got {value!r}") - return value - - def _validate_inputs( features: torch.Tensor, labels: torch.Tensor, @@ -250,7 +246,7 @@ def _validate_inputs( if int(counts.min()) < 2: raise ValueError("each class must contain at least two examples") - validated_k = _positive_integer(k, "k") + validated_k = require_int(k, "k") if validated_k > features.shape[1]: raise ValueError(f"k must be at most the feature count {features.shape[1]}, got {k}") if isinstance(test_fraction, bool) or not isinstance(test_fraction, (int, float)): @@ -271,10 +267,11 @@ def _validate_inputs( raise ValueError(f"class_weight must be 'balanced' or None, got {class_weight!r}") validated_class_weight = cast(ClassWeightMode, class_weight) validated_l2 = _finite_positive_real(l2_strength, "l2_strength") - if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed < 2**63: - raise ValueError(f"seed must be an integer in [0, 2**63), got {seed!r}") - validated_max_iter = _positive_integer(max_iter, "max_iter") - validated_refinement_steps = _nonnegative_integer(max_refinement_steps, "max_refinement_steps") + validated_seed = require_int(seed, "seed", minimum=0, maximum=2**63 - 1) + validated_max_iter = require_int(max_iter, "max_iter") + validated_refinement_steps = require_int( + max_refinement_steps, "max_refinement_steps", minimum=0 + ) validated_tolerance = _finite_positive_real(decrement_tolerance, "decrement_tolerance") if validated_tolerance >= 1: raise ValueError( @@ -291,7 +288,7 @@ def _validate_inputs( std_floor=validated_std_floor, class_weight=validated_class_weight, l2_strength=validated_l2, - seed=seed, + seed=validated_seed, max_iter=validated_max_iter, max_refinement_steps=validated_refinement_steps, decrement_tolerance=validated_tolerance, @@ -408,9 +405,10 @@ def _feature_scores( train_labels: torch.Tensor, train_indices: torch.Tensor, ) -> torch.Tensor: - compute_dtype = torch.float64 if features.dtype == torch.float64 else torch.float32 device_indices = train_indices.to(device=features.device) - train_features = features.index_select(0, device_indices).to(dtype=compute_dtype) + train_features = features.index_select(0, device_indices).to( + dtype=compute_dtype(features.dtype) + ) positive = train_labels.to(device=features.device) scores = train_features[positive].mean(dim=0) - train_features[~positive].mean(dim=0) return scores.to(device="cpu").to(dtype=torch.float64) @@ -840,12 +838,6 @@ def metric_tensor(name: str) -> torch.Tensor: ) -def _nonnegative_integer(value: int, name: str) -> int: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise ValueError(f"{name} must be a nonnegative integer, got {value!r}") - return value - - def fit_sparse_probe( features: Float[torch.Tensor, "example feature"], labels: Bool[torch.Tensor, "example"] | Integer[torch.Tensor, "example"], @@ -1013,15 +1005,13 @@ def sweep_sparse_probe( """ if isinstance(ks, (str, bytes)) or not isinstance(ks, Sequence): raise ValueError("ks must be a non-empty sequence of positive integers") - k_values = tuple(ks) + k_values = tuple(require_int(k, "ks", message="ks must contain positive integers") for k in ks) if not k_values: raise ValueError("ks must be a non-empty sequence of positive integers") - if any(isinstance(k, bool) or not isinstance(k, int) or k < 1 for k in k_values): - raise ValueError("ks must contain positive integers") if any(right <= left for left, right in zip(k_values, k_values[1:])): raise ValueError("ks must be strictly increasing and unique") - random_count = _nonnegative_integer(n_random_subsets, "n_random_subsets") - shuffle_count = _nonnegative_integer(n_label_shuffles, "n_label_shuffles") + random_count = require_int(n_random_subsets, "n_random_subsets", minimum=0) + shuffle_count = require_int(n_label_shuffles, "n_label_shuffles", minimum=0) validated = _validate_inputs( features, labels, diff --git a/transformer_lens/tools/analysis/svd_circuits.py b/transformer_lens/tools/analysis/svd_circuits.py deleted file mode 100644 index 7ea62a8de1..0000000000 --- a/transformer_lens/tools/analysis/svd_circuits.py +++ /dev/null @@ -1,1070 +0,0 @@ -"""Singular-vector decomposition of a single attention head's QK and OV maps. - -An attention head is characterized by two low-rank linear maps: the query-key -map ``W_Q W_K^T`` that scores source positions, and the output-value map -``W_V W_O`` that writes the attended value back into the residual stream. This -tool takes the singular value decomposition of each map for one head and exposes -the singular values (how much each direction matters) together with the left and -right singular vectors that span the map's input and output spaces. - -The decomposition is weight-space only: it reads ``W_Q``/``W_K``/``W_V``/``W_O`` -and needs no forward pass, no activation cache, and no compatibility mode. Each -map is kept factored through -:class:`~transformer_lens.FactoredMatrix.FactoredMatrix`, so the -``d_model x d_model`` product is never materialized and the returned rank is -bounded by ``d_head``. - -For a factored map ``A @ B`` (``A: [ldim, mdim]``, ``B: [mdim, rdim]``), the SVD's -``U`` columns live in ``A``'s input space (``ldim``) and ``V`` columns live in -``B``'s output space (``rdim``): feeding ``x = U[:, i]`` through the map gives -``x @ (A @ B) == S[i] * V[:, i]``, never the reverse. For OV (``A = W_V_h``, -``B = W_O_h``), ``U``'s columns are therefore the value-computation *input* -directions this head reads from the residual stream, and ``V``'s columns are the -*output* directions it writes back into the residual stream - the ones to -project through ``W_U`` for a vocab or logit readout. For QK (``A = W_Q_h``, -``B = W_K_h.transpose(-1, -2)``), both ``U`` (destination/query-read) and ``V`` -(source/key-read) are read directions; QK only ever produces a scalar attention -score, so neither is a write direction. The historical ``.Vh`` alias returns the -same tensor as ``.V`` and is never used here. - -Adjacent singular values closer than a relative gap ``eps`` leave their singular -directions defined only up to a rotation, so the result carries a per-direction -degeneracy report. Directions are grouped into contiguous blocks that end only -at a gap of at least ``eps``; callers attribute such a block as a subspace -instead of trusting a single, rotation-dependent direction inside it. - -A singular value near zero relative to the top of the spectrum is null rather -than near-equal: its singular vector is an arbitrary null-space direction, not a -rotation of a comparable neighbour. Null directions are flagged under their own -tolerance, ``null_rtol``, keyed to the spectrum's top value the way -:func:`torch.linalg.matrix_rank` keys its default tolerance. - -Example:: - - from transformer_lens.model_bridge import TransformerBridge - from transformer_lens.tools.analysis.svd_circuits import decompose_head - - model = TransformerBridge.boot_transformers("gpt2", device="cpu") - decomposition = decompose_head(model, layer=0, head=0) - ov = decomposition.OV - for row in ov.rank_report: - print(f"direction {row.idx}: sigma={row.sigma:.3f} ratio={row.sigma_ratio:.3f}") -""" - -from dataclasses import dataclass -from typing import Callable, List, Literal, Optional, Sequence, Tuple, Union - -import torch -from jaxtyping import Float - -from transformer_lens.FactoredMatrix import FactoredMatrix -from transformer_lens.tools.analysis._model_state import require_eval_mode - -# Which of a head's two maps to decompose: the query-key map or the output-value map. -Which = Literal["QK", "OV"] - -_VALID_WHICH: Tuple[Which, ...] = ("QK", "OV") - -# Relative-gap threshold: neighbouring singular values closer than this (in -# relative terms) are treated as one rotation-ambiguous block. -_DEFAULT_EPS = 1e-2 - -# Absolute floor so relative-gap and ratio computations never divide by ~0. -_SIGMA_FLOOR = 1e-12 - -# Default seed for the causal gate's random baseline, so a bare patch_along_directions -# call is reproducible instead of drawing from the global RNG. Matches estimate_occupancy, -# which seeds its random controls by default. -_DEFAULT_BASELINE_SEED = 0 - -# Number of random in-span control subspaces the gate averages its baseline delta over, -# so one lucky or unlucky draw does not decide the gate. The per-draw magnitudes are -# heavy-tailed, so the averaged threshold still varies with the seed and a direction whose -# delta sits near it can gate either way; pass an explicit rng and raise n_baseline when -# the verdict is close to the threshold. -_DEFAULT_N_BASELINE = 32 - - -class DegenerateDirectionError(ValueError): - """Raised when per-direction attribution is requested for a degenerate direction. - - Subclasses ``ValueError`` so callers that already ``except ValueError`` keep - working, mirroring how the other analysis tools raise ``ValueError`` for - their input guards. - """ - - -@dataclass -class RankReportRow: - """One singular direction's summary, aligned with column ``idx`` of ``U``/``V``. - - Attributes: - idx: Position of the direction, matching the column index in ``U`` and ``V``. - sigma: The singular value for this direction. - sigma_ratio: ``sigma`` normalized by the largest singular value, in ``[0, 1]``. - is_degenerate: True when this direction is not attributable on its own: it - shares a block with a neighbour (defined only up to a rotation within - that block) or it is numerically null. - is_null: True when ``sigma_ratio`` falls below ``null_rtol``, so the singular - vector is an arbitrary direction from the map's null space. - block_id: Index of the contiguous block this direction belongs to. - """ - - idx: int - sigma: float - sigma_ratio: float - is_degenerate: bool - is_null: bool - block_id: int - - -@dataclass -class HeadSVD: - """Factored SVD of one head map (``"QK"`` or ``"OV"``) with a degeneracy report. - - Attributes: - which: Which map this decomposes, ``"QK"`` or ``"OV"``. - layer: Layer of the decomposed head. - head: Head index within the layer. - U: Left singular vectors, ``[d_model, rank]``: column i is the map's input - direction i (for OV, the residual-stream direction this head's value - computation reads from; for QK, the destination/query-read direction). - S: Singular values, ``[rank]``, sorted descending. - V: Right singular vectors, ``[d_model, rank]``: column i is the map's output - direction i for OV (the residual-stream direction this head writes into, - the one to project through ``W_U``), or the source/key-read direction for - QK (QK produces no write direction). The reconstruction is - ``U @ S.diag() @ V.transpose(-2, -1)``. - rank_report: Per-direction :class:`RankReportRow` list, aligned with the - columns of ``U``/``V``. - eps: Relative gap below which adjacent directions share a block; every block - boundary sits at a gap of at least ``eps``. - null_rtol: Relative-to-top-singular-value tolerance below which a direction - is numerically null. - folded_ln: Whether the model's weights carried a folded final LayerNorm when - this was decomposed. ``enable_compatibility_mode`` folds ``ln1`` into - ``W_V`` and centres ``W_O``/``W_U``, so a decomposition describes the OV - map only under the state it was built in; the readout and patch consumers - refuse a decomposition whose state no longer matches the model. - """ - - which: Which - layer: int - head: int - U: Float[torch.Tensor, "d_model rank"] - S: Float[torch.Tensor, "rank"] - V: Float[torch.Tensor, "d_model rank"] - rank_report: List[RankReportRow] - eps: float - null_rtol: float - folded_ln: bool = False - - def is_degenerate(self, i: int) -> bool: - """Whether direction ``i`` is refused: rotation-ambiguous inside a block, or null.""" - return self.rank_report[i].is_degenerate - - def block_of(self, i: int) -> List[int]: - """Return every direction index sharing direction ``i``'s degeneracy block. - - The result is a singleton ``[i]`` for an isolated direction and the full - run for a degenerate one. - """ - block_id = self.rank_report[i].block_id - return [row.idx for row in self.rank_report if row.block_id == block_id] - - def degenerate_blocks(self) -> List[List[int]]: - """Return every degenerate block's indices: the subspaces to attribute whole or skip.""" - blocks: List[List[int]] = [] - current: List[int] = [] - current_block_id: Optional[int] = None - degenerate = False - # block_ids are contiguous and ascending, so one scan recovers the blocks. - for row in self.rank_report: - if row.block_id != current_block_id: - if degenerate: - blocks.append(current) - current = [] - degenerate = False - current_block_id = row.block_id - current.append(row.idx) - degenerate = degenerate or row.is_degenerate - if degenerate: - blocks.append(current) - return blocks - - def require_isolated(self, i: int) -> None: - """Raise :class:`DegenerateDirectionError` unless direction ``i`` is attributable alone. - - The message names the cause, since a rotation-ambiguous block is still a - subspace worth attributing while a null block carries no signal. - """ - row = self.rank_report[i] - if not row.is_degenerate: - return - where = f"Direction {i} of the {self.which} SVD of head L{self.layer}H{self.head}" - block = self.block_of(i) - if row.is_null: - raise DegenerateDirectionError( - f"{where} is numerically null (sigma_ratio {row.sigma_ratio:.2e} < " - f"null_rtol {self.null_rtol:.2e}), so its singular vector is an arbitrary " - f"null-space direction. Attribute the null block {block} as a subspace, " - f"or skip it." - ) - raise DegenerateDirectionError( - f"{where} lies in block {block}, whose members are not separated by a " - f"relative gap of eps={self.eps:g} and so are defined only up to a rotation. " - f"Attribute the block as a subspace instead of the single direction." - ) - - -@dataclass -class HeadDecomposition: - """Container returned by :func:`decompose_head`. - - ``QK`` and ``OV`` hold the :class:`HeadSVD` for each requested map, or - ``None`` when that map was not requested. - """ - - layer: int - head: int - QK: Optional[HeadSVD] = None - OV: Optional[HeadSVD] = None - - -def _read_weight(weight: torch.Tensor) -> torch.Tensor: - """Detach one per-head weight and put it in SVD precision. - - Detached so the returned factors carry no autograd graph into the model. fp16/bf16 - are promoted because reduced-precision SVD is unstable; float64 is kept so the - default ``null_rtol`` matches the precision actually decomposed. - """ - weight = weight.detach() - return weight if weight.dtype == torch.float64 else weight.float() - - -def _has_attn(block) -> bool: - """Whether ``block`` declares an ``attn`` submodule. - - A bridge block resolves an unknown name through its wrapped HF module, so its declared - ``submodules`` are the reliable record of whether it carries attention. - """ - submodules = getattr(block, "submodules", None) - if isinstance(submodules, dict): - return "attn" in submodules - return getattr(block, "attn", None) is not None - - -def _layer_attention(model, layer: int): - """Return ``model.blocks[layer].attn``, refusing a layer that has no attention block.""" - blocks = getattr(model, "blocks", None) - if blocks is None: - raise ValueError( - f"decompose_head reads layer {layer}'s head weights from blocks[{layer}].attn, but " - f"{type(model).__name__} has no `blocks` stack; encoder-decoder bridges keep " - "separate encoder and decoder stacks, which decompose_head does not read." - ) - block = blocks[layer] - if not _has_attn(block): - submodules = getattr(block, "submodules", None) - exposes = f" (it exposes {sorted(submodules)})" if isinstance(submodules, dict) else "" - attention_layers = [i for i, candidate in enumerate(blocks) if _has_attn(candidate)] - hint = ( - f"Layers with an `attn` submodule: {attention_layers}." - if attention_layers - else "No block in this model has one." - ) - raise ValueError( - f"decompose_head needs an attention block at layer {layer}, but blocks[{layer}] " - f"({type(block).__name__}) has no `attn` submodule{exposes}. {hint}" - ) - return block.attn - - -def _hook_result_never_fired(model, layer: int, operation: str) -> ValueError: - """Refusal for a bridge that accepted ``use_attn_result`` but never fired ``hook_result``.""" - blocks = getattr(model, "blocks", None) - attn_class = None - if blocks is not None and 0 <= layer < len(blocks) and _has_attn(blocks[layer]): - attn_class = type(blocks[layer].attn).__name__ - architecture = getattr(getattr(model, "cfg", None), "architecture", None) - names = [name for name in (architecture, attn_class) if name] - where = f" ({', '.join(names)})" if names else "" - return ValueError( - f"{operation}: the attention bridge{where} accepted use_attn_result but never fired " - f"blocks.{layer}.attn.hook_result, so per-head outputs are unavailable on this " - "architecture." - ) - - -def _head_weights( - model, layer: int, head: int -) -> Tuple[ - Float[torch.Tensor, "d_model d_head"], - Float[torch.Tensor, "d_model d_head"], - Float[torch.Tensor, "d_model d_head"], - Float[torch.Tensor, "d_head d_model"], -]: - """Return ``(W_Q_h, W_K_h, W_V_h, W_O_h)`` for one head, detached, in SVD precision. - - Reads the single block's per-head weights rather than the full-model - ``W_Q``/``W_K``/``W_V``/``W_O`` stacks, so only one layer is materialized. On - grouped-query attention ``W_K``/``W_V`` carry one row per key-value head, so the - query head is mapped to its key-value head (query head ``h`` reads kv head - ``h // (n_heads // n_kv_heads)``); a no-op for multi-head attention, where the - head counts already match. - """ - attn = _layer_attention(model, layer) - n_kv_heads = attn.W_K.shape[0] - kv_head = head // (model.cfg.n_heads // n_kv_heads) - W_Q_h = _read_weight(attn.W_Q[head]) # [d_model, d_head] - W_K_h = _read_weight(attn.W_K[kv_head]) # [d_model, d_head] - W_V_h = _read_weight(attn.W_V[kv_head]) # [d_model, d_head] - W_O_h = _read_weight(attn.W_O[head]) # [d_head, d_model] - return W_Q_h, W_K_h, W_V_h, W_O_h - - -def _degeneracy_blocks( - S: Float[torch.Tensor, "rank"], eps: float, null_rtol: float -) -> List[List[int]]: - """Group singular directions into contiguous blocks separated by relative gaps of ``eps``. - - ``S`` is sorted descending. Direction ``i`` joins the open block when - ``1 - S[i]/S[i-1] < eps``, or when it and its predecessor are both null relative to - the top value. Only the gap to the previous direction counts: a block may span far - more than ``eps`` end to end, but every boundary is an ``eps`` gap, and that - separation from the rest of the spectrum is what makes a block stable under - perturbation; no smaller contiguous group inside it is. ``_SIGMA_FLOOR`` keeps the - ratios finite when a divisor is ~0. - """ - n = int(S.shape[0]) - if n == 0: - return [] - values = [float(x) for x in S.tolist()] - top = max(values[0], _SIGMA_FLOOR) - blocks: List[List[int]] = [] - current = [0] - for i in range(1, n): - prev = max(values[i - 1], _SIGMA_FLOOR) - near_equal = (1.0 - values[i] / prev) < eps - null_run = (values[i] / top) < null_rtol and (values[i - 1] / top) < null_rtol - if near_equal or null_run: - current.append(i) - else: - blocks.append(current) - current = [i] - blocks.append(current) - return blocks - - -def _build_rank_report( - S: Float[torch.Tensor, "rank"], blocks: List[List[int]], null_rtol: float -) -> List[RankReportRow]: - """Summarize each singular direction and tag its degeneracy block. - - ``blocks`` must partition ``range(len(S))``. Nullness flags a direction on its own: - a lone null singular vector is arbitrary even though nothing groups with it. - """ - values = [float(x) for x in S.tolist()] - top = max(values) if values else 0.0 - denominator = top if top > _SIGMA_FLOOR else _SIGMA_FLOOR - rows: List[Optional[RankReportRow]] = [None] * len(values) - for block_id, block in enumerate(blocks): - shared = len(block) > 1 - for idx in block: - sigma_ratio = values[idx] / denominator - is_null = sigma_ratio < null_rtol - rows[idx] = RankReportRow( - idx=idx, - sigma=values[idx], - sigma_ratio=sigma_ratio, - is_degenerate=shared or is_null, - is_null=is_null, - block_id=block_id, - ) - return [row for row in rows if row is not None] - - -def _factored_head_svd( - A: Float[torch.Tensor, "d_model d_head"], - B: Float[torch.Tensor, "d_head d_model"], - *, - which: Which, - layer: int, - head: int, - eps: float, - null_rtol: Optional[float] = None, - folded_ln: bool = False, -) -> HeadSVD: - """Decompose the factored map ``A @ B`` for one head into a :class:`HeadSVD`. - - For OV pass ``A = W_V_h`` and ``B = W_O_h``; for QK pass ``A = W_Q_h`` and - ``B = W_K_h.transpose(-1, -2)``. The map stays factored through - :class:`FactoredMatrix`, so the ``d_model x d_model`` product is never - materialized and the rank is bounded by ``d_head``. - - ``null_rtol`` of ``None`` resolves to ``d_model * torch.finfo(S.dtype).eps``, - the same relative tolerance :func:`torch.linalg.matrix_rank` uses by default - for a square ``d_model x d_model`` map, so the null cutoff tracks the - decomposition's own numerical rank rather than a hand-tuned constant. - - ``folded_ln`` is whether the model's weights carried a folded final LayerNorm, - recorded on the result so the consumers can refuse a decomposition taken under a - different state. - """ - U, S, V = FactoredMatrix(A, B).svd() - d_model = U.shape[0] - resolved_null_rtol = null_rtol if null_rtol is not None else d_model * torch.finfo(S.dtype).eps - blocks = _degeneracy_blocks(S, eps, null_rtol=resolved_null_rtol) - rank_report = _build_rank_report(S, blocks, null_rtol=resolved_null_rtol) - return HeadSVD( - which=which, - layer=layer, - head=head, - U=U, - S=S, - V=V, - rank_report=rank_report, - eps=eps, - null_rtol=resolved_null_rtol, - folded_ln=folded_ln, - ) - - -def decompose_head( - model, - layer: int, - head: int, - *, - which: Sequence[str] = ("QK", "OV"), - eps: float = _DEFAULT_EPS, - null_rtol: Optional[float] = None, -) -> HeadDecomposition: - """Decompose a head's QK (``W_Q W_K^T``) and/or OV (``W_V W_O``) maps via SVD. - - Weight-space only: this reads the head's per-block weights via the bridge's - ``model.blocks[layer].attn`` accessors and needs no forward pass and no - compatibility mode. The returned factors are detached from the model. Whether the - model's weights carry a folded final LayerNorm is recorded on each returned - :class:`HeadSVD` so the readout and patch consumers can refuse a decomposition - taken under a different state. - - Args: - model: A ``TransformerBridge``. - layer: Layer of the head to decompose. - head: Head index within the layer. - which: Which maps to decompose, a non-empty sequence drawn from - ``("QK", "OV")``. A bare string is rejected rather than iterated. - eps: Relative gap at which a block of adjacent directions ends; directions - closer than this share a block. - null_rtol: Relative-to-top-singular-value tolerance below which a direction - counts as numerically null. Defaults to ``None``, which resolves to - ``d_model * torch.finfo(S.dtype).eps`` per map, matching - :func:`torch.linalg.matrix_rank`'s default tolerance. - - Returns: - A :class:`HeadDecomposition` whose ``QK``/``OV`` fields hold a - :class:`HeadSVD` for each requested map. - - Raises: - ValueError: If ``layer`` or ``head`` is out of range, if ``which`` is - empty, a bare string, or contains an unknown entry, or if the model has - no ``blocks`` stack or ``blocks[layer]`` has no ``attn`` submodule (SSM, - mixer, linear-attention and AltUp blocks, encoder-decoder bridges). - """ - n_layers = model.cfg.n_layers - n_heads = model.cfg.n_heads - if not 0 <= layer < n_layers: - raise ValueError(f"layer must be in [0, {n_layers}), got {layer!r}") - if not 0 <= head < n_heads: - raise ValueError(f"head must be in [0, {n_heads}), got {head!r}") - if isinstance(which, str): - raise ValueError(f"which must be a sequence of map names such as ('QK',), got {which!r}") - requested = tuple(which) - if not requested: - raise ValueError("which must request at least one of 'QK' or 'OV'") - invalid = [entry for entry in requested if entry not in _VALID_WHICH] - if invalid: - raise ValueError(f"which entries must be in {_VALID_WHICH}, got {invalid!r}") - - W_Q_h, W_K_h, W_V_h, W_O_h = _head_weights(model, layer, head) - folded_ln = _folded_ln_state(model) - qk = None - ov = None - if "QK" in requested: - qk = _factored_head_svd( - W_Q_h, - W_K_h.transpose(-1, -2), - which="QK", - layer=layer, - head=head, - eps=eps, - null_rtol=null_rtol, - folded_ln=folded_ln, - ) - if "OV" in requested: - ov = _factored_head_svd( - W_V_h, - W_O_h, - which="OV", - layer=layer, - head=head, - eps=eps, - null_rtol=null_rtol, - folded_ln=folded_ln, - ) - return HeadDecomposition(layer=layer, head=head, QK=qk, OV=ov) - - -def _folded_ln_state(model) -> bool: - """Whether the model's weights currently carry a folded final LayerNorm. - - ``enable_compatibility_mode`` sets ``compatibility_mode`` before it processes - weights and skips processing entirely under ``no_processing``, so the flag alone - does not imply a fold. The fold happens only when the bridge has processed its - weights with ``fold_ln`` enabled *and* the adapter supports folding: adapters that - declare ``supports_fold_ln = False`` have ``fold_ln`` downgraded to ``False`` - inside ``ProcessWeights``, leaving ``_fold_ln_requested`` set while the norms stay - unfolded. - """ - processed = getattr(model, "_weights_processed", False) - adapter = getattr(model, "adapter", None) - fold_ln_requested = getattr(adapter, "_fold_ln_requested", True) - supports_fold_ln = getattr(adapter, "supports_fold_ln", True) - return bool(processed and fold_ln_requested and supports_fold_ln) - - -def _validate_bridge_compatibility(model) -> None: - """Reject a ``TransformerBridge`` whose ``W_U`` would give a silently wrong projection. - - Projecting an OV direction through ``W_U`` requires the final LayerNorm folded - into ``W_U``. This mirrors :func:`direct_logit_attribution`, the other - unembedding-touching analysis tool, but the check here is stricter: it reads the - bridge's recorded weight-processing state rather than the ``compatibility_mode`` - flag. ``enable_compatibility_mode`` sets that flag before it processes weights and - skips processing entirely under ``no_processing``, so the flag can be ``True`` while - ``W_U`` is still unfolded. The fold only happens once the bridge processes its - weights with ``fold_ln`` enabled, which is what ``_weights_processed``, the - adapter's ``_fold_ln_requested``, and the adapter's ``supports_fold_ln`` record: - adapters that declare no fold support have ``fold_ln`` downgraded to ``False`` - inside ``ProcessWeights``, so their norms stay unfolded even though the bridge - reports processed weights. No hybrid-architecture restriction applies: - projecting a rank-1 OV direction through ``W_U`` does not depend on the block-layout - assumptions that check exists for elsewhere. - """ - # Lazy import - keeps the module importable without the bridge as a hard dependency. - from transformer_lens.model_bridge import TransformerBridge - - if not isinstance(model, TransformerBridge): - return - if not _folded_ln_state(model): - raise ValueError( - "Projecting an OV direction through W_U on a TransformerBridge requires the " - "final LayerNorm folded into W_U, which only happens once the bridge has " - "processed its weights with fold_ln enabled. Call " - "`model.enable_compatibility_mode()` (its default folds LayerNorm) after " - "loading the bridge, then retry." - ) - - -def _validate_decomposition_matches_model(model, head_svd: HeadSVD) -> None: - """Refuse a decomposition built under a different folded-LayerNorm state than the model. - - ``enable_compatibility_mode`` folds ``ln1`` into ``W_V`` and centres ``W_O``/``W_U``, - so a :class:`HeadSVD` decomposed before the call describes a different OV map than the - model now computes: the cached ``U``/``S``/``V`` are stale, and the returned readout or - patch would be silently wrong rather than raise a shape error. This guard is orthogonal - to :func:`_validate_bridge_compatibility` (which requires the fold to be present): here - the model may be in either state, only mismatched from the decomposition's. - """ - current = _folded_ln_state(model) - if current != head_svd.folded_ln: - raise ValueError( - f"This HeadSVD was decomposed with folded_ln={head_svd.folded_ln} but the " - f"model's weights now have folded_ln={current}; the cached singular vectors " - f"describe a different OV map. Re-run decompose_head under the current state, " - f"then retry." - ) - - -def vocab_readout(model, head_svd: HeadSVD, *, k: int = 10) -> Float[torch.Tensor, "d_vocab k"]: - """Project the top-k OV output directions through the unembedding. - - Requires ``head_svd.which == "OV"``: QK produces no write direction to project - (see the module docstring). On a ``TransformerBridge``, the final LayerNorm must - be folded into ``W_U``, which requires the bridge to have processed its weights - with ``fold_ln`` enabled on an adapter that supports folding. - - Does not call ``head_svd.require_isolated``: a degenerate direction's vocab - readout is still a well-defined projection, unlike a per-direction causal claim, - so it is not gated here. The contract that no direction is reported without a - passing causal patch is enforced by :func:`patch_along_directions`. - - Args: - model: A ``TransformerBridge`` with the final LayerNorm folded into ``W_U``; - only its ``W_U`` is read. - head_svd: An OV :class:`HeadSVD` from :func:`decompose_head`. - k: Number of top singular directions to project. - - Returns: - ``W_U.T @ head_svd.V[:, :k]``, shape ``[d_vocab, k]``: column i is - direction i's projection through the unembedding. - - Raises: - ValueError: If ``head_svd.which != "OV"``, if ``k`` is not in - ``(0, rank]``, if ``head_svd`` was decomposed under a different - folded-LayerNorm state than ``model`` now has, or if ``model`` is a - ``TransformerBridge`` whose weights do not carry a folded final - LayerNorm. - """ - if head_svd.which != "OV": - raise ValueError(f"vocab_readout requires an OV HeadSVD, got which={head_svd.which!r}") - _validate_decomposition_matches_model(model, head_svd) - rank = head_svd.V.shape[1] - if not 0 < k <= rank: - raise ValueError(f"k must be in (0, {rank}], got {k!r}") - _validate_bridge_compatibility(model) - # Promote V to W_U's dtype rather than forcing float32: matmul does not promote its - # operands, so a bare .float() raises a dtype mismatch on any bf16, fp16, or fp64 model. - W_U = model.W_U - return W_U.T @ head_svd.V[:, :k].to(W_U.dtype) - - -@dataclass -class LogitSignature: - """Rank-1-reconstruction logit effect for one OV direction, per requested token. - - Attributes: - direction: Which ``HeadSVD`` column this reconstructs. - values: Signed logit contribution, aligned with the requested tokens. - """ - - direction: int - values: Float[torch.Tensor, "token"] - - -def logit_signature( - model, - head_svd: HeadSVD, - direction: int, - tokens: Union[int, Sequence[int], torch.Tensor], -) -> LogitSignature: - """Signed logit effect of one OV direction's rank-1 reconstruction on the given tokens. - - Pure weight-space computation: reconstructs the head's OV output along a single - singular direction (``S[direction] * V[:, direction]``, never ``U`` - see the - module docstring) and projects it through ``W_U`` restricted to ``tokens``. Runs - no forward pass and builds no cache. - - Args: - model: A ``TransformerBridge`` with the final LayerNorm folded into ``W_U``; - only its ``W_U`` is read. - head_svd: An OV :class:`HeadSVD` from :func:`decompose_head`. - direction: Column index of the singular direction to reconstruct. - tokens: Token id(s) to read the logit effect for. - - Returns: - A :class:`LogitSignature` with one value per requested token. - - Raises: - ValueError: If ``head_svd.which != "OV"``, if ``direction`` is not in - ``[0, rank)``, if ``head_svd`` was decomposed under a different - folded-LayerNorm state than ``model`` now has, or if ``model`` is a - ``TransformerBridge`` whose weights do not carry a folded final - LayerNorm. - DegenerateDirectionError: If ``direction`` is not attributable alone (see - :meth:`HeadSVD.require_isolated`). - """ - if head_svd.which != "OV": - raise ValueError(f"logit_signature requires an OV HeadSVD, got which={head_svd.which!r}") - _validate_decomposition_matches_model(model, head_svd) - # Bounds-check before indexing: a negative direction would wrap into V/rank_report and a - # too-large one would raise a bare IndexError, both hiding a caller mistake as a wrong or - # cryptic result rather than a clear refusal. - rank = head_svd.V.shape[1] - direction = int(direction) - if not 0 <= direction < rank: - raise ValueError(f"direction index {direction} out of range [0, {rank})") - head_svd.require_isolated(direction) - _validate_bridge_compatibility(model) - token_ids = torch.as_tensor(tokens, dtype=torch.long).reshape(-1) - # Match the rank-1 reconstruction to W_U's dtype for the projection: matmul does not - # promote its operands, so a float32 reconstruction breaks on bf16, fp16, or fp64 models. - W_U = model.W_U - reconstruction = (head_svd.S[direction] * head_svd.V[:, direction]).to(W_U.dtype) - values = reconstruction @ W_U[:, token_ids] - return LogitSignature(direction=direction, values=values) - - -@dataclass -class ActivationProjection: - """Per-position coefficients of a head's actual output in its OV output basis. - - Attributes: - head_svd: The OV decomposition this was projected against. - coefficients: ``[pos, rank]``; ``coefficients[:, i]`` is the signed amount of - singular direction ``i`` (``head_svd.V[:, i]``) present in the head's actual - output at each position. Summing ``coefficients[:, i] * head_svd.V[:, i]`` - over ``i`` reconstructs the head's real per-position output to numerical - precision, since ``V``'s columns are orthonormal and this projects onto the - exact basis the head writes in. - str_tokens: Tokenized prompt, aligned with the position axis, for display. - """ - - head_svd: HeadSVD - coefficients: Float[torch.Tensor, "pos rank"] - str_tokens: List[str] - - -def project_activations( - model, head_svd: HeadSVD, prompt: Union[str, torch.Tensor] -) -> ActivationProjection: - """Project a head's actual per-position output onto its OV singular directions. - - Requires ``head_svd.which == "OV"``: this projects onto the write/output basis - ``V``, and QK has no such vector (see the module docstring). Runs a real forward - pass with ``use_attn_result`` enabled to read the per-head output - (``hook_result``), then projects it onto ``head_svd.V``. Restores the model's - prior ``use_attn_result`` setting afterward, since flipping that config flag as a - side effect of a read-only analysis call would surprise a caller who already had - hooks or a cache built around its prior state. - - Args: - model: A ``TransformerBridge``. - head_svd: An OV :class:`HeadSVD` from :func:`decompose_head`. - prompt: A single prompt (not a batch): a string or a ``[pos]`` or ``[1, pos]`` - token tensor. - - Returns: - An :class:`ActivationProjection` with the per-position coefficients. - - Raises: - ValueError: If ``head_svd.which != "OV"``, if ``head_svd`` was decomposed under - a different folded-LayerNorm state than ``model`` now has, if ``prompt`` - is not a single prompt (a tensor that is not 1-D, or is 2-D with a leading - dimension greater than one), if the model is in training mode, or if the - attention bridge accepted ``use_attn_result`` but never fired ``hook_result``. - NotImplementedError: If the model's attention adapter exposes no per-head result, - so ``set_use_attn_result(True)`` cannot fork the attention output. - """ - if head_svd.which != "OV": - raise ValueError( - f"project_activations requires an OV HeadSVD, got which={head_svd.which!r}" - ) - _validate_decomposition_matches_model(model, head_svd) - # A single prompt is a string or a 1-D [pos] / 2-D [1, pos] token tensor. Reject any - # other rank here, before the forward: a 0-D tensor would raise a bare IndexError from - # the shape probe below, and a [1, 1, pos] tensor would slip past a leading-dimension - # test and only fail after the forward. A list of prompt strings reveals its batch size - # only once tokenized, so the post-cache check below still guards that path. - if isinstance(prompt, torch.Tensor) and ( - prompt.ndim not in (1, 2) or (prompt.ndim == 2 and prompt.shape[0] > 1) - ): - raise ValueError( - "project_activations requires a single prompt, got a token tensor of shape " - f"{tuple(prompt.shape)}; pass a [pos] or [1, pos] tensor, or a single string." - ) - require_eval_mode(model, operation="project_activations()") - # Cache only the one hook this reads. run_with_cache otherwise retains every hook point of - # the forward pass (about 1 GB against 19 MB on a 512-token gpt2-small prompt) to read a - # single head's output. - hook_name = f"blocks.{head_svd.layer}.attn.hook_result" - previous = getattr(model.cfg, "use_attn_result", False) - model.set_use_attn_result(True) - try: - _, cache = model.run_with_cache(prompt, names_filter=lambda name: name == hook_name) - finally: - model.set_use_attn_result(previous) - if hook_name not in cache: - raise _hook_result_never_fired(model, head_svd.layer, "project_activations()") - result = cache[hook_name][..., head_svd.head, :] - if result.shape[0] != 1: - raise ValueError( - f"project_activations requires a single prompt, got batch={result.shape[0]}" - ) - result = result.squeeze(0).to(head_svd.V.dtype) - coefficients = result @ head_svd.V - str_tokens = model.to_str_tokens(prompt) - return ActivationProjection(head_svd=head_svd, coefficients=coefficients, str_tokens=str_tokens) - - -def _validate_retained_blocks(head_svd: HeadSVD, retained: Sequence[int]) -> None: - """Raise if ``retained`` splits a degenerate block instead of keeping it whole or empty. - - A degenerate block's members are defined only up to a rotation (or, for a null - block, arbitrary null-space vectors), so attributing a causal effect to part of the - block while dropping the rest would let a caller route around the guard - :meth:`HeadSVD.require_isolated` already enforces per direction. - """ - retained_set = set(retained) - for block in head_svd.degenerate_blocks(): - block_set = set(block) - overlap = retained_set & block_set - if overlap and overlap != block_set: - raise DegenerateDirectionError( - f"retained directions {sorted(overlap)} split block {sorted(block_set)} of " - f"the {head_svd.which} SVD of head L{head_svd.layer}H{head_svd.head}, whose " - f"members are not separated by a relative gap of eps={head_svd.eps:g} or are " - f"jointly null. Keep or ablate the whole block, not part of it." - ) - - -def _resolve_retained( - head_svd: HeadSVD, keep: Optional[Sequence[int]], ablate: Optional[Sequence[int]] -) -> List[int]: - """Resolve ``keep``/``ablate`` to the sorted list of retained direction indices. - - Exactly one of ``keep``/``ablate`` must be given; ``ablate``'s complement over the - map's full rank becomes the retained set. Every supplied index is coerced to ``int`` - and bounds-checked against ``[0, rank)`` before use, so a negative index raises rather - than wrapping into ``V[:, ...]`` and an out-of-range ``ablate`` raises rather than - silently subtracting nothing from the complement. Raises - :class:`DegenerateDirectionError` if the result would split a degenerate block (see - :func:`_validate_retained_blocks`). - """ - if (keep is None) == (ablate is None): - raise ValueError("patch_along_directions requires exactly one of keep or ablate") - rank = head_svd.V.shape[1] - - def _checked(indices: Sequence[int], name: str) -> List[int]: - resolved: List[int] = [] - for raw in indices: - index = int(raw) - if not 0 <= index < rank: - raise ValueError(f"{name} index {index} out of range [0, {rank})") - resolved.append(index) - return resolved - - if keep is not None: - retained = sorted(set(_checked(keep, "keep"))) - else: - assert ablate is not None - ablate_set = set(_checked(ablate, "ablate")) - retained = [i for i in range(rank) if i not in ablate_set] - _validate_retained_blocks(head_svd, retained) - return retained - - -def _make_subspace_hook(head: int, projector: Float[torch.Tensor, "d_model d_model"]): - """Build a ``hook_result`` hook that reconstructs one head's output onto ``span(projector)``. - - Leaves every other head's slice of the ``[batch, pos, head_index, d_model]`` tensor - untouched. Clones before mutating so the hook never writes into the activation the - forward pass itself is still using. - """ - - def hook_fn(activation: torch.Tensor, hook) -> torch.Tensor: - activation = activation.clone() - # The projector is drawn on CPU (QR is unimplemented on MPS and a CUDA generator - # cannot feed a CPU randn), so move it to the activation's device and dtype here. - activation[:, :, head, :] = activation[:, :, head, :] @ projector.to( - device=activation.device, dtype=activation.dtype - ) - return activation - - return hook_fn - - -@dataclass -class PatchResult: - """Result of causally patching a head's output onto a chosen OV singular subspace. - - Attributes: - head_svd: The OV decomposition patched against. - retained: Direction indices whose span the head's output was reconstructed - onto; the complement was zeroed. - original_metric: Metric value on the unmodified prompt. - patched_metric: Metric value after the subspace reconstruction. - delta_metric: ``patched_metric - original_metric``. - baseline_delta_metric: the mean of the per-draw ``delta_metric`` magnitudes over - several random control subspaces of the same width as ``retained``, each drawn - inside the head's own OV span ``span(V)`` rather than from the full residual - stream, so the control is the effect of an arbitrary same-size subspace of this - head's output rather than of an unrelated residual-stream direction. Averaging - the magnitudes rather than the signed deltas keeps this a typical control - effect that does not shrink when the controls mix sign, so it is non-negative. - gated: whether the retained subspace passed the causal test for the mode it was - expressed in, comparing ``abs(delta_metric)`` against ``baseline_delta_metric`` - (or an explicit threshold, if one was passed). For ``ablate`` (retain the complement), - removing a load-bearing subspace should move the metric more than removing an - arbitrary same-size one, so ``gated`` is ``abs(delta_metric) > threshold``. - For ``keep`` (retain only the given subspace), a subspace that reconstructs - the head's behavior should move the metric less than keeping an arbitrary - same-size one, so ``gated`` is ``abs(delta_metric) < threshold``. A single - "moved more than baseline" test cannot answer both, since ``keep=S`` and - ``ablate=complement(S)`` resolve to the same retained set. - """ - - head_svd: HeadSVD - retained: List[int] - original_metric: float - patched_metric: float - delta_metric: float - baseline_delta_metric: float - gated: bool - - -@torch.no_grad() -def patch_along_directions( - model, - head_svd: HeadSVD, - prompt: Union[str, torch.Tensor], - metric: Callable[[torch.Tensor], float], - *, - keep: Optional[Sequence[int]] = None, - ablate: Optional[Sequence[int]] = None, - threshold: Optional[float] = None, - rng: Optional[torch.Generator] = None, - n_baseline: int = _DEFAULT_N_BASELINE, -) -> PatchResult: - """Causally validate a claimed OV subfunction by reconstructing the head's output onto it. - - Requires ``head_svd.which == "OV"``: this reconstructs the write/output basis - ``V``, and QK has no such vector (see the module docstring). Runs the prompt with - ``use_attn_result`` enabled once unmodified, once with the head's ``hook_result`` - slice reconstructed onto ``span(head_svd.V[:, retained])``, and once per random - control subspace. Each control subspace is drawn *inside the head's own OV span* - ``span(V)`` (not from the full residual stream, where a width-``w`` random subspace - would keep only ``w/d_model`` of a head output that itself occupies only - ``rank/d_model`` of the stream; an in-span control of width ``w`` keeps ``w/rank`` - of it), so a moved metric is compared against the effect of an arbitrary - subspace of this head's output of the same width. The per-draw control delta magnitudes - are averaged over ``n_baseline`` draws so one lucky or unlucky draw does not decide the - gate, and so controls that mix sign do not cancel into a smaller threshold. Averaging - narrows but does not remove the seed dependence of the threshold; see the ``n_baseline`` - entry below. Restores the model's prior ``use_attn_result`` setting afterward. - - The gate's success condition depends on the mode the caller expressed, because - ``keep=S`` and ``ablate=complement(S)`` resolve to the same retained set and a single - "moved more than the control" test would answer only the ``ablate`` question. See - :attr:`PatchResult.gated`. - - Args: - model: A ``TransformerBridge``. - head_svd: An OV :class:`HeadSVD` from :func:`decompose_head`. - prompt: A single prompt: a string or a ``[1, pos]`` token tensor. - metric: A function from the model's logits to a scalar. - keep: Direction indices to retain; the rest are zeroed. Exactly one of - ``keep``/``ablate`` must be given. - ablate: Direction indices to zero; the rest are retained. - threshold: Explicit gate threshold. Defaults to ``None``, which uses - ``baseline_delta_metric`` (already a magnitude) instead. - rng: Optional generator for the random control subspaces, for reproducibility. - Defaults to a generator seeded with ``_DEFAULT_BASELINE_SEED`` so a bare - call is reproducible rather than drawing from the global RNG. - n_baseline: Number of random in-span control subspaces to average the baseline - delta over. Must be at least 1. The per-draw control magnitudes are - heavy-tailed, so the averaged threshold still varies with the seed and a - direction whose delta sits near it can gate either way; pass an explicit - ``rng`` for a reproducible verdict and raise ``n_baseline`` when the verdict - is close to the threshold. Each draw costs one forward pass. - - Returns: - A :class:`PatchResult` describing the patched, baseline, and original metrics. - - Raises: - ValueError: If ``head_svd.which != "OV"``, if ``head_svd`` was decomposed under a - different folded-LayerNorm state than ``model`` now has, if ``keep``/``ablate`` - are both given or both omitted, if any index is out of ``[0, rank)``, if the - retained set is empty (``keep=[]`` or ``ablate`` over the full rank) or spans the - full rank (``keep`` over every direction) and no explicit ``threshold`` is - supplied, if ``n_baseline < 1``, if the model is in training mode, or if the - attention bridge accepted ``use_attn_result`` but never fired ``hook_result`` - (checked after the patched forward, before any control forward). - NotImplementedError: If the model's attention adapter exposes no per-head result, - so ``set_use_attn_result(True)`` cannot fork the attention output. - DegenerateDirectionError: If the retained directions split a degenerate block - (see :func:`_validate_retained_blocks`). - """ - if head_svd.which != "OV": - raise ValueError( - f"patch_along_directions requires an OV HeadSVD, got which={head_svd.which!r}" - ) - if n_baseline < 1: - raise ValueError(f"n_baseline must be at least 1, got {n_baseline}") - _validate_decomposition_matches_model(model, head_svd) - retained = _resolve_retained(head_svd, keep, ablate) - # _resolve_retained has confirmed exactly one of keep/ablate is set, so the mode the - # caller expressed is unambiguous and selects the gate's success condition (see below). - mode = "keep" if keep is not None else "ablate" - V = head_svd.V - rank = V.shape[1] - if (not retained or len(retained) == rank) and threshold is None: - # An empty retained set reconstructs the head onto the zero subspace; a full-rank - # retained set reconstructs it onto span(V) itself (V V^T). In both cases the kept - # projector and the equal-width random control projector coincide: for the empty set - # both are the zero projector, and for the full set every width-rank in-span control - # is V Q Q^T V^T = V V^T. The two deltas then tie by construction and the gate - # compares a quantity against itself. Require an explicit threshold to gate either - # degenerate set on purpose. - raise ValueError( - "keep/ablate retains either no directions or every direction, so the " - "reconstruction's kept projector coincides with the random control projector " - "and their deltas tie by construction; pass an explicit threshold to gate this." - ) - - require_eval_mode(model, operation="patch_along_directions()") - - if rng is None: - rng = torch.Generator().manual_seed(_DEFAULT_BASELINE_SEED) - - width = len(retained) - kept_projector = V[:, retained] @ V[:, retained].transpose(-2, -1) - - hook_name = f"blocks.{head_svd.layer}.attn.hook_result" - previous = getattr(model.cfg, "use_attn_result", False) - model.set_use_attn_result(True) - try: - original_metric = float(metric(model(prompt))) - kept_hook = _make_subspace_hook(head_svd.head, kept_projector) - kept_hook_calls: List[bool] = [] - - def recording_hook(activation: torch.Tensor, hook) -> torch.Tensor: - kept_hook_calls.append(True) - return kept_hook(activation, hook) - - patched_logits = model.run_with_hooks(prompt, fwd_hooks=[(hook_name, recording_hook)]) - # A bridge can accept use_attn_result without ever firing hook_result; gating that - # no-op patch against the controls would report a verdict about nothing. - if not kept_hook_calls: - raise _hook_result_never_fired(model, head_svd.layer, "patch_along_directions()") - patched_metric = float(metric(patched_logits)) - - baseline_deltas: List[float] = [] - for _ in range(n_baseline): - # Draw a random width-of-rank subspace inside the head's own OV span. The QR - # is drawn on CPU (unimplemented on MPS, and a CUDA generator cannot feed a - # CPU randn); the columns are mapped into span(V) on V's device, and the hook - # moves the finished projector to the activation's device. - random_rank = torch.randn(rank, rank, generator=rng, dtype=V.dtype) - random_basis, _ = torch.linalg.qr(random_rank) - baseline_directions = V @ random_basis[:, :width].to(V.device) - baseline_projector = baseline_directions @ baseline_directions.transpose(-2, -1) - baseline_logits = model.run_with_hooks( - prompt, - fwd_hooks=[(hook_name, _make_subspace_hook(head_svd.head, baseline_projector))], - ) - baseline_deltas.append(float(metric(baseline_logits)) - original_metric) - finally: - model.set_use_attn_result(previous) - - delta_metric = patched_metric - original_metric - baseline_delta_metric = sum(abs(d) for d in baseline_deltas) / len(baseline_deltas) - gate_threshold = baseline_delta_metric if threshold is None else threshold - # keep retains only the claimed subspace, so it passes when it reconstructs the head's - # behavior better than an arbitrary same-width one (moves the metric less); ablate - # removes it, so it passes when removing it matters more than removing an arbitrary - # same-width one (moves the metric more). - if mode == "keep": - gated = abs(delta_metric) < gate_threshold - else: - gated = abs(delta_metric) > gate_threshold - - return PatchResult( - head_svd=head_svd, - retained=retained, - original_metric=original_metric, - patched_metric=patched_metric, - delta_metric=delta_metric, - baseline_delta_metric=baseline_delta_metric, - gated=gated, - ) diff --git a/transformer_lens/utilities/__init__.py b/transformer_lens/utilities/__init__.py index d06fe43f58..99cea18ba6 100644 --- a/transformer_lens/utilities/__init__.py +++ b/transformer_lens/utilities/__init__.py @@ -62,10 +62,12 @@ from .parameter_swap import temporarily_swap_parameter from .slice import Slice, SliceInput from .statistics import ( + SuccessRateInterval, bootstrap_ci, derive_generator, sign_flip_permutation_pvalue, standard_error, + success_rate_ci, ) from .tensors import ( check_structure, diff --git a/transformer_lens/utilities/statistics.py b/transformer_lens/utilities/statistics.py index 1e9b190724..4546a0cb24 100644 --- a/transformer_lens/utilities/statistics.py +++ b/transformer_lens/utilities/statistics.py @@ -1,9 +1,9 @@ """Model-free statistics over a per-example axis of an analysis result. -Pure torch, no model access: standard errors, a vectorized percentile bootstrap -and a paired sign-flip permutation test, plus the seeded-generator convention -shared with sparse probing so every consumer derives independent, reproducible -streams the same way. +No model access: standard errors, a vectorized percentile bootstrap, a paired +sign-flip permutation test and an exact Clopper-Pearson interval for success +rates, plus the seeded-generator convention shared with sparse probing so every +consumer derives independent, reproducible streams the same way. """ from __future__ import annotations @@ -11,7 +11,8 @@ import hashlib import itertools import math -from typing import Optional, Tuple +from dataclasses import dataclass +from typing import Optional, Sequence, Tuple import torch @@ -67,8 +68,7 @@ def bootstrap_ci( A percentile bootstrap cannot express uncertainty a sample does not contain: a constant vector resamples to itself and the interval collapses to a point whatever ``n`` is, and intervals on 4-8 samples are wide and skewed. For - binary success rates prefer an exact interval - (:func:`~transformer_lens.tools.analysis.jacobian_lens_causal_swap_benchmark.success_rate_ci`). + binary success rates prefer an exact interval (:func:`success_rate_ci`). """ if statistic not in _STATISTICS: raise ValueError(f"statistic must be one of {_STATISTICS}, got {statistic!r}") @@ -151,3 +151,111 @@ def _count_hits(null: torch.Tensor, observed: torch.Tensor, alternative: str) -> else: hit = null <= observed return hit.sum(dim=-1).to(null.dtype) + + +@dataclass(frozen=True) +class SuccessRateInterval: + """An exact Clopper-Pearson confidence interval around a success rate. + + ``n_trials`` and ``n_successes`` are the counts the interval was computed from, so a + reader can see the denominator behind the bounds rather than inferring it. + """ + + point_estimate: float + ci_low: float + ci_high: float + n_trials: int + n_successes: int + confidence: float + + +# Each bisection step halves the bracket, so 200 steps resolve the bound far below the +# precision any reported rate needs. +_CLOPPER_PEARSON_STEPS = 200 + + +def _binomial_cdf(k: int, n: int, p: float) -> float: + """Exact ``P(X <= k)`` for ``X ~ Binomial(n, p)``. + + Accumulates the pmf by recurrence (``term_{i} = term_{i-1} * (n - i + 1) / i * p / (1 - p)``) + rather than forming ``comb(n, i)`` and ``p ** i`` separately, which overflow at large ``n``. + """ + if k < 0: + return 0.0 + if k >= n: + return 1.0 + if p <= 0.0: + return 1.0 + if p >= 1.0: + return 0.0 + term = (1.0 - p) ** n + total = term + odds = p / (1.0 - p) + for i in range(1, k + 1): + term *= (n - i + 1) / i * odds + total += term + return total + + +def _clopper_pearson_bound(n_successes: int, n_trials: int, tail: float, *, upper: bool) -> float: + """Solves one Clopper-Pearson bound by bisecting the exact binomial CDF. + + The lower bound solves ``P(X >= k) = tail`` and the upper bound solves ``P(X <= k) = + tail``. ``P(X >= k)`` rises with ``p`` while ``P(X <= k)`` falls, so the two bounds + bracket from opposite sides; reversing that collapses every interval to ``[0, 1]``. + """ + if upper and n_successes == n_trials: + return 1.0 + if not upper and n_successes == 0: + return 0.0 + low, high = 0.0, 1.0 + for _ in range(_CLOPPER_PEARSON_STEPS): + midpoint = (low + high) / 2.0 + if upper: + # P(X <= k) decreases in p, so a probability above the tail puts the bound below + # the midpoint. + if _binomial_cdf(n_successes, n_trials, midpoint) > tail: + low = midpoint + else: + high = midpoint + else: + # P(X >= k) increases in p, so a probability above the tail puts the bound above + # the midpoint. + if 1.0 - _binomial_cdf(n_successes - 1, n_trials, midpoint) > tail: + high = midpoint + else: + low = midpoint + return (low + high) / 2.0 + + +def success_rate_ci( + successes: Sequence[bool], + *, + confidence: float = 0.95, +) -> SuccessRateInterval: + """Computes an exact Clopper-Pearson confidence interval for a success rate. + + A percentile bootstrap cannot express uncertainty about an all-failure or all-success + sample: every resample of an all-zero vector is all zeros, so the interval collapses to + ``[0, 0]`` and reports the same certainty for one trial as for a thousand. The exact + interval instead widens as the trial count shrinks, giving ``[0.0, 0.459]`` for zero + successes out of six. + + Deterministic by construction: there is no resampling and no seed. + + Raises: + ValueError: If ``successes`` is empty. + """ + if len(successes) == 0: + raise ValueError("successes must be a non-empty sequence") + n_trials = len(successes) + n_successes = sum(1 for success in successes if success) + tail = (1.0 - confidence) / 2.0 + return SuccessRateInterval( + point_estimate=n_successes / n_trials, + ci_low=_clopper_pearson_bound(n_successes, n_trials, tail, upper=False), + ci_high=_clopper_pearson_bound(n_successes, n_trials, tail, upper=True), + n_trials=n_trials, + n_successes=n_successes, + confidence=confidence, + )