Skip to content

Commit e98a687

Browse files
authored
Support explicit head_dim on MistralBackbone (enables Magistral) (#2692)
* Support explicit head_dim on MistralBackbone (enables Magistral) Mistral and its derivatives always had `head_dim = hidden_dim // num_query_heads` hard-coded in `CachedMistralAttention.build`. That assumption breaks for Magistral (mistralai/Magistral-Small-2506): hidden_size=5120, num_attention_heads=32 -> 5120/32 = 160 head_dim in HF config -> 128 Add an optional `head_dim` argument on `MistralBackbone`, `MistralTransformerDecoder`, and `CachedMistralAttention`. When unset, behavior is unchanged. When provided, it overrides the hidden/heads fallback so the Q/K/V and output einsum projections build with the correct per-head size. Also: - Pass `head_dim` through the HuggingFace -> keras-hub converter and fall back to `.get("sliding_window")` so the `null` setting on Magistral no longer KeyErrors. - Add a unit test on `MistralBackbone` that exercises the explicit head_dim path with sliding_window=None. - Add a numerical-parity test that builds a small HF Mistral with `head_dim=12` (!= hidden/num_heads), converts it through the preset loader, and asserts the keras-hub forward pass matches the HF reference to within fp16 precision. After this PR lands, users can load Magistral directly: keras_hub.models.MistralBackbone.from_preset( "hf://mistralai/Magistral-Small-2506" ) Addresses the "[Contributions Welcome] Add Magistral" item in the KerasHub roadmap (#1836) and issue #2314. * Address review feedback on MistralBackbone head_dim - Rename the constructor-argument attribute `_configured_head_dim` to `_head_dim` on `CachedMistralAttention`, matching the style-guide rule that layer attributes should share the name of the `__init__` argument (keras-hub style guide line 542). - Simplify `build()` to only compute `_head_dim` from `_hidden_dim` when the caller did not supply one, instead of maintaining a second attribute for the configured value. - Update `get_config` to return the renamed attribute. - Tweak the `MistralBackbone.head_dim` docstring: "not a multiple of" was inaccurate — any value that differs from `hidden_dim // num_query_heads` needs to be set explicitly, and `//` matches the code path. * Plumb head_dim through Mistral checkpoint conversion (adds Magistral preset) Update tools/checkpoint_conversion/convert_mistral_checkpoints.py to: * Add `magistral_small_2506_en` to PRESET_MAP. * Extract `head_dim` once from the HF config (with fallback to `hidden_size // num_attention_heads`) and use it in the Q/K/V/O reshape calls instead of the hard-coded ratio. Standard Mistral checkpoints take the fallback path and behavior is unchanged. * Pass `head_dim` through to `MistralBackbone` via `backbone_kwargs`. Also handle the transformers 5.x rope_parameters drift in both this script and `keras_hub/src/utils/transformers/convert_mistral.py`: `rope_theta` no longer lives at the top level of the HF config and is nested inside `rope_parameters`. Without this fallback, both the script-path conversion and `from_preset("hf://...")` raise on any recent HF Mistral/Magistral checkpoint. Numerics verified on synthetic standard and Magistral-shaped configs (head_dim=12 != hidden//heads, sliding_window=None) within atol=1e-2, matching the precision bound of the fp16 weight hops. * Use ops.convert_to_numpy in test_explicit_head_dim_matches_hf `np.asarray()` cannot directly convert a CUDA torch tensor returned by the keras backbone under the PyTorch backend, breaking PyTorch-GPU CI: TypeError: can't convert cuda:0 device type tensor to numpy. Use Tensor.cpu() to copy the tensor to host memory first. `keras.ops.convert_to_numpy()` is backend-agnostic and handles the `.cpu().numpy()` hop internally for PyTorch. * Cover explicit head_dim path with run_backbone_test Route test_explicit_head_dim through run_backbone_test so the Magistral style config (explicit head_dim, sliding_window=None) also gets serialization and mixed precision coverage, then keep the head_dim assertion on the built attention layer. * Add magistral_small_2507_en to Mistral PRESET_MAP * Support Tekken tokenizer conversion for Mistral Magistral checkpoints ship a Tekken (byte-level BPE) tokenizer as tekken.json instead of a SentencePiece tokenizer.model, so the previous convert_tokenizer that hardcoded get_file(preset, 'tokenizer.model') failed on them. Add convert_tekken.py to reconstruct BPE merge rules from the Tekken rank-ordered byte vocabulary and re-encode it with the GPT-2 byte-to-unicode mapping. MistralTokenizer now accepts vocabulary/merges in addition to a proto: when built from Tekken data it delegates to an internal BytePairTokenizer configured with the Tekken split pattern, with a tf.py_function bridge for the tf.data graph path. Existing SentencePiece presets are unchanged. * Parse Tekken vocab with mistral_common Use Mistral's own mistral_common library to parse tekken.json during conversion instead of parsing the raw JSON by hand. This is the same backend Hugging Face delegates to for Tekken, so the byte vocabulary, special tokens, split pattern and vocab size come from a maintained source rather than reimplemented parsing. mistral_common is imported lazily inside convert_tokenizer and is only needed at conversion time; the runtime tokenizer still uses BytePairTokenizer and does not depend on it. Added as an optional dep. * Fix Tekken tokenizer init order on torch backend On the torch backend a keras.Layer is also a torch.nn.Module, which rejects submodule assignment before its own __init__ runs. Initialize the base tokenizer before assigning the internal BytePairTokenizer so the Tekken path works on all backends. * Make Tekken tokenizer a standalone MistralTekkenTokenizer class Address review feedback: - Add MistralTekkenTokenizer(BytePairTokenizer) as a public class instead of turning MistralTokenizer into a dual-personality class. MistralTokenizer is left untouched (SentencePiece only). convert_tokenizer returns a MistralTekkenTokenizer directly when tekken.json is detected. - Inline the Tekken parsing helpers into convert_mistral.py and remove the separate convert_tekken.py, matching how convert_gemma4.py inlines its tokenizer helpers. - Drop mistral-common from requirements-common.txt; it is only needed at conversion time and is already lazily imported with an ImportError guard. * Use explicit head_dim when building the generation cache MistralCausalLM._build_cache always derived head_dim as hidden_dim // num_query_heads, ignoring an explicitly configured head_dim. For Magistral (hidden_dim=5120, num_query_heads=32, head_dim=128) this built the cache with head_dim=160 and generate() failed in dot_product_attention. Fall back to the derived value only when head_dim is not set, matching MistralAttention. Add a regression test with an explicit head_dim. * Align Mistral conversion script with recent converter patterns - Load the HF reference in float32 on CPU and precompute its logits, then free it so only one full-precision model is in memory at a time (qwen3_5 pattern). This also fixes the bfloat16 .numpy() crash. - Load the KerasHub model through from_preset("hf://...") so the script exercises the built-in transformers converter (which now handles head_dim) instead of duplicating the weight porting logic. - Verify parameter counts, token IDs, and logits against the HF reference in float32 with a hard assert (atol=1e-3). - Reload in bfloat16 and save the preset in bfloat16 (gemma4 pattern).
1 parent 5289f33 commit e98a687

13 files changed

Lines changed: 702 additions & 254 deletions

keras_hub/api/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,9 @@
511511
from keras_hub.src.models.mistral.mistral_causal_lm_preprocessor import (
512512
MistralCausalLMPreprocessor as MistralCausalLMPreprocessor,
513513
)
514+
from keras_hub.src.models.mistral.mistral_tokenizer import (
515+
MistralTekkenTokenizer as MistralTekkenTokenizer,
516+
)
514517
from keras_hub.src.models.mistral.mistral_tokenizer import (
515518
MistralTokenizer as MistralTokenizer,
516519
)

keras_hub/api/tokenizers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
from keras_hub.src.models.metaclip_2.metaclip_2_tokenizer import (
7272
MetaCLIP2Tokenizer as MetaCLIP2Tokenizer,
7373
)
74+
from keras_hub.src.models.mistral.mistral_tokenizer import (
75+
MistralTekkenTokenizer as MistralTekkenTokenizer,
76+
)
7477
from keras_hub.src.models.mistral.mistral_tokenizer import (
7578
MistralTokenizer as MistralTokenizer,
7679
)

keras_hub/src/models/mistral/mistral_attention.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@ def __init__(
2727
kernel_initializer="glorot_uniform",
2828
sliding_window=512,
2929
dropout=0,
30+
head_dim=None,
3031
**kwargs,
3132
):
3233
super().__init__(**kwargs)
3334
self._num_query_heads = num_query_heads
3435
self._num_key_value_heads = num_key_value_heads
3536
self._sliding_window = sliding_window
3637
self._dropout = dropout
38+
self._head_dim = head_dim
3739

3840
self._num_key_value_groups = num_query_heads // num_key_value_heads
3941
self._rope_max_wavelength = rope_max_wavelength
@@ -54,7 +56,8 @@ def build(self, inputs_shape):
5456
# v = num key/value heads
5557
# h = head dim
5658
self._hidden_dim = inputs_shape[-1]
57-
self._head_dim = self._hidden_dim // self._num_query_heads
59+
if self._head_dim is None:
60+
self._head_dim = self._hidden_dim // self._num_query_heads
5861
self._inv_norm_factor = 1.0 / math.sqrt(self._head_dim)
5962

6063
self._query_dense = keras.layers.EinsumDense(
@@ -239,6 +242,7 @@ def get_config(self):
239242
),
240243
"sliding_window": self._sliding_window,
241244
"dropout": self._dropout,
245+
"head_dim": self._head_dim,
242246
}
243247
)
244248
return config

keras_hub/src/models/mistral/mistral_backbone.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ class MistralBackbone(Backbone):
5252
attention layers. This controls the maximum cache size for the
5353
attention layers in each transformer decoder. Only `sliding_window`
5454
number of tokens are saved in the cache and used to generate the
55-
next token. Defaults to `512`.
55+
next token. Defaults to `512`. Pass `None` to disable sliding
56+
window attention entirely (e.g. Magistral).
57+
head_dim (int, optional): The size of each attention head. When
58+
`None` (the default), falls back to `hidden_dim // num_query_heads`.
59+
Set explicitly when the model's head size is not equal to
60+
`hidden_dim // num_query_heads` — e.g. Magistral uses
61+
`head_dim=128` with `hidden_dim=5120` and `num_query_heads=32`.
5662
dtype: string or `keras.mixed_precision.DTypePolicy`. The dtype to use
5763
for model computations and weights. Note that some computations,
5864
such as softmax and layer normalization, will always be done at
@@ -98,6 +104,7 @@ def __init__(
98104
rope_scaling_factor=1.0,
99105
layer_norm_epsilon=1e-6,
100106
sliding_window=512,
107+
head_dim=None,
101108
dropout=0,
102109
dtype=None,
103110
**kwargs,
@@ -123,6 +130,7 @@ def __init__(
123130
activation=ops.silu,
124131
kernel_initializer=_mistral_kernel_initializer(stddev=0.02),
125132
sliding_window=sliding_window,
133+
head_dim=head_dim,
126134
dropout=dropout,
127135
dtype=dtype,
128136
name=f"transformer_layer_{i}",
@@ -165,6 +173,7 @@ def __init__(
165173
self.num_key_value_heads = num_key_value_heads
166174
self.rope_scaling_factor = rope_scaling_factor
167175
self.sliding_window = sliding_window
176+
self.head_dim = head_dim
168177
self.layer_norm_epsilon = layer_norm_epsilon
169178
self.dropout = dropout
170179

@@ -181,6 +190,7 @@ def get_config(self):
181190
"rope_scaling_factor": self.rope_scaling_factor,
182191
"num_key_value_heads": self.num_key_value_heads,
183192
"sliding_window": self.sliding_window,
193+
"head_dim": self.head_dim,
184194
"layer_norm_epsilon": self.layer_norm_epsilon,
185195
"dropout": self.dropout,
186196
}

keras_hub/src/models/mistral/mistral_backbone_test.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ def test_num_parameters(self):
4242
# Reference value calculated using the PyTorch model
4343
self.assertEqual(model.count_params(), 2704)
4444

45+
def test_explicit_head_dim(self):
46+
# Magistral-style config: `head_dim` is set explicitly and does not
47+
# equal `hidden_dim // num_query_heads`. `sliding_window=None` is
48+
# also exercised here. Run the full backbone test so the new path
49+
# gets serialization and precision coverage.
50+
init_kwargs = {
51+
**self.init_kwargs,
52+
"sliding_window": None,
53+
"head_dim": 4,
54+
}
55+
self.run_backbone_test(
56+
cls=MistralBackbone,
57+
init_kwargs=init_kwargs,
58+
input_data=self.input_data,
59+
expected_output_shape=(2, 5, 16),
60+
)
61+
model = MistralBackbone(**init_kwargs)
62+
attention = model.transformer_layers[0]._self_attention_layer
63+
self.assertEqual(attention._head_dim, 4)
64+
4565
@pytest.mark.extra_large
4666
def test_smallest_preset(self):
4767
self.run_preset_test(

keras_hub/src/models/mistral/mistral_causal_lm.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ def _build_cache(self, token_ids):
100100
max_length = ops.shape(token_ids)[1]
101101
num_layers = self.backbone.num_layers
102102
num_key_value_heads = self.backbone.num_key_value_heads
103-
head_dim = self.backbone.hidden_dim // self.backbone.num_query_heads
103+
head_dim = self.backbone.head_dim or (
104+
self.backbone.hidden_dim // self.backbone.num_query_heads
105+
)
104106
shape = [
105107
batch_size,
106108
num_layers,

keras_hub/src/models/mistral/mistral_causal_lm_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ def test_causal_lm_basics(self):
4747
expected_output_shape=(2, 8, 10),
4848
)
4949

50+
def test_generate_with_explicit_head_dim(self):
51+
# Magistral-style config where `head_dim` is set explicitly and does
52+
# not equal `hidden_dim // num_query_heads`. The generation cache must
53+
# be built with the explicit `head_dim`, not the derived value.
54+
backbone = MistralBackbone(
55+
vocabulary_size=self.preprocessor.tokenizer.vocabulary_size(),
56+
num_layers=2,
57+
num_query_heads=4,
58+
num_key_value_heads=2,
59+
hidden_dim=8,
60+
intermediate_dim=16,
61+
head_dim=6,
62+
sliding_window=None,
63+
)
64+
self.assertNotEqual(
65+
backbone.head_dim, backbone.hidden_dim // backbone.num_query_heads
66+
)
67+
causal_lm = MistralCausalLM(
68+
preprocessor=self.preprocessor, backbone=backbone
69+
)
70+
prompt = "the quick brown fox"
71+
output = causal_lm.generate(prompt)
72+
self.assertTrue(prompt in output)
73+
5074
def test_generate(self):
5175
causal_lm = MistralCausalLM(**self.init_kwargs)
5276
# String input.

keras_hub/src/models/mistral/mistral_tokenizer.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
try:
2+
import tensorflow as tf
3+
except ImportError:
4+
tf = None
5+
16
from keras_hub.src.api_export import keras_hub_export
27
from keras_hub.src.models.mistral.mistral_backbone import MistralBackbone
8+
from keras_hub.src.tokenizers.byte_pair_tokenizer import BytePairTokenizer
39
from keras_hub.src.tokenizers.sentence_piece_tokenizer import (
410
SentencePieceTokenizer,
511
)
12+
from keras_hub.src.utils.tensor_utils import preprocessing_function
13+
14+
try:
15+
import tokenizers as hf_tokenizers
16+
from tokenizers import decoders
17+
from tokenizers import models as hf_models
18+
from tokenizers import pre_tokenizers
19+
except ImportError:
20+
hf_tokenizers = None
621

722

823
@keras_hub_export(
@@ -20,6 +35,10 @@ class MistralTokenizer(SentencePieceTokenizer):
2035
Mistral models and provides a `from_preset()` method to automatically
2136
download a matching vocabulary for a Mistral preset.
2237
38+
This tokenizer is used by SentencePiece-based Mistral presets. Presets that
39+
ship a Tekken (byte-level BPE) vocabulary, such as Magistral, use
40+
`keras_hub.tokenizers.MistralTekkenTokenizer` instead.
41+
2342
If input is a batch of strings (rank > 0), the layer will output a
2443
`tf.RaggedTensor` where the last dimension of the output is ragged.
2544
@@ -55,3 +74,159 @@ def __init__(self, proto, **kwargs):
5574
self._add_special_token("</s>", "end_token")
5675
self.pad_token_id = 0
5776
super().__init__(proto=proto, **kwargs)
77+
78+
79+
@keras_hub_export(
80+
[
81+
"keras_hub.tokenizers.MistralTekkenTokenizer",
82+
"keras_hub.models.MistralTekkenTokenizer",
83+
]
84+
)
85+
class MistralTekkenTokenizer(BytePairTokenizer):
86+
"""Mistral Tekken tokenizer layer based on byte-level BPE.
87+
88+
This tokenizer class handles Mistral's Tekken (tiktoken-style byte-level
89+
BPE) vocabulary, used by presets such as Magistral. It is based on
90+
`keras_hub.tokenizers.BytePairTokenizer`, but uses the Tekken
91+
pre-tokenization regex instead of the GPT-2/Llama3 pattern hardcoded in the
92+
base class, and checks for the special tokens needed by Mistral models.
93+
94+
The `vocabulary` and `merges` are usually produced from a `tekken.json`
95+
file by the Hugging Face conversion path; see
96+
`keras_hub.src.utils.transformers.convert_mistral`.
97+
98+
If input is a batch of strings (rank > 0), the layer will output a
99+
`tf.RaggedTensor` where the last dimension of the output is ragged.
100+
101+
If input is a scalar string (rank == 0), the layer will output a dense
102+
`tf.Tensor` with static shape `[None]`.
103+
104+
Args:
105+
vocabulary: A dict mapping token strings to integer ids, or a path to a
106+
vocabulary JSON file.
107+
merges: A list of BPE merge rules, or a path to a merges file.
108+
split_pattern: str. The Tekken pre-tokenization regex.
109+
110+
Examples:
111+
```python
112+
tokenizer = keras_hub.models.MistralTekkenTokenizer.from_preset(
113+
"hf://mistralai/Magistral-Small-2506",
114+
)
115+
tokenizer("The quick brown fox jumped.")
116+
tokenizer.detokenize(tokenizer("The quick brown fox jumped."))
117+
```
118+
"""
119+
120+
backbone_cls = MistralBackbone
121+
122+
def __init__(
123+
self, vocabulary=None, merges=None, split_pattern=None, **kwargs
124+
):
125+
self.split_pattern = split_pattern
126+
self._add_special_token("<s>", "start_token")
127+
self._add_special_token("</s>", "end_token")
128+
self.pad_token_id = 0
129+
super().__init__(
130+
vocabulary=vocabulary,
131+
merges=merges,
132+
unsplittable_tokens=[self.start_token, self.end_token],
133+
**kwargs,
134+
)
135+
136+
def _set_vocabulary_and_merges_tokenizers(self, vocabulary, merges):
137+
self.vocabulary = vocabulary.copy()
138+
self.merges = list(merges)
139+
_merges = []
140+
for merge in self.merges:
141+
if "#version:" in merge.lstrip():
142+
continue
143+
a, b = str(merge).split(" ")
144+
_merges.append((a, b))
145+
self._tokenizer = hf_tokenizers.Tokenizer(
146+
hf_models.BPE(vocab=vocabulary, merges=_merges, fuse_unk=False)
147+
)
148+
if self.unsplittable_tokens:
149+
self._tokenizer.add_special_tokens(self.unsplittable_tokens)
150+
self._tokenizer.pre_tokenizer = pre_tokenizers.Sequence(
151+
[
152+
pre_tokenizers.Split(
153+
hf_tokenizers.Regex(self.split_pattern),
154+
behavior="isolated",
155+
),
156+
pre_tokenizers.ByteLevel(
157+
add_prefix_space=self.add_prefix_space, use_regex=False
158+
),
159+
]
160+
)
161+
self._tokenizer.decoder = decoders.ByteLevel()
162+
163+
# Dummy attrs for serialization compatibility with the base class.
164+
if not hasattr(self, "cache"):
165+
self.byte2unicode = None
166+
self.unicode2byte = None
167+
self.cache = None
168+
self.id_to_token_map = None
169+
self.token_to_id_map = None
170+
self.merge_ranks_lookup_default = None
171+
self.merge_ranks = None
172+
173+
def _set_vocabulary_and_merges_tf(self, vocabulary, merges):
174+
# The base class hardcodes the GPT-2 split regex in its `tf.data`
175+
# path, which does not match Tekken. We instead bridge to the
176+
# `tokenizers` backend from within the graph (see `_tokenize_tf`), so
177+
# there is nothing to build here.
178+
self.vocabulary = vocabulary.copy()
179+
self.merges = list(merges)
180+
181+
def _maybe_initialized_tokenizers(self):
182+
if getattr(self, "_tokenizer", None) is None:
183+
self._set_vocabulary_and_merges_tokenizers(
184+
self.vocabulary, self.merges
185+
)
186+
187+
@preprocessing_function
188+
def _tokenize_tf(self, inputs):
189+
self._maybe_initialized_tokenizers()
190+
191+
def _encode(string_tensor):
192+
values = string_tensor.numpy()
193+
strings = [v.decode("utf-8") for v in values.tolist()]
194+
encodings = self._tokenizer.encode_batch(
195+
strings, add_special_tokens=False
196+
)
197+
return tf.ragged.constant(
198+
[e.ids for e in encodings], dtype=self.compute_dtype
199+
)
200+
201+
inputs = tf.convert_to_tensor(inputs)
202+
unbatched = inputs.shape.rank == 0
203+
if unbatched:
204+
inputs = tf.expand_dims(inputs, 0)
205+
tokens = tf.py_function(
206+
_encode,
207+
[inputs],
208+
Tout=tf.RaggedTensorSpec(
209+
shape=[None, None],
210+
dtype=self.compute_dtype,
211+
ragged_rank=1,
212+
),
213+
)
214+
215+
if self.sequence_length:
216+
output_shape = tokens.shape.as_list()
217+
output_shape[-1] = self.sequence_length
218+
tokens = tokens.to_tensor(
219+
shape=output_shape,
220+
default_value=getattr(self, "pad_token_id", 0),
221+
)
222+
if unbatched:
223+
tokens = tokens[0]
224+
return tokens
225+
226+
def get_config(self):
227+
config = super().get_config()
228+
config.update({"split_pattern": self.split_pattern})
229+
# `unsplittable_tokens` is derived from the special tokens in the
230+
# constructor, so it is not a separate config argument.
231+
del config["unsplittable_tokens"]
232+
return config

0 commit comments

Comments
 (0)