vrajnotviraj commited on
Commit
cb7816a
·
verified ·
1 Parent(s): 1446a81

Add optional MLX backend for Apple Silicon (backend='mlx' or 'auto'; ONNX stays the default)

Browse files
README.md CHANGED
@@ -25,6 +25,11 @@ tags:
25
  - distillation
26
  - knowledge-distillation
27
  - onnxruntime
 
 
 
 
 
28
  - zero-shot
29
  - nlu
30
  - intent-router
@@ -70,11 +75,11 @@ model-index:
70
  name: Routing accuracy, 6 unseen domains
71
  ---
72
 
73
- # Laya Intent Router 150M: zero-shot intent classification on CPU (ONNX, int8)
74
 
75
  **A 150M-parameter zero-shot intent detection model with out-of-scope detection, 160 ms p95 on 4 CPU threads.** You give it a user message and a list of intents written in plain English. It tells you which intent the message belongs to, or that it belongs to none of them.
76
 
77
- No training. No labelled data. You write the intents when you call it, and you can change them on every request. It's a small ONNX file you run with `onnxruntime` and `numpy`, no GPU and no torch.
78
 
79
  On a held-out set of 1,636 messages it gets **94.9% routing accuracy and 98.6% out-of-scope recall**, up from 72.8% and 68.2% for the original [Laya](https://huggingface.co/convaiinnovations/laya).
80
 
@@ -131,6 +136,37 @@ Each intent is a key plus 1 or more example phrasings or a short description. `m
131
 
132
  Or clone [the GitHub repo](https://github.com/vrajnotviraj/laya-intent-router) and use `from laya_intent_router import LayaIntentRouter`, or run `python laya_intent_router.py "your message here"`.
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  ## More examples
135
 
136
  A SaaS support bot, 4 intents, nothing fine-tuned:
@@ -205,6 +241,16 @@ Everything trained locally on a 32 GB M2 Pro. Training code: [github.com/vrajnot
205
  | `tokenizer.json`, `rl_agent_config.json` | tokenizer, prompt format, calibrated temperatures, threshold |
206
  | `shortlist/` | bge-small-en-v1.5 ONNX embedder for long intent lists |
207
  | `laya_intent_router.py` | the whole inference code, one file, no torch |
 
 
 
 
 
 
 
 
 
 
208
 
209
  ## License and credits
210
 
 
25
  - distillation
26
  - knowledge-distillation
27
  - onnxruntime
28
+ - mlx
29
+ - apple-silicon
30
+ - macos
31
+ - metal
32
+ - on-device
33
  - zero-shot
34
  - nlu
35
  - intent-router
 
75
  name: Routing accuracy, 6 unseen domains
76
  ---
77
 
78
+ # Laya Intent Router 150M: zero-shot intent classification on CPU (ONNX, int8) and Apple Silicon (MLX)
79
 
80
  **A 150M-parameter zero-shot intent detection model with out-of-scope detection, 160 ms p95 on 4 CPU threads.** You give it a user message and a list of intents written in plain English. It tells you which intent the message belongs to, or that it belongs to none of them.
81
 
82
+ No training. No labelled data. You write the intents when you call it, and you can change them on every request. It's a small ONNX file you run with `onnxruntime` and `numpy`, no GPU and no torch. On an Apple Silicon Mac, an optional MLX backend runs the same model on the GPU in about 15 to 25 ms.
83
 
84
  On a held-out set of 1,636 messages it gets **94.9% routing accuracy and 98.6% out-of-scope recall**, up from 72.8% and 68.2% for the original [Laya](https://huggingface.co/convaiinnovations/laya).
85
 
 
136
 
137
  Or clone [the GitHub repo](https://github.com/vrajnotviraj/laya-intent-router) and use `from laya_intent_router import LayaIntentRouter`, or run `python laya_intent_router.py "your message here"`.
138
 
139
+ ## Faster on a Mac: MLX backend
140
+
141
+ On Apple Silicon the same model can run on the GPU through [MLX](https://github.com/ml-explore/mlx). Install `mlx` next to the usual packages and pass `backend="mlx"` or `backend="auto"`:
142
+
143
+ ```bash
144
+ pip install onnxruntime tokenizers numpy huggingface_hub mlx
145
+ ```
146
+
147
+ ```python
148
+ r = lir.LayaIntentRouter.from_pretrained(backend="auto") # MLX if it can, ONNX if not
149
+ r.backend # 'mlx'
150
+ lir.LayaIntentRouter.from_pretrained(backend="mlx") # force MLX, errors out if mlx is missing
151
+ lir.LayaIntentRouter.from_pretrained() # default: ONNX on CPU, never downloads mlx/
152
+ ```
153
+
154
+ The default is ONNX, so nothing changes unless you ask for MLX. `auto` only picks MLX on an Apple Silicon Mac with `mlx` importable and the `mlx/` weights in the repo. Everywhere else (Linux, Windows, Intel Macs, or a Mac without `mlx`) it runs ONNX on CPU, same as before. Each backend downloads only its own weights, so ONNX users never pull the 328 MB `mlx/` folder and MLX users never pull `laya.onnx`. `mlx` is optional and never required.
155
+
156
+ One catch: a copy of `laya_intent_router.py` from before September 29, 2026 downloads the whole repo, `mlx/` included. Grab the current file to skip that.
157
+
158
+ The MLX weights are converted from the same trained checkpoint as `laya.onnx` (fp16, with the Linear layers quantized to 8 bits at load). On 5,000 dev routing episodes they made the same match-or-none decision as the ONNX model 99.7% of the time, and accuracy and out-of-scope recall were within 0.001. The few that differ sit right on the 0.625 threshold, where ONNX's own int8 rounding tips them one way or the other.
159
+
160
+ Latency on an M2 Pro, 1 message at a time, warm, whole `route()` call including the shortlist (p50 / p95, 300 calls each):
161
+
162
+ | intents | ONNX, 4 CPU threads | MLX (M2 Pro GPU) |
163
+ |---|---|---|
164
+ | 2 | 66 / 107 ms | 15 / 22 ms |
165
+ | 7 | 107 / 213 ms | 24 / 37 ms |
166
+ | 20 | 150 / 219 ms | 28 / 38 ms |
167
+
168
+ So about 4 to 5x faster. The machine was busy during these runs (ONNX p95 is 160 ms on a quiet one), so treat the p95s as upper bounds and compare the columns with each other. It doesn't get under 10 ms: the cost is 22 encoder layers over a ~225-token prompt, and dropping from fp32 to fp16 or 8-bit saves at most about 8 ms. I've only measured an M2 Pro.
169
+
170
  ## More examples
171
 
172
  A SaaS support bot, 4 intents, nothing fine-tuned:
 
241
  | `tokenizer.json`, `rl_agent_config.json` | tokenizer, prompt format, calibrated temperatures, threshold |
242
  | `shortlist/` | bge-small-en-v1.5 ONNX embedder for long intent lists |
243
  | `laya_intent_router.py` | the whole inference code, one file, no torch |
244
+ | `mlx/` | the same model as MLX weights for Apple Silicon (fp16, 8-bit Linear at load, 328 MB), optional |
245
+ | `laya_intent_router_mlx.py` | the MLX model code, only loaded when you pass `backend="mlx"` or `"auto"` |
246
+
247
+ ## FAQ
248
+
249
+ **Can I run it in Ollama or LM Studio?**
250
+
251
+ No, and I don't think it's possible without breaking the model. Ollama and LM Studio run GGUF files through llama.cpp, which is built for text generation and plain embeddings. This model isn't either of those. After the encoder it has a small decision head: 2 extra transformer layers, a scorer that reads one position per intent (the `[MASK]` in front of each one) and a temperature per menu size. GGUF has nowhere to put that head, and llama.cpp has no way to run it. You'd get an embedding model and lose the part that says "none of these".
252
+
253
+ If you want it local and fast on a Mac, use the MLX backend above. It runs the whole graph, head included, in-process.
254
 
255
  ## License and credits
256
 
laya_intent_router.py CHANGED
@@ -9,12 +9,17 @@
9
  # {'match': 'cancel_order', 'score': 0.97, 'probabilities': {...}}
10
 
11
  Needs: pip install onnxruntime tokenizers numpy huggingface_hub
 
 
12
  """
13
 
14
  import hashlib
15
  import json
 
16
  import os
 
17
  import re
 
18
  from collections import OrderedDict
19
 
20
  import numpy as np
@@ -30,6 +35,9 @@ HISTORY_HINT = (
30
  )
31
  NO_MATCH_DESCRIPTION = "Gibberish, filler words, or a message unrelated to every other path."
32
  NONE = "__none__"
 
 
 
33
  OPTION_CAP, MIN_HEAD_TOKENS, PHRASING_SEP = 96, 16, '" | "'
34
 
35
 
@@ -53,6 +61,32 @@ def _humanise(path_id):
53
  return re.sub(r"[_\-.:/]+", " ", s).strip().lower()
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def _session(path, threads):
57
  import onnxruntime as ort
58
 
@@ -100,13 +134,21 @@ class Shortlist:
100
 
101
 
102
  class LayaIntentRouter:
103
- def __init__(self, model_dir, threads=4, shortlist_k=4):
 
 
104
  from tokenizers import Tokenizer
105
 
 
 
 
 
 
106
  self.cfg = json.load(open(os.path.join(model_dir, "rl_agent_config.json")))
107
  self.tok = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))
108
  self.cls, self.sep, self.mask = (self.tok.token_to_id(t) for t in ("[CLS]", "[SEP]", "[MASK]"))
109
- self.sess = _session(os.path.join(model_dir, "laya.onnx"), threads)
 
110
  self.threshold = self.cfg.get("match_threshold", 0.625)
111
  self.temps = self.cfg["temperature_by_options"]
112
  sl_dir = os.path.join(model_dir, "shortlist")
@@ -114,10 +156,21 @@ class LayaIntentRouter:
114
  self.shortlist = Shortlist(sl_dir, threads) if self.k else None
115
 
116
  @classmethod
117
- def from_pretrained(cls, repo_id=REPO_ID, **kw):
 
118
  from huggingface_hub import snapshot_download
119
 
120
- return cls(snapshot_download(repo_id), **kw)
 
 
 
 
 
 
 
 
 
 
121
 
122
  def _tokens(self, text):
123
  return self.tok.encode(text.replace("[MASK]", " "), add_special_tokens=False)
@@ -189,8 +242,6 @@ class LayaIntentRouter:
189
 
190
 
191
  if __name__ == "__main__":
192
- import sys
193
-
194
  here = os.path.dirname(os.path.abspath(__file__))
195
  r = LayaIntentRouter(here) if os.path.exists(os.path.join(here, "laya.onnx")) else LayaIntentRouter.from_pretrained()
196
  paths = {
@@ -199,6 +250,7 @@ if __name__ == "__main__":
199
  "return_order": ["Customer wants to return an order"],
200
  "wrong_item": ["Customer received an item different from what they ordered"],
201
  }
 
202
  for msg in sys.argv[1:] or ["where is my parcel #A-7721", "qwewqeqw", "i got blue shoes but ordered black"]:
203
  out = r.route(msg, paths)
204
  print(f"{msg!r:45} -> {out['match']} ({out['score']:.2f})")
 
9
  # {'match': 'cancel_order', 'score': 0.97, 'probabilities': {...}}
10
 
11
  Needs: pip install onnxruntime tokenizers numpy huggingface_hub
12
+ On Apple Silicon, `pip install mlx` as well and the model runs on the GPU with backend="mlx" or "auto";
13
+ laya_intent_router_mlx.py has the MLX code). The default is backend="onnx" (CPU), which never downloads mlx/.
14
  """
15
 
16
  import hashlib
17
  import json
18
+ import importlib.util
19
  import os
20
+ import platform
21
  import re
22
+ import sys
23
  from collections import OrderedDict
24
 
25
  import numpy as np
 
35
  )
36
  NO_MATCH_DESCRIPTION = "Gibberish, filler words, or a message unrelated to every other path."
37
  NONE = "__none__"
38
+ BACKENDS = ("auto", "onnx", "mlx")
39
+ SHARED_FILES = ["tokenizer.json", "rl_agent_config.json", "shortlist/*"]
40
+ BACKEND_FILES = {"onnx": ["laya.onnx"], "mlx": ["mlx/*", "laya_intent_router_mlx.py"]}
41
  OPTION_CAP, MIN_HEAD_TOKENS, PHRASING_SEP = 96, 16, '" | "'
42
 
43
 
 
61
  return re.sub(r"[_\-.:/]+", " ", s).strip().lower()
62
 
63
 
64
+ def _mlx_available():
65
+ if sys.platform != "darwin" or platform.machine() != "arm64":
66
+ return False
67
+ try:
68
+ import mlx.core as mx
69
+
70
+ return bool(mx.metal.is_available()) # False in macOS VMs / CI runners without a Metal GPU
71
+ except Exception:
72
+ return False
73
+
74
+
75
+ def _has_mlx_weights(model_dir):
76
+ return os.path.exists(os.path.join(model_dir, "mlx", "model.safetensors"))
77
+
78
+
79
+ def _mlx_session(model_dir):
80
+ for d in (os.path.dirname(os.path.abspath(__file__)), model_dir): # next to this file, else the downloaded copy
81
+ f = os.path.join(d, "laya_intent_router_mlx.py")
82
+ if os.path.exists(f):
83
+ spec = importlib.util.spec_from_file_location("laya_intent_router_mlx", f)
84
+ mod = importlib.util.module_from_spec(spec)
85
+ spec.loader.exec_module(mod)
86
+ return mod.Session(os.path.join(model_dir, "mlx"))
87
+ raise FileNotFoundError("laya_intent_router_mlx.py not found next to laya_intent_router.py or in " + model_dir)
88
+
89
+
90
  def _session(path, threads):
91
  import onnxruntime as ort
92
 
 
134
 
135
 
136
  class LayaIntentRouter:
137
+ def __init__(self, model_dir, threads=4, shortlist_k=4, backend="onnx"):
138
+ """backend: "onnx" (CPU, default), "mlx" (Apple GPU) or "auto" (mlx on Apple Silicon when mlx is installed
139
+ and mlx/ weights are present, onnx otherwise). The chosen one is in self.backend."""
140
  from tokenizers import Tokenizer
141
 
142
+ if backend not in BACKENDS:
143
+ raise ValueError(f"backend must be one of {BACKENDS}, got {backend!r}")
144
+ if backend == "auto":
145
+ backend = "mlx" if _has_mlx_weights(model_dir) and _mlx_available() else "onnx"
146
+
147
  self.cfg = json.load(open(os.path.join(model_dir, "rl_agent_config.json")))
148
  self.tok = Tokenizer.from_file(os.path.join(model_dir, "tokenizer.json"))
149
  self.cls, self.sep, self.mask = (self.tok.token_to_id(t) for t in ("[CLS]", "[SEP]", "[MASK]"))
150
+ self.backend = backend
151
+ self.sess = _mlx_session(model_dir) if backend == "mlx" else _session(os.path.join(model_dir, "laya.onnx"), threads)
152
  self.threshold = self.cfg.get("match_threshold", 0.625)
153
  self.temps = self.cfg["temperature_by_options"]
154
  sl_dir = os.path.join(model_dir, "shortlist")
 
156
  self.shortlist = Shortlist(sl_dir, threads) if self.k else None
157
 
158
  @classmethod
159
+ def from_pretrained(cls, repo_id=REPO_ID, backend="onnx", **kw):
160
+ """Downloads only what the backend needs: ONNX never fetches mlx/, MLX never fetches laya.onnx."""
161
  from huggingface_hub import snapshot_download
162
 
163
+ if backend not in BACKENDS:
164
+ raise ValueError(f"backend must be one of {BACKENDS}, got {backend!r}")
165
+ if backend == "auto":
166
+ if _mlx_available():
167
+ d = snapshot_download(repo_id, allow_patterns=SHARED_FILES + BACKEND_FILES["mlx"])
168
+ if _has_mlx_weights(d):
169
+ return cls(d, backend="mlx", **kw)
170
+ backend = "onnx"
171
+ if backend == "mlx" and not _mlx_available(): # fail before downloading 328 MB we can't use
172
+ raise ImportError('backend="mlx" needs Apple Silicon and `pip install mlx`')
173
+ return cls(snapshot_download(repo_id, allow_patterns=SHARED_FILES + BACKEND_FILES[backend]), backend=backend, **kw)
174
 
175
  def _tokens(self, text):
176
  return self.tok.encode(text.replace("[MASK]", " "), add_special_tokens=False)
 
242
 
243
 
244
  if __name__ == "__main__":
 
 
245
  here = os.path.dirname(os.path.abspath(__file__))
246
  r = LayaIntentRouter(here) if os.path.exists(os.path.join(here, "laya.onnx")) else LayaIntentRouter.from_pretrained()
247
  paths = {
 
250
  "return_order": ["Customer wants to return an order"],
251
  "wrong_item": ["Customer received an item different from what they ordered"],
252
  }
253
+ print(f"backend: {r.backend}")
254
  for msg in sys.argv[1:] or ["where is my parcel #A-7721", "qwewqeqw", "i got blue shoes but ordered black"]:
255
  out = r.route(msg, paths)
256
  print(f"{msg!r:45} -> {out['match']} ({out['score']:.2f})")
laya_intent_router_mlx.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MLX backend for laya_intent_router.py: the same model running on the Apple Silicon GPU.
2
+
3
+ Used by LayaIntentRouter(backend="mlx" or "auto"). Needs `pip install mlx` and the weights in `mlx/`.
4
+ Encoder: ModernBERT (Ettin-150M). Pre-norm layers (layer 0 has no attn_norm), bias-free Linear and
5
+ LayerNorm, GeGLU MLP with exact GELU, rotate-half RoPE with a theta per layer type, global attention
6
+ on every 3rd layer and a |i - j| <= local_attention // 2 window on the rest. Head: 2 norm-first
7
+ transformer layers (ReLU), then a scorer on each [MASK] marker. Weights are stored in fp16; the Linear
8
+ layers are quantized to 8 bits at load when mlx/config.json asks for it.
9
+ """
10
+
11
+ import json
12
+ import os
13
+
14
+ import mlx.core as mx
15
+ import mlx.nn as nn
16
+ import numpy as np
17
+
18
+
19
+ def _mask(attention_mask, window=None, L=None):
20
+ m = None
21
+ if window is not None:
22
+ i = mx.arange(L)
23
+ m = (mx.abs(i[:, None] - i[None, :]) <= window)[None, None]
24
+ if attention_mask is not None:
25
+ pad = attention_mask.astype(mx.bool_)[:, None, None, :]
26
+ m = pad if m is None else m & pad
27
+ return m
28
+
29
+
30
+ class Attention(nn.Module):
31
+ def __init__(self, d, heads, theta):
32
+ super().__init__()
33
+ self.heads, self.theta = heads, theta
34
+ self.Wqkv = nn.Linear(d, 3 * d, bias=False)
35
+ self.Wo = nn.Linear(d, d, bias=False)
36
+
37
+ def __call__(self, x, mask):
38
+ B, L, d = x.shape
39
+ q, k, v = self.Wqkv(x).reshape(B, L, 3, self.heads, -1).transpose(2, 0, 3, 1, 4)
40
+ q = mx.fast.rope(q, q.shape[-1], traditional=False, base=self.theta, scale=1.0, offset=0)
41
+ k = mx.fast.rope(k, k.shape[-1], traditional=False, base=self.theta, scale=1.0, offset=0)
42
+ o = mx.fast.scaled_dot_product_attention(q, k, v, scale=q.shape[-1] ** -0.5, mask=mask)
43
+ return self.Wo(o.transpose(0, 2, 1, 3).reshape(B, L, d))
44
+
45
+
46
+ class MLP(nn.Module):
47
+ def __init__(self, d, inter):
48
+ super().__init__()
49
+ self.Wi = nn.Linear(d, 2 * inter, bias=False)
50
+ self.Wo = nn.Linear(inter, d, bias=False)
51
+
52
+ def __call__(self, x):
53
+ a, g = mx.split(self.Wi(x), 2, axis=-1)
54
+ return self.Wo(nn.gelu(a) * g)
55
+
56
+
57
+ class EncoderLayer(nn.Module):
58
+ def __init__(self, c, idx):
59
+ super().__init__()
60
+ d, eps = c["hidden_size"], c["norm_eps"]
61
+ self.sliding = c["layer_types"][idx] == "sliding_attention"
62
+ theta = c["rope_parameters"]["sliding_attention" if self.sliding else "full_attention"]["rope_theta"]
63
+ if idx:
64
+ self.attn_norm = nn.LayerNorm(d, eps=eps, bias=False)
65
+ self.attn = Attention(d, c["num_attention_heads"], theta)
66
+ self.mlp_norm = nn.LayerNorm(d, eps=eps, bias=False)
67
+ self.mlp = MLP(d, c["intermediate_size"])
68
+
69
+ def __call__(self, x, mask):
70
+ x = x + self.attn(self.attn_norm(x) if "attn_norm" in self else x, mask)
71
+ return x + self.mlp(self.mlp_norm(x))
72
+
73
+
74
+ class Embeddings(nn.Module):
75
+ def __init__(self, c):
76
+ super().__init__()
77
+ self.tok_embeddings = nn.Embedding(c["vocab_size"], c["hidden_size"])
78
+ self.norm = nn.LayerNorm(c["hidden_size"], eps=c["norm_eps"], bias=False)
79
+
80
+ def __call__(self, ids):
81
+ return self.norm(self.tok_embeddings(ids))
82
+
83
+
84
+ class ModernBert(nn.Module):
85
+ def __init__(self, c):
86
+ super().__init__()
87
+ self.window = c["local_attention"] // 2
88
+ self.embeddings = Embeddings(c)
89
+ self.layers = [EncoderLayer(c, i) for i in range(c["num_hidden_layers"])]
90
+ self.final_norm = nn.LayerNorm(c["hidden_size"], eps=c["norm_eps"], bias=False)
91
+
92
+ def __call__(self, ids, attention_mask=None):
93
+ x = self.embeddings(ids)
94
+ full, local = _mask(attention_mask), _mask(attention_mask, self.window, ids.shape[1])
95
+ for layer in self.layers:
96
+ x = layer(x, local if layer.sliding else full)
97
+ return self.final_norm(x)
98
+
99
+
100
+ class MultiheadAttention(nn.Module):
101
+ def __init__(self, d, heads):
102
+ super().__init__()
103
+ self.heads = heads
104
+ self.in_proj_weight, self.in_proj_bias = mx.zeros((3 * d, d)), mx.zeros((3 * d,))
105
+ self.out_proj = nn.Linear(d, d)
106
+
107
+ def __call__(self, x, mask):
108
+ B, L, d = x.shape
109
+ qkv = x @ self.in_proj_weight.T + self.in_proj_bias
110
+ q, k, v = qkv.reshape(B, L, 3, self.heads, -1).transpose(2, 0, 3, 1, 4)
111
+ o = mx.fast.scaled_dot_product_attention(q, k, v, scale=q.shape[-1] ** -0.5, mask=mask)
112
+ return self.out_proj(o.transpose(0, 2, 1, 3).reshape(B, L, d))
113
+
114
+
115
+ class HeadLayer(nn.Module):
116
+ def __init__(self, d, heads):
117
+ super().__init__()
118
+ self.self_attn = MultiheadAttention(d, heads)
119
+ self.linear1, self.linear2 = nn.Linear(d, 4 * d), nn.Linear(4 * d, d)
120
+ self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
121
+
122
+ def __call__(self, x, mask):
123
+ x = x + self.self_attn(self.norm1(x), mask)
124
+ return x + self.linear2(nn.relu(self.linear1(self.norm2(x))))
125
+
126
+
127
+ class Head(nn.Module):
128
+ def __init__(self, d, n):
129
+ super().__init__()
130
+ self.layers = [HeadLayer(d, max(1, d // 64)) for _ in range(n)]
131
+
132
+
133
+ class DecisionModel(nn.Module):
134
+ def __init__(self, c):
135
+ super().__init__()
136
+ d = c["hidden_size"]
137
+ self.encoder = ModernBert(c)
138
+ self.head = Head(d, c["head_layers"])
139
+ self.type_emb = nn.Embedding(3, d)
140
+ self.scorer = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, d), nn.GELU(), nn.Linear(d, 1))
141
+ # the act head ships with the weights but routing never reads it
142
+ self.act_head = nn.Sequential(nn.Linear(d + 4, 256), nn.GELU(), nn.Linear(256, c["n_act"]))
143
+ self.temperature = mx.ones((3,))
144
+
145
+ def __call__(self, input_ids, attention_mask, marker_pos, marker_mask, qtype):
146
+ h = self.encoder(input_ids, attention_mask) + self.type_emb(qtype)[:, None, :]
147
+ mask = _mask(attention_mask)
148
+ for layer in self.head.layers:
149
+ h = layer(h, mask)
150
+ m = mx.take_along_axis(h, mx.maximum(marker_pos, 0)[:, :, None], axis=1)
151
+ return mx.where(marker_mask, self.scorer(m).squeeze(-1).astype(mx.float32), -1e4)
152
+
153
+
154
+ class Session:
155
+ """Drop-in for the onnxruntime session the router calls: run(["logits"], feeds) -> [np.ndarray]."""
156
+
157
+ def __init__(self, weights_dir):
158
+ c = json.load(open(os.path.join(weights_dir, "config.json")))
159
+ self.model = DecisionModel(c)
160
+ self.model.load_weights(os.path.join(weights_dir, "model.safetensors"), strict=True)
161
+ q = c.get("quantization")
162
+ if q:
163
+ nn.quantize(self.model, group_size=q["group_size"], bits=q["bits"],
164
+ class_predicate=lambda _, m: isinstance(m, nn.Linear) and m.weight.shape[-1] % q["group_size"] == 0)
165
+ self.model.eval()
166
+ mx.eval(self.model.parameters())
167
+
168
+ def run(self, names, feeds):
169
+ assert list(names) == ["logits"], names
170
+ am = feeds["attention_mask"]
171
+ f = {k: mx.array(v) for k, v in feeds.items()}
172
+ logits = self.model(f["input_ids"], None if am.all() else f["attention_mask"], f["marker_pos"],
173
+ f["marker_mask"], f["qtype"])
174
+ return [np.array(logits)]
mlx/config.json ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "hidden_size": 768,
3
+ "num_attention_heads": 12,
4
+ "num_hidden_layers": 22,
5
+ "intermediate_size": 1152,
6
+ "layer_types": [
7
+ "full_attention",
8
+ "sliding_attention",
9
+ "sliding_attention",
10
+ "full_attention",
11
+ "sliding_attention",
12
+ "sliding_attention",
13
+ "full_attention",
14
+ "sliding_attention",
15
+ "sliding_attention",
16
+ "full_attention",
17
+ "sliding_attention",
18
+ "sliding_attention",
19
+ "full_attention",
20
+ "sliding_attention",
21
+ "sliding_attention",
22
+ "full_attention",
23
+ "sliding_attention",
24
+ "sliding_attention",
25
+ "full_attention",
26
+ "sliding_attention",
27
+ "sliding_attention",
28
+ "full_attention"
29
+ ],
30
+ "local_attention": 128,
31
+ "rope_parameters": {
32
+ "full_attention": {
33
+ "rope_theta": 160000.0,
34
+ "rope_type": "default"
35
+ },
36
+ "sliding_attention": {
37
+ "rope_theta": 160000.0,
38
+ "rope_type": "default"
39
+ }
40
+ },
41
+ "norm_eps": 1e-05,
42
+ "vocab_size": 50368,
43
+ "head_layers": 2,
44
+ "n_act": 2,
45
+ "model_type": "laya-decision-modernbert",
46
+ "quantization": {
47
+ "bits": 8,
48
+ "group_size": 64
49
+ }
50
+ }
mlx/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b8b637753dbc51c3df33ce165444df95d692309b770cbee0759c346e4a9be0c1
3
+ size 327984812