Zero-Shot Classification
ONNX
Safetensors
Laya
MLX
English
intent-classification
intent-detection
text-classification
chatbot
conversational-ai
customer-support
routing
out-of-scope-detection
int8
cpu
modernbert
ettin
distillation
knowledge-distillation
onnxruntime
apple-silicon
macos
metal
on-device
zero-shot
nlu
intent-router
semantic-router
llm-router
open-intent-detection
out-of-distribution-detection
customer-service
banking
e-commerce
edge
Eval Results (legacy)
Instructions to use vrajnotviraj/laya-intent-router-150m-onnx with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Laya
How to use vrajnotviraj/laya-intent-router-150m-onnx with Laya:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- MLX
How to use vrajnotviraj/laya-intent-router-150m-onnx with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] hf download vrajnotviraj/laya-intent-router-150m-onnx --local-dir laya-intent-router-150m-onnx
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
Add optional MLX backend for Apple Silicon (backend='mlx' or 'auto'; ONNX stays the default)
Browse files- README.md +48 -2
- laya_intent_router.py +58 -6
- laya_intent_router_mlx.py +174 -0
- mlx/config.json +50 -0
- mlx/model.safetensors +3 -0
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.
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|