Skip to content

fix(bridge): derive position_ids from attention_mask for left-padded input - #1610

Open
sohv wants to merge 1 commit into
TransformerLensOrg:dev-4.xfrom
sohv:fix/bridge-left-padding-positions
Open

fix(bridge): derive position_ids from attention_mask for left-padded input#1610
sohv wants to merge 1 commit into
TransformerLensOrg:dev-4.xfrom
sohv:fix/bridge-left-padding-positions

Conversation

@sohv

@sohv sohv commented Aug 5, 2026

Copy link
Copy Markdown

Description

Fixes #1609.

TransformerBridge.forward() did not derive position_ids from a supplied attention_mask, so left-padded input silently got the wrong absolute positions — no error, no NaN, just wrong logits and a wrong loss.

On gpt2, one prompt, mask supplied:

n_pad HT loss Bridge loss (before) HT drift Bridge drift
0 4.503170 4.503170
1 4.503169 13.594296 1.4e-06 9.09
3 4.503169 11.154946 9.5e-07 6.65
8 4.503169 10.787075 1.4e-06 6.28

Right padding was never affected (drift ≤ 9.5e-07) — causality already protects it.

transformer_bridge.py derived position_ids only for batched list input, so pre-tokenized tensors fell through to HF's plain arange and the padding offset was never removed. This extends the same correction that branch already applies. An explicitly supplied position_ids still wins.

Two consequences this also fixes:

  • The bridge was inconsistent with itself — the same batch gave different logits depending on whether it was passed as strings or token IDs (max |logit diff| 4.142e+01).
  • enable_compatibility_mode(), which documents "HookedTransformer-equivalent numerics", matched HT exactly on unpadded input (0.000e+00) but diverged on left-padded input.

Tests

Adds tests/integration/model_bridge/test_left_padding_positions.py: logit invariance under both padding sides, the same property in compatibility mode, and agreement between the derived and an explicitly supplied position_ids.

Red-before / green-after: 9 passed with the fix, 5 failed without it. The right-padding cases are controls — they pass in both states, so the tests are specific to the bug rather than to padding in general.

These sit in the integration tier rather than the unit tier deliberately: left padding produces a fully masked query row, which the Native attention path turns into NaN until the masked-softmax fix in #1608 lands, so boot_native cannot express the property yet.

Verification

  • New tests: 9 passed / 5 failed without the fix
  • tests/unit/model_bridge + tests/unit/test_tokenizer_padding_side.py: 3955 passed, 27 skipped, 10 xfailed
  • pycln / isort / black clean; mypy clean

Relationship to #1607 / #1608

Independent bugs on the same path that compound. This is measured across four states (gpt2, aggregate loss on a left-padded batch; HT reference 4.814578):

state batch loss max |logit diff|
dev-4.x 7.254170 1.361e+02
#1608 alone 7.411356 1.361e+02
this PR alone 6.688155 9.918e-05
both 4.814578 9.918e-05

This PR fixes the logits; #1608 fixes the loss aggregation. Batched loss is only correct with both, so reviewing this one in isolation will still show a wrong aggregate. No file overlap, so they merge in either order.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Checklist:

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

@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.

Thanks for both discovering and resolving this bug @sohv! Great work. Just a couple review comments below, let me know if you have any questions

# than left to HF's default arange. HookedTransformer does this via
# pos_embed; without it the bridge silently returns wrong logits.
if (
attention_mask is not None

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.

A manual forward(new_token, attention_mask=<full mask>, past_key_values=cache) with left padding returns logits, but raises RuntimeError: The size of tensor a (19) must match the size of tensor b (10) because the derived position_ids spans past+new while input_ids is only the new token. Can the derivation be limited to the tokens actually being passed, the way get_offset_position_ids does it (utilities/tensors.py:131), with a test covering a cached step?

and "position_ids" not in kwargs
and not _is_inputs_embeds
and attention_mask.ndim == 2
and bool((attention_mask[:, 0] == 0).any())

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 gate tests only column 0, so a mask with an interior gap and no leading padding still diverges from HookedTransformer. I measured 3.631e+00 on gpt2 in compat mode against a 0.000e+00 unpadded control. Would it be possible to widen this to any mask with a gap?

…input

TransformerBridge.forward() did not derive position_ids from a supplied
attention_mask, so masked-out tokens silently shifted the absolute position of
every real token after them — no error, no NaN, just wrong logits and a wrong
loss. On gpt2 the loss for one prompt moved from 4.503170 unpadded to 11.154946
with three left pads, while HookedTransformer stays invariant (drift ~1e-06).
Right padding was never affected, since causality already protects it.

transformer_bridge.py derived position_ids only for batched *list* input, so
pre-tokenized tensors fell through to HF's plain arange and the offset was never
removed. This reuses utils.get_offset_position_ids — the same helper PosEmbed
and AbstractAttention already use — so the bridge shares HookedTransformer's
position derivation rather than paralleling it. An explicitly supplied
position_ids still wins, and an all-ones mask reduces to arange, so this is a
no-op when there is no padding.

The derivation is offset by any cached prefix: with past_key_values the mask
spans past+new while input_ids holds only the new tokens, so positions are
sliced back to the tokens actually being passed.

The bridge was also inconsistent with itself before this — the same batch gave
different logits depending on whether it was passed as strings or token IDs
(max |logit diff| 4.142e+01) — and enable_compatibility_mode(), which documents
"HookedTransformer-equivalent numerics", diverged on left-padded input while
matching exactly on unpadded input.

Adds integration regression tests: logit invariance under both padding sides,
the same property in compatibility mode, agreement with the shared helper's
derivation, precedence of an explicit position_ids, interior mask gaps, and a
cached decode step. Right-padding cases are controls that pass with and without
the fix. They live in the integration tier because left padding produces a fully
masked query row, which the Native attention path turns into NaN until the
masked-softmax fix in TransformerLensOrg#1608 lands.

Fixes TransformerLensOrg#1609.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sohv
sohv force-pushed the fix/bridge-left-padding-positions branch from c37a8d8 to cdc2af2 Compare August 5, 2026 21:35
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.

2 participants