STT load_model silently returns a randomly initialized model when checkpoint keys don't match (strict=False default)
Summary
When a checkpoint's weight keys do not match the model's module tree, the STT
load_model path returns a randomly initialized model with no error and no
warning. Inference then runs and produces fluent-looking garbage, so the failure
surfaces far from its cause — if it surfaces at all.
This is not hypothetical: every Voxtral checkpoint quantized with the other MLX Voxtral port (mzbac/mlx.voxtral) has this key layout, and several are published on the Hub.
Reproduction (mlx-audio 0.5.0, mlx 0.32.1, macOS)
import mlx.nn as nn
from mlx_audio.stt.utils import load_model
m = load_model("MarkusKaemmerer/Voxtral-Mini-3B-2507-8bit-dense-encoder")
# completes silently -- nothing on stdout or stderr (verified with redirect)
q = sum(1 for _, mod in m.named_modules()
if isinstance(mod, (nn.QuantizedLinear, nn.QuantizedEmbedding)))
print(q) # 0 -- for a checkpoint that is 8-bit quantized on diskThe language model's parameters are untouched initializer output — e.g.
language_model.model.layers.0.self_attn.q_proj.weight comes back float32 with
mean ≈ 0, std ≈ 0.0104, while the file carries U32 packed weights plus
scales/biases for that layer. 405 dense Linear modules, 211 of them in the
language model.
That the loader can see the problem is one flag away:
load_model("MarkusKaemmerer/Voxtral-Mini-3B-2507-8bit-dense-encoder", strict=True)
# ValueError: Received 700 parameters not in model: embed_tokens.biases, ...Mechanism
The checkpoint names the language model language_model.layers.0…; mlx-audio's
Voxtral module tree is language_model.model.layers.0… (its LanguageModel
wraps LlamaModel as self.model). From there, three silent steps compound:
- Voxtral's
sanitize()only transposes conv weights; it does not remap the prefix. apply_quantization'sget_class_predicatechecksp in quantizationand falls back tof"{p}.scales" in weights— both keyed on module paths, which don't match the file's paths. So no layer is converted to quantized.base_load_modelcallsmodel.load_weights(..., strict=strict)with the defaultstrict=False, which skips every mismatched key without comment.
What I am not proposing
Flipping the strict default. Several models legitimately rely on lenient
loading — e.g. whisper's sanitize() remaps and drops HF-format keys — and a
hard default change would break working paths.
Proposal
After load_weights, check which of the model's parameters actually received a
value, and warn when parameters were left at initialization (count plus a few
example keys; possibly only above a small threshold to tolerate benign buffers).
That distinguishes the benign case (extra keys in the file, ignored) from the
catastrophic one (model parameters unfilled), costs one set comparison at load
time, and would have turned this from a debugging session into a one-line log
message.
Judging by #538 and #714, key-mismatch situations do occur in the wild, and the silent variant is the expensive one.
Aside
If there is interest, I'd be happy to send a small PR adding the
language_model. → language_model.model. remap to Voxtral's sanitize(), which
would make the mlx-voxtral-layout checkpoints on the Hub actually load — that port
is unmaintained (no release since 2025-08) and its users are natural migrants to
mlx-audio. Related: #900 / #901.
Source: Blaizzy/mlx-audio