diff --git a/tests/integration/model_bridge/test_inspect_attention_mask.py b/tests/integration/model_bridge/test_inspect_attention_mask.py new file mode 100644 index 000000000..f7b63f7c8 --- /dev/null +++ b/tests/integration/model_bridge/test_inspect_attention_mask.py @@ -0,0 +1,213 @@ +"""Offline mask parity through the real Inspect driver and HF provider.""" + +import pytest +import torch +from tokenizers import Tokenizer +from tokenizers.models import WordLevel +from transformers import ( + GPT2Config, + GPT2LMHeadModel, + LlamaConfig, + LlamaForCausalLM, + OPTConfig, + OPTForCausalLM, + PreTrainedTokenizerFast, +) + +pytest.importorskip("inspect_ai") +pytestmark = pytest.mark.inspect + + +@pytest.fixture(scope="module", params=["gpt2", "llama", "opt"]) +def inspect_models(tmp_path_factory, request): + from transformer_lens.model_bridge.remote_bridge import RemoteBridge + + path = tmp_path_factory.mktemp("inspect_mask_model") + torch.manual_seed(2) + if request.param == "gpt2": + model = GPT2LMHeadModel( + GPT2Config( + vocab_size=32, + n_embd=16, + n_layer=1, + n_head=2, + n_positions=32, + bos_token_id=1, + eos_token_id=2, + pad_token_id=0, + attn_implementation="eager", + ) + ) + elif request.param == "llama": + model = LlamaForCausalLM( + LlamaConfig( + vocab_size=32, + hidden_size=16, + intermediate_size=32, + num_hidden_layers=1, + num_attention_heads=2, + num_key_value_heads=2, + max_position_embeddings=32, + bos_token_id=1, + eos_token_id=2, + pad_token_id=0, + attn_implementation="eager", + ) + ) + else: + model = OPTForCausalLM( + OPTConfig( + vocab_size=32, + hidden_size=16, + ffn_dim=32, + num_hidden_layers=1, + num_attention_heads=2, + word_embed_proj_dim=16, + max_position_embeddings=32, + bos_token_id=1, + eos_token_id=2, + pad_token_id=0, + attn_implementation="eager", + ) + ) + model.eval() + model.save_pretrained(path) + tokenizer = PreTrainedTokenizerFast( + tokenizer_object=Tokenizer(WordLevel({str(i): i for i in range(32)}, unk_token="3")), + pad_token="0", + bos_token="1", + eos_token="2", + unk_token="3", + ) + tokenizer.save_pretrained(path) + bridge = RemoteBridge.boot_inspect(str(path), device="cpu") + yield bridge, model + bridge.close() + + +@pytest.mark.parametrize("left_padding", [True, False]) +def test_masked_logits_cache_and_loss(inspect_models, left_padding): + bridge, model = inspect_models + tokens = torch.tensor([[0, 0, 7, 8, 9] if left_padding else [7, 8, 9, 0, 0]]) + mask = torch.tensor([[0, 0, 1, 1, 1] if left_padding else [1, 1, 1, 0, 0]]) + kwargs = {"attention_mask": mask} + if left_padding and model.config.model_type != "opt": + kwargs["position_ids"] = (mask.cumsum(-1) - 1).clamp_min(0) + with torch.no_grad(): + expected = model(tokens, **kwargs).logits + actual, cache = bridge.run_with_cache( + tokens, attention_mask=mask, names_filter=["blocks.0.hook_out"] + ) + torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-6) + alone, alone_cache = bridge.run_with_cache( + torch.tensor([[7, 8, 9]]), names_filter=["blocks.0.hook_out"] + ) + valid = mask[0].bool() + torch.testing.assert_close(actual[:, valid], alone, rtol=1e-5, atol=1e-6) + torch.testing.assert_close( + cache["blocks.0.hook_out"][:, valid], alone_cache["blocks.0.hook_out"], rtol=1e-5, atol=1e-6 + ) + loss = bridge.forward(tokens, attention_mask=mask, return_type="loss") + alone_loss = bridge.forward(torch.tensor([[7, 8, 9]]), return_type="loss") + torch.testing.assert_close(loss, alone_loss) + labeled_loss = bridge.forward(tokens, labels=tokens, attention_mask=mask, return_type="loss") + torch.testing.assert_close(labeled_loss, alone_loss) + + +def test_all_ones_mask_preserves_unmasked_forward(inspect_models): + bridge, _ = inspect_models + tokens = torch.tensor([[7, 8, 9]]) + torch.testing.assert_close( + bridge.forward(tokens, attention_mask=torch.ones_like(tokens)), + bridge.forward(tokens), + rtol=0, + atol=0, + ) + + +def test_kwargs_forward_uses_shared_position_gate(inspect_models, monkeypatch): + from transformer_lens.utilities.position_ids import ( + accepts_mask_derived_position_ids, + ) + + bridge, _ = inspect_models + model = bridge._driver._model.api._hf + original_forward = model.forward + positions = [] + + def kwargs_forward(input_ids, **kwargs): + positions.append(kwargs.get("position_ids")) + return original_forward(input_ids, **kwargs) + + monkeypatch.setattr(model, "forward", kwargs_forward) + tokens = torch.tensor([[0, 0, 7, 8, 9]]) + mask = torch.tensor([[0, 0, 1, 1, 1]]) + bridge.forward(tokens, attention_mask=mask) + assert len(positions) == 1 + if accepts_mask_derived_position_ids(model): + torch.testing.assert_close(positions[0], (mask.cumsum(-1) - 1).clamp_min(0)) + else: + assert positions[0] is None + + +@pytest.mark.parametrize("mask", [[[1, 1]], [[1, 0.5, 1]], [[0, 0, 0]], [[[1, 1, 1]]]]) +def test_invalid_masks_fail_at_public_and_provider_boundaries(inspect_models, mask): + from inspect_ai.model import GenerateConfig + + bridge, _ = inspect_models + with pytest.raises(ValueError, match="attention_mask"): + bridge.forward(torch.tensor([[7, 8, 9]]), attention_mask=torch.tensor(mask)) + with pytest.raises(ValueError, match="attention_mask"): + bridge._driver._model.api._generate_capture( + [], {"input_ids": [7, 8, 9], "attention_mask": mask}, GenerateConfig() + ) + + +@pytest.mark.parametrize("dtype", [torch.bool, torch.float32, torch.bfloat16]) +def test_binary_mask_dtypes(inspect_models, dtype): + bridge, _ = inspect_models + tokens = torch.tensor([[0, 7, 8]]) + mask = torch.tensor([[0, 1, 1]]) + torch.testing.assert_close( + bridge.forward(tokens, attention_mask=mask.to(dtype)), + bridge.forward(tokens, attention_mask=mask), + rtol=0, + atol=0, + ) + + +def test_masked_capture_and_intervention(inspect_models): + bridge, _ = inspect_models + tokens = torch.tensor([[0, 0, 7, 8, 9]]) + mask = torch.tensor([[0, 0, 1, 1, 1]]) + names = ["blocks.0.hook_out", "blocks.0.attn.hook_pattern"] + intervention = {"blocks.0.attn.hook_out": {"op": "scale", "factor": 0.5}} + padded, cache = bridge.run_with_cache( + tokens, attention_mask=mask, names_filter=names, intervene=intervention + ) + alone, alone_cache = bridge.run_with_cache( + torch.tensor([[7, 8, 9]]), names_filter=names, intervene=intervention + ) + torch.testing.assert_close(padded[:, 2:], alone, rtol=1e-5, atol=1e-6) + torch.testing.assert_close(cache[names[0]][:, 2:], alone_cache[names[0]], rtol=1e-5, atol=1e-6) + pattern = cache[names[1]] + torch.testing.assert_close(pattern[:, :, 2:, 2:], alone_cache[names[1]], rtol=1e-5, atol=1e-6) + assert torch.count_nonzero(pattern[:, :, 2:, :2]) == 0 + + +def test_provider_completion_uses_last_attended_token(inspect_models): + from inspect_ai.model import GenerateConfig + + bridge, model = inspect_models + api = bridge._driver._model.api + with torch.no_grad(): + logits = model(torch.tensor([[7, 8, 9]])).logits[0, -1] + next_id = int(logits.argmax()) + output = api._generate_capture( + [], + {"input_ids": [7, 8, 9, 0, 0], "attention_mask": [1, 1, 1, 0, 0]}, + GenerateConfig(logprobs=True, top_logprobs=3), + ) + assert output.completion == api._tokenizer.decode([next_id]) + entry = output.choices[0].logprobs.content[0] + assert entry.logprob == pytest.approx(float(logits.log_softmax(-1)[next_id]), abs=1e-6) diff --git a/tests/unit/model_bridge/test_inspect_driver.py b/tests/unit/model_bridge/test_inspect_driver.py index 399ec173f..609136f53 100644 --- a/tests/unit/model_bridge/test_inspect_driver.py +++ b/tests/unit/model_bridge/test_inspect_driver.py @@ -80,6 +80,28 @@ def _driver(model=None) -> InspectDriver: return InspectDriver(model=model or _fake_model(), adapter=_adapter(), tokenizer=None) +@pytest.mark.parametrize("provider", ["tl_bridge", "tl_bridge_vllm", "vllm-lens"]) +def test_unsupported_provider_rejects_attention_mask(provider): + driver = InspectDriver(_fake_model(), _adapter(), None, profiles.for_provider(provider)) + try: + with pytest.raises(NotImplementedError, match="attention_mask"): + driver.forward(np.array([[7, 8]]), attention_mask=np.array([[1, 1]])) + finally: + driver.close() + + +def test_vllm_provider_direct_capture_rejects_attention_mask(): + from inspect_ai.model import GenerateConfig + + from transformer_lens.model_bridge.sources.inspect.vllm_provider import ( + TransformerLensVLLMModelAPI, + ) + + api = object.__new__(TransformerLensVLLMModelAPI) + with pytest.raises(NotImplementedError, match="attention_mask"): + api._generate_capture([], {"input_ids": [7, 8], "attention_mask": [1, 1]}, GenerateConfig()) + + class TestProtocolConformance: def test_is_driver_and_validates(self): driver = _driver() diff --git a/tests/unit/model_bridge/test_inspect_vllm_provider.py b/tests/unit/model_bridge/test_inspect_vllm_provider.py index 8b193bb23..3d06b30b3 100644 --- a/tests/unit/model_bridge/test_inspect_vllm_provider.py +++ b/tests/unit/model_bridge/test_inspect_vllm_provider.py @@ -672,7 +672,7 @@ class TestBootInspectVLLMDtype: """boot_inspect must forward the resolved dtype to the tl_bridge_vllm provider so bridge_config.dtype matches what the engine loads.""" - def _boot(self, monkeypatch, **boot_kwargs): + def _boot(self, monkeypatch, mask_support=None, **boot_kwargs): import inspect_ai.model as inspect_ai_model import torch @@ -705,6 +705,8 @@ def fake_get_model(name, memoize=False, **kwargs): capability_note=lambda: "", provides_sequence_logits=False, ) + if mask_support is not None: + api.supports_attention_mask = mask_support return SimpleNamespace(api=api) from transformers import AutoConfig @@ -750,3 +752,11 @@ def test_explicit_dtype_forwarded(self, monkeypatch): _, captured = self._boot(monkeypatch, dtype=torch.float16) assert captured["dtype"] is torch.float16 + + @pytest.mark.parametrize("mask_support", [None, False, True]) + def test_mask_support_comes_from_api(self, monkeypatch, mask_support): + bridge, _ = self._boot(monkeypatch, mask_support=mask_support) + try: + assert bridge._driver._profile.supports_attention_mask is bool(mask_support) + finally: + bridge.close() diff --git a/tests/unit/model_bridge/test_position_ids_injection_gate.py b/tests/unit/model_bridge/test_position_ids_injection_gate.py index a40beb3fa..57414facb 100644 --- a/tests/unit/model_bridge/test_position_ids_injection_gate.py +++ b/tests/unit/model_bridge/test_position_ids_injection_gate.py @@ -18,10 +18,15 @@ import torch.nn as nn from transformer_lens.model_bridge import TransformerBridge +from transformer_lens.utilities.position_ids import accepts_mask_derived_position_ids -# Unbound so it can run against a stand-in that owns only ``_driver``; the gate -# reads nothing else off the bridge. -gate = TransformerBridge._accepts_derived_position_ids + +# Exercise the shared helper alongside the local driver's cached gate. +def gate(bridge: Any) -> bool: + verdict = TransformerBridge._accepts_derived_position_ids(bridge) + model = bridge._driver.underlying_model + assert verdict == (accepts_mask_derived_position_ids(model) if model is not None else False) + return verdict def _bridge_over(model: Optional[nn.Module]) -> Any: diff --git a/transformer_lens/model_bridge/sources/inspect/driver.py b/transformer_lens/model_bridge/sources/inspect/driver.py index 2460b437d..7bd7340fe 100644 --- a/transformer_lens/model_bridge/sources/inspect/driver.py +++ b/transformer_lens/model_bridge/sources/inspect/driver.py @@ -26,6 +26,7 @@ from transformer_lens.model_bridge.sources._driver_base import DriverBase from . import hooks, wire +from .masks import normalize_attention_mask from .profiles import TLBridgeProfile # Cap a single provider call so a hung remote/provider forward unblocks the sync caller @@ -68,6 +69,7 @@ def forward( intervene: Mapping[str, Intervention] | None = None, max_new_tokens: int = 1, return_logits: bool = True, + attention_mask: TensorLike | None = None, **kwargs: Any, ) -> ForwardResult: if self._model is None: @@ -79,6 +81,11 @@ def forward( "InspectDriver supports max_new_tokens=1 only (single-forward capture)." ) ids = self._normalize_input_ids(input_ids) + mask = None + if attention_mask is not None: + if not getattr(self._profile, "supports_attention_mask", False): + raise NotImplementedError("This Inspect provider does not support attention_mask.") + mask = normalize_attention_mask(attention_mask, len(ids)) # capture is authoritative: the bridge passes exactly the hooks with handlers, # so () means "capture nothing" (logits only), not "capture everything". names = list(capture) @@ -89,6 +96,8 @@ def forward( prompt, extra_args = self._profile.build_request( ids, wire_keys, interventions, return_logits, self.tokenizer ) + if mask is not None: + extra_args["attention_mask"] = mask output = self._run_coro(self._generate(prompt, extra_args)) diff --git a/transformer_lens/model_bridge/sources/inspect/masks.py b/transformer_lens/model_bridge/sources/inspect/masks.py new file mode 100644 index 000000000..5f4109c94 --- /dev/null +++ b/transformer_lens/model_bridge/sources/inspect/masks.py @@ -0,0 +1,21 @@ +"""Torch-free validation of the single-sequence Inspect padding-mask wire format.""" + +from typing import Any + +import numpy as np + + +def normalize_attention_mask(mask: Any, n_tokens: int) -> list[int]: + """Accept a binary flat wire mask or a single-row public padding mask.""" + if hasattr(mask, "detach"): + mask = mask.detach().cpu().tolist() + array = np.asarray(mask) + if array.ndim == 2 and array.shape[0] == 1: + array = array[0] + if array.ndim != 1 or array.shape[0] != n_tokens: + raise ValueError("Inspect attention_mask must match the single input sequence length.") + if not np.all((array == 0) | (array == 1)): + raise ValueError("Inspect attention_mask must be a binary 0/1 padding mask.") + if not np.any(array): + raise ValueError("Inspect attention_mask must retain at least one token.") + return [int(value) for value in array.tolist()] diff --git a/transformer_lens/model_bridge/sources/inspect/profiles.py b/transformer_lens/model_bridge/sources/inspect/profiles.py index 7be2ae40c..453109654 100644 --- a/transformer_lens/model_bridge/sources/inspect/profiles.py +++ b/transformer_lens/model_bridge/sources/inspect/profiles.py @@ -32,9 +32,15 @@ class TLBridgeProfile: # only populate the gen position; lets RemoteBridge.forward reject loss/both there. provides_sequence_logits = True - def __init__(self, supported_kinds: Any = None, provides_sequence_logits: bool = True) -> None: + def __init__( + self, + supported_kinds: Any = None, + provides_sequence_logits: bool = True, + supports_attention_mask: bool = True, + ) -> None: self._kinds = supported_kinds self.provides_sequence_logits = provides_sequence_logits + self.supports_attention_mask = supports_attention_mask def supported_hooks(self, n_layers: int) -> frozenset[str]: return hooks.supported_hook_points(n_layers, self._kinds) @@ -79,6 +85,7 @@ class VLLMLensProfile: """ provides_sequence_logits = False + supports_attention_mask = False def supported_hooks(self, n_layers: int) -> frozenset[str]: return frozenset(f"blocks.{i}.hook_out" for i in range(n_layers)) @@ -142,7 +149,7 @@ def for_provider(provider: str) -> Any: if provider.startswith("vllm-lens"): return VLLMLensProfile() if provider in ("tl_bridge", "tl_bridge_vllm"): - return TLBridgeProfile() + return TLBridgeProfile(supports_attention_mask=False) # An unknown provider would otherwise get full-capability codec and NaN downstream. raise ValueError( f"No Inspect codec for provider {provider!r}. Known providers: 'tl_bridge', " diff --git a/transformer_lens/model_bridge/sources/inspect/source.py b/transformer_lens/model_bridge/sources/inspect/source.py index 698f9edd0..b1959bed6 100644 --- a/transformer_lens/model_bridge/sources/inspect/source.py +++ b/transformer_lens/model_bridge/sources/inspect/source.py @@ -61,6 +61,11 @@ def boot_inspect( default) and eager attention. Full-sequence logits ride on ``return_logits=True`` (the default); pass ``return_logits=False`` to skip the (seq × d_vocab) payload for pure activation capture (``run_with_cache`` keeps them since it returns logits). + + ``tl_bridge`` accepts a binary, single-row ``attention_mask`` matching the input + length. Masked forwards use padding-aware positions where the model accepts 2-D + position IDs; model-owned position derivation is left intact. The vLLM Inspect + providers reject masks rather than silently ignore them. """ from inspect_ai.model import get_model from transformers import AutoConfig, AutoTokenizer @@ -118,7 +123,11 @@ def boot_inspect( kinds = api.supported_kinds() if hasattr(api, "supported_kinds") else None note = api.capability_note() if hasattr(api, "capability_note") else "" psl = bool(getattr(api, "provides_sequence_logits", True)) - profile = profiles.TLBridgeProfile(supported_kinds=kinds, provides_sequence_logits=psl) + profile = profiles.TLBridgeProfile( + supported_kinds=kinds, + provides_sequence_logits=psl, + supports_attention_mask=bool(getattr(api, "supports_attention_mask", False)), + ) if note: warnings.warn(note, UserWarning, stacklevel=2) else: diff --git a/transformer_lens/model_bridge/sources/inspect/transformers_provider.py b/transformer_lens/model_bridge/sources/inspect/transformers_provider.py index 244322a85..26df800e2 100644 --- a/transformer_lens/model_bridge/sources/inspect/transformers_provider.py +++ b/transformer_lens/model_bridge/sources/inspect/transformers_provider.py @@ -29,6 +29,8 @@ modelapi, ) +from transformer_lens.utilities.position_ids import accepts_mask_derived_position_ids + from . import hooks, wire from ._provider_base import ( _InspectModelAPIBase, @@ -37,6 +39,7 @@ _require_served, _warn_unsupported_config, ) +from .masks import normalize_attention_mask # NOT "transformer_lens" — inspect_ai ships a built-in provider by that name (the # reverse direction: serving a HookedTransformer as an Inspect model for generation). @@ -83,6 +86,7 @@ class TransformerLensTransformersModelAPI(_InspectModelAPIBase): # Real per-position logits via direct HF forward — loss/both via RemoteBridge work. provides_sequence_logits = True + supports_attention_mask = True def __init__( self, @@ -145,6 +149,19 @@ def _generate_capture(self, input: Any, extra_args: Mapping[str, Any], config: G input_ids = extra_args.get("input_ids") if input_ids is None: input_ids = self._messages_to_ids(input)[0].tolist() + mask_value = extra_args.get("attention_mask") + mask = ( + normalize_attention_mask(mask_value, len(input_ids)) if mask_value is not None else None + ) + forward_kwargs: dict[str, Any] = {} + if mask is not None: + attention_mask = torch.tensor([mask], device=self._device) + forward_kwargs["attention_mask"] = attention_mask + positions = (attention_mask.cumsum(-1) - 1).clamp_min(0) + default_positions = torch.arange(len(input_ids), device=self._device) + needs_positions = bool(((positions != default_positions) & attention_mask.bool()).any()) + if needs_positions and accepts_mask_derived_position_ids(self._hf): + forward_kwargs["position_ids"] = positions # capture/interventions are keyed by ":" (hooks.wire_key). capture_keys = list(extra_args.get("capture", [])) for key in capture_keys: @@ -170,7 +187,7 @@ def _generate_capture(self, input: Any, extra_args: Mapping[str, Any], config: G try: with torch.no_grad(): ids = torch.tensor([list(input_ids)], device=self._device) - outputs = self._hf(ids, output_attentions=bool(pattern_layers)) + outputs = self._hf(ids, output_attentions=bool(pattern_layers), **forward_kwargs) logits = outputs.logits # (1, seq, vocab) finally: for handle in handles: @@ -188,9 +205,11 @@ def _generate_capture(self, input: Any, extra_args: Mapping[str, Any], config: G if want_logits: metadata["tl_logits"] = wire.encode_array(logits[0].float().cpu().numpy()) - next_id = int(logits[0, -1].argmax()) + last_token = max(i for i, keep in enumerate(mask) if keep) if mask is not None else -1 + next_logits = logits[0, last_token] + next_id = int(next_logits.argmax()) logprobs = ( - Logprobs(content=[self._logprob_entry(next_id, logits[0, -1], config.top_logprobs)]) + Logprobs(content=[self._logprob_entry(next_id, next_logits, config.top_logprobs)]) if config.logprobs else None ) diff --git a/transformer_lens/model_bridge/sources/inspect/vllm_provider.py b/transformer_lens/model_bridge/sources/inspect/vllm_provider.py index e12a08204..76cb7f5ef 100644 --- a/transformer_lens/model_bridge/sources/inspect/vllm_provider.py +++ b/transformer_lens/model_bridge/sources/inspect/vllm_provider.py @@ -85,6 +85,7 @@ class TransformerLensVLLMModelAPI(_InspectModelAPIBase): # so earlier positions are -inf and loss would be NaN. RemoteBridge.forward must reject # return_type ∈ {loss, both} — read by source.py → TLBridgeProfile → InspectDriver. provides_sequence_logits = False + supports_attention_mask = False def __init__( self, @@ -194,6 +195,8 @@ def _generate_capture(self, input: Any, extra_args: Mapping[str, Any], config: G single-token generate (vLLM's prefill populates the capture buffers), read them back via ``collective_rpc``, and return the wire-format ``metadata["activations"]`` + synthesized ``tl_logits`` the InspectDriver expects.""" + if extra_args.get("attention_mask") is not None: + raise NotImplementedError("tl_bridge_vllm does not support attention_mask.") from vllm import SamplingParams from vllm.inputs import TokensPrompt diff --git a/transformer_lens/model_bridge/transformer_bridge.py b/transformer_lens/model_bridge/transformer_bridge.py index 597419895..0aca31c3d 100644 --- a/transformer_lens/model_bridge/transformer_bridge.py +++ b/transformer_lens/model_bridge/transformer_bridge.py @@ -70,6 +70,7 @@ from transformer_lens.model_bridge.get_params_util import get_bridge_params from transformer_lens.utilities.activation_functions import softcap_enabled from transformer_lens.utilities.devices import move_to_and_update_config +from transformer_lens.utilities.position_ids import accepts_mask_derived_position_ids from transformer_lens.utilities.quantization import require_readable_weight if TYPE_CHECKING: @@ -1979,44 +1980,7 @@ def _accepts_derived_position_ids(self) -> bool: if cached is not None and cached[0] is underlying: return bool(cached[1]) - def verdict() -> bool: - fwd_params = inspect.signature(underlying.forward).parameters - if "position_ids" not in fwd_params and not any( - p.kind is inspect.Parameter.VAR_KEYWORD for p in fwd_params.values() - ): - return False - - # ``get_rope_index`` lives on the inner text model, not the - # ForConditionalGeneration wrapper that is usually original_model. - for module in ( - underlying, - getattr(underlying, "model", None), - getattr(underlying, "language_model", None), - ): - if module is not None and hasattr(module, "get_rope_index"): - return False - - # Config-level backstop for mRoPE models that spell the derivation - # differently; the section list is what makes positions 3-D. - config = getattr(underlying, "config", None) - for candidate in (config, getattr(config, "text_config", None)): - scaling = getattr(candidate, "rope_scaling", None) - if isinstance(scaling, dict) and "mrope_section" in scaling: - return False - - # A positional embedding that takes the mask derives positions for - # itself. Only embeddings that override nn.Embedding.forward are - # worth inspecting, which keeps this to a handful per model. - for module in underlying.modules(): - if not isinstance(module, nn.Embedding): - continue - if type(module).forward is nn.Embedding.forward: - continue - if "attention_mask" in inspect.signature(module.forward).parameters: - return False - return True - - accepts = verdict() + accepts = accepts_mask_derived_position_ids(underlying) self.__dict__["_derived_position_ids_ok"] = (underlying, accepts) return accepts diff --git a/transformer_lens/utilities/position_ids.py b/transformer_lens/utilities/position_ids.py new file mode 100644 index 000000000..c4f415b57 --- /dev/null +++ b/transformer_lens/utilities/position_ids.py @@ -0,0 +1,27 @@ +"""Shared eligibility gate for mask-derived position IDs.""" + +import inspect + +from torch import nn + + +def accepts_mask_derived_position_ids(model: nn.Module) -> bool: + """Accept explicit/kwargs positions unless the model owns their derivation.""" + parameters = inspect.signature(model.forward).parameters + if "position_ids" not in parameters and not any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values() + ): + return False + for module in (model, getattr(model, "model", None), getattr(model, "language_model", None)): + if module is not None and hasattr(module, "get_rope_index"): + return False + config = getattr(model, "config", None) + for candidate in (config, getattr(config, "text_config", None)): + scaling = getattr(candidate, "rope_scaling", None) + if isinstance(scaling, dict) and "mrope_section" in scaling: + return False + for module in model.modules(): + if isinstance(module, nn.Embedding) and type(module).forward is not nn.Embedding.forward: + if "attention_mask" in inspect.signature(module.forward).parameters: + return False + return True