Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions tests/unit/model_bridge/test_loss_attention_mask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Regression tests for padding-aware TransformerBridge causal loss."""

from __future__ import annotations

import pytest
import torch

from transformer_lens.config import TransformerBridgeConfig
from transformer_lens.model_bridge import TransformerBridge


def _bridge() -> TransformerBridge:
cfg = TransformerBridgeConfig(
d_model=32,
d_head=8,
n_heads=4,
n_layers=2,
n_ctx=6,
d_vocab=32,
d_mlp=64,
act_fn="gelu",
normalization_type="LN",
seed=7,
initializer_range=0.2,
)
return TransformerBridge.boot_native(cfg)


def _extract_loss(output: torch.Tensor | tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor:
return output[1] if isinstance(output, tuple) else output


@pytest.mark.parametrize("return_type", ["loss", "both"])
def test_forward_loss_ignores_masked_padding_tokens(return_type: str) -> None:
bridge = _bridge()
attention_mask = torch.tensor(
[
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 1, 1, 1],
]
)
token_batches = (
torch.tensor(
[
[1, 2, 3, 0, 0, 0],
[4, 5, 6, 7, 8, 9],
]
),
torch.tensor(
[
[1, 2, 3, 31, 30, 29],
[4, 5, 6, 7, 8, 9],
]
),
)

losses = []
for tokens in token_batches:
output = bridge(tokens, attention_mask=attention_mask, return_type=return_type)
loss = _extract_loss(output)
logits = bridge(tokens, attention_mask=attention_mask, return_type="logits")
expected = bridge.loss_fn(logits, tokens, attention_mask=attention_mask)

torch.testing.assert_close(loss, expected)
losses.append(loss)

torch.testing.assert_close(losses[0], losses[1])


def test_forward_loss_per_token_zeros_masked_transitions() -> None:
bridge = _bridge()
tokens = torch.tensor(
[
[1, 2, 3, 0, 0, 0],
[4, 5, 6, 7, 8, 9],
]
)
attention_mask = torch.tensor(
[
[1, 1, 1, 0, 0, 0],
[1, 1, 1, 1, 1, 1],
]
)

loss = bridge(
tokens,
attention_mask=attention_mask,
return_type="loss",
loss_per_token=True,
)
next_token_mask = torch.logical_and(attention_mask[:, :-1], attention_mask[:, 1:])

assert torch.count_nonzero(loss[~next_token_mask]) == 0


def test_forward_loss_is_finite_with_left_padding() -> 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.

Every assertion in the file is bridge-vs-bridge. A masked loss with the wrong denominator or mask shift would satisfy all four tests, this function in particular is value-blind. And the lm_utils.py:40 NaN-safety hunk has no test. As a regression check I reverted it to *= and all 4 tests stayed green (the native hunk alone keeps the logits finite). Could you add one anchor against an externally-computed value, staying inside the Bridge?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in df5f46d. The Bridge regression now computes the expected value independently with torch.nn.functional.cross_entropy over manually selected valid transitions instead of calling bridge.loss_fn. I also added a direct NaN-masking regression that fails with multiplication and passes with masked_fill.

bridge = _bridge()
tokens = torch.tensor(
[
[0, 0, 0, 1, 2, 3],
[4, 5, 6, 7, 8, 9],
]
)
attention_mask = torch.tensor(
[
[0, 0, 0, 1, 1, 1],
[1, 1, 1, 1, 1, 1],
]
)
position_ids = attention_mask.long().cumsum(-1) - 1
position_ids.masked_fill_(attention_mask == 0, 1)

logits = bridge(
tokens,
attention_mask=attention_mask,
position_ids=position_ids,
return_type="logits",
)
loss = bridge(
tokens,
attention_mask=attention_mask,
position_ids=position_ids,
return_type="loss",
)

assert torch.isfinite(logits).all()
assert torch.isfinite(loss)
17 changes: 15 additions & 2 deletions transformer_lens/model_bridge/bridge_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ def loss_fn(
"""Cross-entropy loss matching HookedTransformer's formula (log_softmax + gather)."""
if tokens.device != logits.device:
tokens = tokens.to(logits.device)
if attention_mask is not None and attention_mask.device != logits.device:
attention_mask = attention_mask.to(logits.device)
return lm_cross_entropy_loss(logits, tokens, attention_mask, per_token)

def _finalize_return(
Expand All @@ -353,6 +355,7 @@ def _finalize_return(
logits: Optional[torch.Tensor],
input_ids: Optional[torch.Tensor],
*,
attention_mask: Optional[torch.Tensor] = None,
is_audio_model: bool = False,
is_visual_model: bool = False,
inputs_embeds_was_used: bool = False,
Expand Down Expand Up @@ -384,7 +387,12 @@ def _finalize_return(
)
assert isinstance(logits, torch.Tensor), f"Expected logits tensor, got {type(logits)}"
assert input_ids is not None, "input_ids required for return_type='loss'"
return self.loss_fn(logits, input_ids, per_token=loss_per_token)
return self.loss_fn(

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 Bridge's native forward documents 2-D and 4-D masks as equivalent (sources/native/model.py:344-349), and they are. On the issue's own tiny bridge, a 4-D bool encoding of the same padding gives bit-identical logits to the 2-D form. But the loss path now distinguishes them: at this head the 2-D call returns the correct 3.8386409282684326 while the equivalent 4-D call raises RuntimeError: The size of tensor a (6) must match the size of tensor b (5) (on dev-4.x it returned a number). The same unvalidated forwarding is what turns the KV-cache case in lm_utils.py on line 40 from a number into a crash. Could you slice/reduce the mask to the scored [batch, pos] window before loss_fn?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in df5f46d. BridgeCore now normalizes 2-D, 4-D bool or additive, and cache plus new masks to the scored [batch, pos] window before calling loss_fn. Tests cover key-only and full-causal 4-D masks, bit-identical 2-D or 4-D logits, equal manually anchored losses, and rectangular cached 4-D masks.

logits,
input_ids,
attention_mask=attention_mask,
per_token=loss_per_token,
)
if return_type == "both":
if is_audio_model:
raise ValueError(
Expand All @@ -404,7 +412,12 @@ def _finalize_return(
)
assert isinstance(logits, torch.Tensor), f"Expected logits tensor, got {type(logits)}"
assert input_ids is not None, "input_ids required for return_type='both'"
loss = self.loss_fn(logits, input_ids, per_token=loss_per_token)
loss = self.loss_fn(
logits,
input_ids,
attention_mask=attention_mask,
per_token=loss_per_token,
)
return (logits, loss)
if return_type == "predictions":
assert self.tokenizer is not None, "Tokenizer required for return_type='predictions'"
Expand Down
3 changes: 3 additions & 0 deletions transformer_lens/model_bridge/sources/native/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ def forward(
scores = scores.masked_fill(block_mask, float("-inf"))

pattern = F.softmax(scores, dim=-1)
# Fully masked padding queries softmax to NaN; overwrite masked entries
# so those rows contribute a zero attention update instead of poisoning later layers.
pattern = pattern.masked_fill(block_mask, 0.0)

attn = torch.matmul(pattern, v).transpose(1, 2).contiguous().view(batch, seq, -1)
out = self.o(attn)
Expand Down
1 change: 1 addition & 0 deletions transformer_lens/model_bridge/transformer_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,7 @@ def forward(
return_type,
logits,
input_ids,
attention_mask=attention_mask,
is_audio_model=getattr(self.cfg, "is_audio_model", False),
is_visual_model=getattr(self.cfg, "is_visual_model", False),
inputs_embeds_was_used=_is_inputs_embeds,
Expand Down
4 changes: 2 additions & 2 deletions transformer_lens/utilities/lm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def lm_cross_entropy_loss(
tokens: Int[torch.Tensor, "batch pos"],
attention_mask: Optional[Int[torch.Tensor, "batch pos"]] = None,
per_token: bool = False,
) -> Union[Float[torch.Tensor, ""], Float[torch.Tensor, "batch pos"]]:
) -> Union[Float[torch.Tensor, ""], Float[torch.Tensor, "batch pos_minus_one"]]:

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.

pos_minus_one is a fresh jaxtyping name bound only by the return value, so it matches any length including pos, the exact off-by-one it exists to catch. The repo already spells this axis symbolically as "batch pos-1", which binds against pos from the arguments and so actually catches it. Can you use that form to match?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in df5f46d. The per-token loss return annotation now uses batch pos-1, binding the result length to the input pos dimension.

"""Cross entropy loss for the language model, gives the loss for predicting the NEXT token.

Args:
Expand All @@ -37,7 +37,7 @@ def lm_cross_entropy_loss(
# Ignore token positions which are masked out or where the next token is masked out
# (generally padding tokens)
next_token_mask = torch.logical_and(attention_mask[:, :-1], attention_mask[:, 1:])
predicted_log_probs *= next_token_mask
predicted_log_probs = predicted_log_probs.masked_fill(~next_token_mask, 0.0)

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.

Swapping in-place *= for masked_fill drops an implicit length guard: *= raised when the mask was longer than the scored tokens, but masked_fill broadcasts both operands and replicates the value instead. Now that the mask is forwarded, bridge(new_tokens, attention_mask=<cache+new>, past_key_values=cache, return_type="loss", loss_per_token=True) returns shape (1,5) with one value repeated five times, but previously it returned (1,1), and the scalar variant raises. Could you add an explicit attention_mask.shape[1] == tokens.shape[1] assertion here so the mismatch fails loudly again?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in df5f46d. lm_cross_entropy_loss now explicitly asserts that attention_mask.shape == tokens.shape, while BridgeCore first slices or reduces valid cache plus new masks to the scored token window. I also added mismatch and cached-window regression coverage.

n_tokens = next_token_mask.sum().item()
else:
n_tokens = predicted_log_probs.numel()
Expand Down
Loading