Skip to content

Add subspace interchange interventions - #1892

Merged
jlarson4 merged 2 commits into
TransformerLensOrg:devfrom
dipo101:subspace-interchange
Oct 13, 2026
Merged

jlarson4 merged 2 commits into
TransformerLensOrg:devfrom
dipo101:subspace-interchange

Conversation

@dipo101

@dipo101 dipo101 commented Oct 13, 2026

Copy link
Copy Markdown

Description

Part 1 of 3 for #1879.

Adds tools.analysis.interchange: an intervention that sets a base input's activation inside an orthonormal subspace to its value on a source input, at one hook point and optionally one position per example. Also adds difference-of-means, probe and random bases, a norm-matched random-subspace placebo, tests and a docs page.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Checklist:

  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have not rewritten tests relating to key interfaces which would affect backward compatibility

Add transformer_lens.tools.analysis.interchange: an intervention that sets a
base input's activation inside an orthonormal subspace to its value on a
source input, at one hook point and optionally one position per example.
It comes with difference-of-means, probe and random bases to intervene on
and a norm-matched random-subspace placebo, plus unit tests, GPT-2
integration tests and a docs page.

Part of TransformerLensOrg#1879.

@koriyoshi2041 koriyoshi2041 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collect_activations rejects a hook that fires more than once, but run_with_intervention does not enforce the same single-fire contract. A model with the same HookPoint invoked twice silently applies the intervention twice (I reproduced hook_calls == 2, with no exception). That makes the source activation ambiguous and can change a later occurrence using an activation collected for the earlier one. Could run_with_intervention count hook invocations and raise after the forward pass, with a symmetric regression test alongside test_a_hook_that_fires_twice_is_refused?

@jlarson4 jlarson4 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first phase of the DAS issue looks pretty good! I have a couple comments, let me know if you have any questions

return torch.cat(out)


def run_with_intervention(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_with_intervention sends each base through one forward pass and unless the caller wraps it in no_grad, the full model's autograd graph persists. Measuring IIA over a realistic number of pairs will run out of memory long before collect_activations would. The cost row in analysis_tools.md also describes batched passes that this function doesn't make. Would it be possible to apply the same bounded-memory solution used for collection to evaluation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. run_with_intervention stays the only differentiable pass that DAS trains through (#1893 calls it once per micro-batch).

I also added intervened_logits, which mirrors collect_activations. Batched by batch_size, under no_grad, with logits returned on the CPU. The docs example and the cost row now use it, and I made sure #1893 and #1894 evaluate with it too.


def _select(act: torch.Tensor, positions: Optional[torch.Tensor]) -> torch.Tensor:
if positions is None:
if act.ndim != 2:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With positions set, _select indexes axis 1 whatever the activation's rank. On a 4-D hook such as attn.hook_pattern, that axis is heads, so collect_activations quietly returns a [batch, q, k] slice and the error only surfaces later, somewhere unrelated. Can we enforce the [batch, pos, d_model] contract on this branch the way the positions=None branch checks its shape?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Anything unexpected raises a ValueError with the shape named. There’s a test with a 4-D pattern-shaped hook.

Comment thread tests/unit/tools/test_interchange.py Outdated
def test_positions_select_one_vector_per_example():
model = _Toy(seq=True)
base, source = torch.randn(4, 3, D_MODEL), torch.randn(4, 3, D_MODEL)
last = torch.full((4,), 2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every test passes the same position for every row, so per-example indexing is never exercised. A _select that applies the first row's position to all rows passes the whole suite. Lets adjust at least one case to use different positions across rows.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I've made the position test now use a different position per row, [0, 2, 1, 2], on two toys. 1 reads only the last position, and one sums every position. That means every ro'ws read and write is checked. Trying to apply the first row’s position to every row now fails it.

@jlarson4 jlarson4 linked an issue Oct 13, 2026 that may be closed by this pull request
1 task done
run_with_intervention now raises unless the hook fires exactly once in the
forward pass, as collect_activations already did. With positions, the
activation must be [batch, pos, d_model] and positions must hold one index
per example; other shapes raise instead of slicing the wrong axis. Add
intervened_logits, which evaluates an intervention in batches without
gradients and returns CPU logits, so memory is bounded by batch_size. The
position tests now use a different position per row.
@dipo101

dipo101 commented Oct 13, 2026

Copy link
Copy Markdown
Author

@koriyoshi2041

Good catch, thanks. run_with_intervention now counts hook calls and raises unless the hook fires exactly once in the forward pass, which also catches a hook that does nto fire. The regression test checks both functions, for two calls and for zero, and the docstring is now saying task_logits must make one forward pass. Fixed in b450b36.

@koriyoshi2041

Copy link
Copy Markdown
Contributor

Verified at b450b366. The implementation now counts both zero and repeated firings after the forward pass, and the regression covers both collect_activations and run_with_intervention symmetrically. I also ran the focused file locally: 12 passed in tests/unit/tools/test_interchange.py. This resolves my earlier single-fire concern.

@jlarson4

Copy link
Copy Markdown
Collaborator

Looks good, moving on to the next part and merging

@jlarson4
jlarson4 merged commit ad42ad1 into TransformerLensOrg:dev Oct 13, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Proposal] Distributed Alignment Search (DAS) with trained controls

3 participants