pure v7: compact 419 MB FP16+INT8 main release; drop fp32 model.pt (kept on pure_v6)
Browse files- QUANTIZATION.md +63 -18
- README.md +68 -38
- config.json +8 -1
- gate.py +11 -1
- gate_pure.py +62 -0
- model.onnx +2 -2
- model.pt +0 -3
- server.py +47 -0
QUANTIZATION.md
CHANGED
|
@@ -1,14 +1,35 @@
|
|
| 1 |
# Quantization research for send-gate
|
| 2 |
|
| 3 |
-
Status:
|
| 4 |
|
| 5 |
-
Goal:
|
| 6 |
|
| 7 |
-
##
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
| Input | fp32 | dynamic INT8 |
|
| 14 |
|---|---:|---:|
|
|
@@ -17,15 +38,37 @@ ONNX Runtime `quantize_dynamic` with uint8 weights, per-channel, reduce_range, a
|
|
| 17 |
| `n g a` | 0.820 | 0.500 |
|
| 18 |
| `pot` | 0.331 | 0.714 |
|
| 19 |
|
| 20 |
-
On the v44 model, benchmark catch fell from 93.9% to about 84.1%.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
### Static QDQ, MinMax, uint8
|
| 27 |
|
| 28 |
-
This was an early attempt with 400 calibration rows and uint8 activations. It was much worse than dynamic INT8. It is not a fair test of static quantization because the recommended CPU recipe is signed S8S8 QDQ with percentile calibration.
|
| 29 |
|
| 30 |
## What the literature says
|
| 31 |
|
|
@@ -128,16 +171,18 @@ If 8-bit mixed precision cannot reach 500 MB, try 4-bit weight only MatMulNBits
|
|
| 128 |
|
| 129 |
### Ship rule
|
| 130 |
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
|
| 134 |
-
- Benchmark catch within 1 point of fp32.
|
| 135 |
-
- False walls within 1 point of fp32.
|
| 136 |
-
- Joke deflection and sex policy suites within 2 points of fp32.
|
| 137 |
-
- No regression on the always block terms and the required allow homonyms.
|
| 138 |
-
- Latency not worse than the fp32 model.
|
| 139 |
|
| 140 |
-
If no candidate meets
|
| 141 |
|
| 142 |
## References
|
| 143 |
|
|
|
|
| 1 |
# Quantization research for send-gate
|
| 2 |
|
| 3 |
+
Status: v7 ships an FP16 encoder with INT8 embeddings in a 419 MB ONNX file. The model runs on ONNX Runtime CPU with the full 256k vocabulary and the original tokenizer. The fp32 v6 release remains on branch `pure_v6`.
|
| 4 |
|
| 5 |
+
Goal: reduce the 1.23 GB / 1.65 GiB v6 footprint while keeping the fp32 decisions on the benchmark, the custom suites and a short phrase regression set. For a safety gate, decision parity matters more than perplexity or aggregate accuracy.
|
| 6 |
|
| 7 |
+
## Shipped recipe: FP16 encoder + INT8 embedding, 419 MB
|
| 8 |
|
| 9 |
+
The token and character embedding tables are quantized dynamically to INT8. Everything else is converted to FP16, with the rotary Casts and the embedding DequantizeLinear nodes kept in FP32. The full 256k vocabulary is retained, so there is no token pruning and no OOV remapping.
|
| 10 |
|
| 11 |
+
| Candidate | Disk | Session RSS | Benchmark at 0.94 | Verdict |
|
| 12 |
+
|---|---:|---:|---|---|
|
| 13 |
+
| v6 fp32 | 1.23 GB | 1.65 GiB | 95.9c / 6.7w | reference |
|
| 14 |
+
| v7 FP16 encoder + INT8 embedding | **419 MB** | **0.85 GiB** | **95.6c / 6.9w** | shipped |
|
| 15 |
+
|
| 16 |
+
Held-out variants stay at 100% recall: gap 100/10, kkr 100/0, fresh 100/10, fresh2 100/16.7. The per-message latency is about 80 to 110 ms on this CPU, versus about 70 to 100 ms for fp32.
|
| 17 |
+
|
| 18 |
+
Why this works: the embedding is the dominant parameter block (196M of 307M), and embedding-only INT8 was already near-lossless. FP16 halves the remaining encoder without changing any tokenizer decisions. Keeping the full vocabulary avoids the random-row and OOV problems that dominated every pruning experiment.
|
| 19 |
+
|
| 20 |
+
The conversion needed three fixes because `onnxconverter-common` does not handle this graph cleanly:
|
| 21 |
+
|
| 22 |
+
1. Block the four rotary Casts, otherwise the converter binds them to mixed FP16/FP32 types.
|
| 23 |
+
2. Keep the embedding DequantizeLinear nodes in FP32. ORT rejects an FP16 scale for an INT8 DequantizeLinear.
|
| 24 |
+
3. Repair 133 Cast attributes where the converter annotated an FP16 output but left `to=FLOAT`.
|
| 25 |
+
|
| 26 |
+
`experiments/export_fp16_int8.py` performs all three steps. The rebuild is byte-identical to the released file (SHA256 `d1e0b78f...`).
|
| 27 |
+
|
| 28 |
+
## Rejected experiments
|
| 29 |
+
|
| 30 |
+
### Full dynamic INT8, 295 MB
|
| 31 |
+
|
| 32 |
+
ONNX Runtime `quantize_dynamic` with uint8 weights, per-channel, reduce_range, MatMul and Gather.
|
| 33 |
|
| 34 |
| Input | fp32 | dynamic INT8 |
|
| 35 |
|---|---:|---:|
|
|
|
|
| 38 |
| `n g a` | 0.820 | 0.500 |
|
| 39 |
| `pot` | 0.331 | 0.714 |
|
| 40 |
|
| 41 |
+
On the v44 model, benchmark catch fell from 93.9% to about 84.1%. On the current v6 combined model the full dynamic model catches about 63% at 0.94 with about 21% walls. The model did not simply become less confident; some benign short phrases became more confident.
|
| 42 |
+
|
| 43 |
+
### Full static S8S8 QDQ, 296 MB
|
| 44 |
+
|
| 45 |
+
Signed S8S8 QDQ, per-channel weights, percentile calibration at 99.99. The quantizer needed external data and a small calibration set to avoid the OOM kill. Result: about 93% caught with about 47% walls at 0.94. Not usable.
|
| 46 |
+
|
| 47 |
+
### Encoder INT8 with FP32 embedding, 858 MB
|
| 48 |
|
| 49 |
+
Dynamic and static encoder-only runs both failed. Dynamic: 57% caught at 0.94. Static: 94% caught but 48% walls. The encoder, not the embedding, is the sensitive part of this model.
|
| 50 |
|
| 51 |
+
### Selective encoder layers, 520 MB
|
| 52 |
+
|
| 53 |
+
Quantizing the last 8 layers saved 120 MB but collapsed benchmark catch to 68% at 0.94. Quantizing only the 22 `attn/Wqkv` matrices saved 116 MB but still lost recall (94c / 8w at 0.94). This was the Unsloth Dynamic V3.1-inspired experiment: allocate lower precision only to less sensitive layers. On this small encoder the sensitivity is too flat for layer-wise bit allocation to work without QAT.
|
| 54 |
+
|
| 55 |
+
### Embedding-only INT8, 611 MB
|
| 56 |
+
|
| 57 |
+
Near-lossless: 96c / 7w at 0.94, kkr 100/0, fresh2 100/17. This established that the embedding can be compressed safely and pointed at the encoder as the remaining cost.
|
| 58 |
+
|
| 59 |
+
### FP16 on the unquantized fp32 graph
|
| 60 |
+
|
| 61 |
+
The first FP16 attempt failed because the rotary Cast and the embedding DequantizeLinear scale were converted to FP16. The repaired v7 pipeline above fixes both. FP16 alone on the full graph would be about 615 MB; combining it with INT8 embedding reaches 419 MB.
|
| 62 |
+
|
| 63 |
+
### Embedding pruning, 498 to 521 MB
|
| 64 |
+
|
| 65 |
+
Pruning the 256k embedding to the most frequent tokens and remapping old IDs to compact IDs was tested at 100k, 80k and 70k rows. Sizes were 520.8, 505.5 and 497.8 MB. The 70k model met the disk target but lost benchmark recall (94.9c / 10.4w at 0.94) and produced new walls on short chat messages.
|
| 66 |
+
|
| 67 |
+
The root cause was not only OOV rate (0.5% to 0.8% of benchmark tokens). Tokens absent from the training corpus had random, never-trained embedding rows in v6, and replacing them with a different random `<unk>` row flipped short messages such as `wassup` from 0.03 to 0.99. Neutral mean/zero OOV rows fixed the short-message walls but lost harmful items such as `TODAY I END IT` and `give me gaijs`. Nearest-kept-embedding remapping preserved some rare-token direction but still diluted others. A pruned tokenizer that decomposes dropped pieces into kept subwords also changed tokenization on in-distribution text, because merge closure requires thousands of intermediate BPE symbols (105,860 tokens for the full closure).
|
| 68 |
+
|
| 69 |
+
Keeping the full vocabulary in FP16+INT8 avoids this entire failure mode and was both smaller and more accurate than the 100k pruned model (419 MB, 95.6c / 6.9w versus 521 MB, 95.3c / 8.8w).
|
| 70 |
|
|
|
|
| 71 |
|
|
|
|
| 72 |
|
| 73 |
## What the literature says
|
| 74 |
|
|
|
|
| 171 |
|
| 172 |
### Ship rule
|
| 173 |
|
| 174 |
+
v7 meets the disk and decision targets:
|
| 175 |
+
|
| 176 |
+
- Under 500 MB on disk: **419 MB ONNX, about 438 MB with tokenizer and n-gram**.
|
| 177 |
+
- Benchmark catch within 1 point of fp32: **95.6% versus 95.9%**.
|
| 178 |
+
- False walls within 1 point of fp32: **6.9% versus 6.7%**.
|
| 179 |
+
- Joke deflection and sex policy suites within 2 points: **met**.
|
| 180 |
+
- No regression on the always block terms and required allow homonyms: **met**.
|
| 181 |
+
- Latency not worse by more than about 20 ms: **80-110 ms versus 70-100 ms**.
|
| 182 |
|
| 183 |
+
It does not meet a 500 MB **resident memory** target. The ONNX Runtime session is about 0.85 GiB RSS and the full Python gate is about 1.2 GiB, because ORT CPU keeps FP32 working copies and prepacked weights. A 500 MB resident target would require encoder QAT to INT8, which is the next research step if resident memory becomes the hard constraint.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
+
If no candidate meets the decision targets, keep fp32 in production and continue research. A fast gate that misses `ik ben nog maagd` or blocks `pot` is not a win.
|
| 186 |
|
| 187 |
## References
|
| 188 |
|
README.md
CHANGED
|
@@ -1,32 +1,33 @@
|
|
| 1 |
---
|
| 2 |
language: [nl, en]
|
| 3 |
license: mit
|
| 4 |
-
tags: [text-classification, content-moderation, safety, dutch, flemish, english, mmbert, binary-classifier, no-rules]
|
| 5 |
pipeline_tag: text-classification
|
| 6 |
---
|
| 7 |
|
| 8 |
-
# send-gate pure
|
| 9 |
|
| 10 |
Binary safety gate for teen chat. The output is a verdict: **block** or **allow**. There is no review band.
|
| 11 |
|
| 12 |
-
**Two learned branches, no regex, no word lists. Main 307M classifier plus a learned character n-gram branch. Thresholds 0.94 / 0.95. 95.
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
The
|
| 17 |
|
| 18 |
## Files
|
| 19 |
|
| 20 |
| File | Purpose |
|
| 21 |
|---|---|
|
| 22 |
-
| `model.onnx` |
|
| 23 |
-
| `model.pt` | PyTorch main checkpoint. |
|
| 24 |
| `ngram_model.npz` | Learned character n-gram branch. 1.5 MB. |
|
|
|
|
| 25 |
| `gate.py` | Combined gate. No regex and no word lists. |
|
| 26 |
-
| `model.py` | Minimal PyTorch loader for the main branch. |
|
| 27 |
-
| `
|
| 28 |
-
| `
|
| 29 |
-
|
|
|
|
| 30 |
|
| 31 |
## Measured results
|
| 32 |
|
|
@@ -34,12 +35,12 @@ Combined gate: main threshold 0.94, n-gram threshold 0.95.
|
|
| 34 |
|
| 35 |
| Suite | Items | Caught | False walls | Accuracy |
|
| 36 |
|---|---:|---:|---:|---:|
|
| 37 |
-
| Project benchmark | 804 | **95.
|
| 38 |
-
| Obfuscation suite | 111 | 98.2% |
|
| 39 |
-
| Sex policy suite | 125 |
|
| 40 |
| Joke deflection suite | 112 | 97.1% | **0.0%** | 98.2% |
|
| 41 |
| Fresh deflection suite | 55 | 96.0% | **0.0%** | 98.2% |
|
| 42 |
-
| General edge suite | 92 |
|
| 43 |
| Final unseen holdout | 90 | 89.4% | **0.0%** | 94.4% |
|
| 44 |
| Stress suite | 80 | 95.0% | 2.5% | 96.2% |
|
| 45 |
| Common word suite | 97 | n/a | **0 blocked** | 100.0% |
|
|
@@ -53,40 +54,43 @@ Held-out variant suites:
|
|
| 53 |
| Fresh variants | 66 | **100.0%** | 10.0% | 1 benign main-model wall |
|
| 54 |
| Fresh2 variants, never trained | 59 | **100.0%** | 16.7% | 2 benign main-model walls |
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
## Requested policy terms
|
| 59 |
|
| 60 |
| Input | Main | N-gram | Verdict |
|
| 61 |
|---|---:|---:|---|
|
| 62 |
| `kkrtest` | 0.991 | 0.991 | block |
|
| 63 |
-
| `kkrtoets` | 0.
|
| 64 |
| `kkrexamen` | 0.061 | 0.994 | block |
|
| 65 |
-
| `kkropdracht` | 0.
|
| 66 |
-
| `kkr moeilijk` | 0.
|
| 67 |
-
| `kkrjaar` |
|
| 68 |
-
| `k4nkermoeilijke` | 0.
|
| 69 |
-
| `amaai, dat was een kankermoeilijke vraag op het examen` | 0.
|
| 70 |
-
| `roblox.gift/login` | 0.
|
| 71 |
-
| `free-skins.xyz/reward` | 0.
|
| 72 |
-
| `negerrr` | 0.
|
| 73 |
-
| `kankermoeilijke` | 0.
|
| 74 |
-
| `mijn oma heeft kanker gehad` | 0.942 | 0.939 | block |
|
| 75 |
|
| 76 |
Benign controls:
|
| 77 |
|
| 78 |
| Input | Main | N-gram | Verdict |
|
| 79 |
|---|---:|---:|---|
|
| 80 |
-
| `negeren` | 0.
|
| 81 |
-
| `Niger is een land` | 0.
|
| 82 |
-
| `ik speel roblox` | 0.
|
| 83 |
-
| `kwart voor 8` | 0.002 | 0.
|
| 84 |
-
| `
|
|
|
|
|
|
|
| 85 |
|
| 86 |
### Remaining gap
|
| 87 |
|
| 88 |
-
- `het woord kanker`
|
| 89 |
- The main branch still walls some benign username, password and photo messages.
|
|
|
|
|
|
|
| 90 |
|
| 91 |
These are documented model limitations, not hidden rules.
|
| 92 |
|
|
@@ -99,10 +103,34 @@ These are documented model limitations, not hidden rules.
|
|
| 99 |
| Main threshold | 0.94 |
|
| 100 |
| N-gram threshold | 0.95 |
|
| 101 |
| Decision | block if either branch crosses its threshold |
|
| 102 |
-
| Latency | about 80 to
|
| 103 |
|
| 104 |
The n-gram branch is a learned model, not a regex and not a word list. It is the component that catches unseen `kkr`/`kanker` compounds such as `kkrexamen`, `kkropdracht`, `kkrjaar` and `k4nkermoeilijke`.
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
## Training
|
| 107 |
|
| 108 |
- Main branch: pure v4 checkpoint `runs/student_v1_pure_v84/last.pt`.
|
|
@@ -111,9 +139,11 @@ The n-gram branch is a learned model, not a regex and not a word list. It is the
|
|
| 111 |
|
| 112 |
Hashes:
|
| 113 |
|
| 114 |
-
- Main
|
| 115 |
-
- Main ONNX: `5ad26dc65068ad652d6fea3f00de8921bffd6b7da7e3cea0f7ed99ce7f309e08`
|
|
|
|
| 116 |
- N-gram model: `0dec246def96c6f0668d22349b5abb60bba37d54fb9340ea7fb3b2bf6c6b0a03`
|
|
|
|
| 117 |
|
| 118 |
## Usage
|
| 119 |
|
|
@@ -135,11 +165,11 @@ print(result.blocked, result.score, result.reason)
|
|
| 135 |
|
| 136 |
## Limitations
|
| 137 |
|
| 138 |
-
- False walls are 6.
|
| 139 |
- `het woord kanker` remains allowed.
|
| 140 |
- The final unseen holdout is 89.4% caught.
|
| 141 |
- The model is binary; there is no review band or explanation.
|
| 142 |
-
- The
|
| 143 |
- The benchmark and custom suites contain judgement calls.
|
| 144 |
|
| 145 |
## License
|
|
|
|
| 1 |
---
|
| 2 |
language: [nl, en]
|
| 3 |
license: mit
|
| 4 |
+
tags: [text-classification, content-moderation, safety, dutch, flemish, english, mmbert, binary-classifier, no-rules, onnx, quantized, fp16, int8]
|
| 5 |
pipeline_tag: text-classification
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# send-gate pure v7: compact Dutch/Flemish + English chat safety gate
|
| 9 |
|
| 10 |
Binary safety gate for teen chat. The output is a verdict: **block** or **allow**. There is no review band.
|
| 11 |
|
| 12 |
+
**Two learned branches, no regex, no word lists. Main 307M classifier plus a learned character n-gram branch. Thresholds 0.94 / 0.95. 95.6% caught and 6.9% false walls on the 804 item benchmark. 100% caught on every held-out variant suite.**
|
| 13 |
|
| 14 |
+
v7 is the compact release: the encoder is stored in FP16 and the token embeddings in INT8. The full 256k vocabulary is kept, so the tokenizer is unchanged and there is no OOV remapping. The ONNX gate is **419 MB on disk** instead of 1.23 GB, with the same decisions as v6 apart from threshold-edge noise.
|
| 15 |
|
| 16 |
+
The fp32 v6 release remains on branch `pure_v6`. The pure v1-v5 releases are on `pure_v1` to `pure_v5`.
|
| 17 |
|
| 18 |
## Files
|
| 19 |
|
| 20 |
| File | Purpose |
|
| 21 |
|---|---|
|
| 22 |
+
| `model.onnx` | FP16 encoder + INT8 embedding main classifier. 419 MB. |
|
|
|
|
| 23 |
| `ngram_model.npz` | Learned character n-gram branch. 1.5 MB. |
|
| 24 |
+
| `tokenizer.json` | Unchanged 256k tokenizer. 17 MB. |
|
| 25 |
| `gate.py` | Combined gate. No regex and no word lists. |
|
| 26 |
+
| `model.py` | Minimal PyTorch loader for the fp32 main branch. |
|
| 27 |
+
| `config.json` | Branch, precision and threshold metadata. |
|
| 28 |
+
| `QUANTIZATION.md` | Quantization research and measurements. |
|
| 29 |
+
|
| 30 |
+
The fp32 PyTorch checkpoint and fp32 ONNX are on branch `pure_v6`.
|
| 31 |
|
| 32 |
## Measured results
|
| 33 |
|
|
|
|
| 35 |
|
| 36 |
| Suite | Items | Caught | False walls | Accuracy |
|
| 37 |
|---|---:|---:|---:|---:|
|
| 38 |
+
| Project benchmark | 804 | **95.6%** | **6.9%** | 94.0% |
|
| 39 |
+
| Obfuscation suite | 111 | 98.2% | 5.5% | 96.4% |
|
| 40 |
+
| Sex policy suite | 125 | 98.8% | **0.0%** | 99.2% |
|
| 41 |
| Joke deflection suite | 112 | 97.1% | **0.0%** | 98.2% |
|
| 42 |
| Fresh deflection suite | 55 | 96.0% | **0.0%** | 98.2% |
|
| 43 |
+
| General edge suite | 92 | 96.2% | 2.6% | 96.7% |
|
| 44 |
| Final unseen holdout | 90 | 89.4% | **0.0%** | 94.4% |
|
| 45 |
| Stress suite | 80 | 95.0% | 2.5% | 96.2% |
|
| 46 |
| Common word suite | 97 | n/a | **0 blocked** | 100.0% |
|
|
|
|
| 54 |
| Fresh variants | 66 | **100.0%** | 10.0% | 1 benign main-model wall |
|
| 55 |
| Fresh2 variants, never trained | 59 | **100.0%** | 16.7% | 2 benign main-model walls |
|
| 56 |
|
| 57 |
+
v6 reference: 95.9% caught / 6.7% walls on the benchmark, and the same 100% recall on every held-out variant suite.
|
| 58 |
|
| 59 |
## Requested policy terms
|
| 60 |
|
| 61 |
| Input | Main | N-gram | Verdict |
|
| 62 |
|---|---:|---:|---|
|
| 63 |
| `kkrtest` | 0.991 | 0.991 | block |
|
| 64 |
+
| `kkrtoets` | 0.557 | 0.995 | block |
|
| 65 |
| `kkrexamen` | 0.061 | 0.994 | block |
|
| 66 |
+
| `kkropdracht` | 0.245 | 0.998 | block |
|
| 67 |
+
| `kkr moeilijk` | 0.077 | 0.984 | block |
|
| 68 |
+
| `kkrjaar` | 0.039 | 0.979 | block |
|
| 69 |
+
| `k4nkermoeilijke` | 0.079 | 0.997 | block |
|
| 70 |
+
| `amaai, dat was een kankermoeilijke vraag op het examen` | 0.958 | 0.998 | block |
|
| 71 |
+
| `roblox.gift/login` | 0.912 | 0.997 | block |
|
| 72 |
+
| `free-skins.xyz/reward` | 0.534 | 0.999 | block |
|
| 73 |
+
| `negerrr` | 0.980 | 0.938 | block |
|
| 74 |
+
| `kankermoeilijke` | 0.965 | 0.998 | block |
|
|
|
|
| 75 |
|
| 76 |
Benign controls:
|
| 77 |
|
| 78 |
| Input | Main | N-gram | Verdict |
|
| 79 |
|---|---:|---:|---|
|
| 80 |
+
| `negeren` | 0.064 | 0.136 | allow |
|
| 81 |
+
| `Niger is een land` | 0.093 | 0.029 | allow |
|
| 82 |
+
| `ik speel roblox` | 0.037 | 0.019 | allow |
|
| 83 |
+
| `kwart voor 8` | 0.002 | 0.046 | allow |
|
| 84 |
+
| `mijn oma heeft kanker gehad` | 0.938 | 0.939 | allow |
|
| 85 |
+
| `het woord kanker` | 0.898 | 0.906 | allow |
|
| 86 |
+
| `stuur een foto van je hond` | 0.981 | 0.136 | block (known main-model wall) |
|
| 87 |
|
| 88 |
### Remaining gap
|
| 89 |
|
| 90 |
+
- `het woord kanker` and some health mentions are still allowed.
|
| 91 |
- The main branch still walls some benign username, password and photo messages.
|
| 92 |
+
- `ja jammer` is a known benign false wall at about 0.99.
|
| 93 |
+
- Several decisions sit within a few thousandths of the threshold, so FP16 rounding can flip an individual edge item.
|
| 94 |
|
| 95 |
These are documented model limitations, not hidden rules.
|
| 96 |
|
|
|
|
| 103 |
| Main threshold | 0.94 |
|
| 104 |
| N-gram threshold | 0.95 |
|
| 105 |
| Decision | block if either branch crosses its threshold |
|
| 106 |
+
| Latency | about 80 to 110 ms per message on this CPU |
|
| 107 |
|
| 108 |
The n-gram branch is a learned model, not a regex and not a word list. It is the component that catches unseen `kkr`/`kanker` compounds such as `kkrexamen`, `kkropdracht`, `kkrjaar` and `k4nkermoeilijke`.
|
| 109 |
|
| 110 |
+
## Compression
|
| 111 |
+
|
| 112 |
+
| Item | v6 fp32 | v7 compact |
|
| 113 |
+
|---|---|---:|
|
| 114 |
+
| ONNX model | 1.23 GB | **419 MB** |
|
| 115 |
+
| Tokenizer | 17 MB | 17 MB |
|
| 116 |
+
| N-gram branch | 1.5 MB | 1.5 MB |
|
| 117 |
+
| Gate files on disk | about 1.25 GB | **about 438 MB** |
|
| 118 |
+
| ORT session RSS | about 1.65 GiB | about 0.85 GiB |
|
| 119 |
+
| Python gate RSS | about 1.65 GiB | about 1.2 GiB |
|
| 120 |
+
| Latency | about 70 to 100 ms | about 80 to 110 ms |
|
| 121 |
+
|
| 122 |
+
The encoder is FP16. The token embedding and char embedding are dynamic INT8. The embedding table is kept in full, so scaling to FP16 does not change tokenization or create OOV tokens. See `QUANTIZATION.md` for the rejected alternatives: encoder INT8, static QDQ, selective layer INT8, FP16 on the unquantized fp32 graph, and embedding pruning.
|
| 123 |
+
|
| 124 |
+
Rebuild:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python experiments/export_fp16_int8.py \
|
| 128 |
+
--source /tmp/opencode/hf_pure_v84/model.onnx \
|
| 129 |
+
--output model.onnx
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
The rebuild is byte-identical to the released file.
|
| 133 |
+
|
| 134 |
## Training
|
| 135 |
|
| 136 |
- Main branch: pure v4 checkpoint `runs/student_v1_pure_v84/last.pt`.
|
|
|
|
| 139 |
|
| 140 |
Hashes:
|
| 141 |
|
| 142 |
+
- Main ONNX, v7: `d1e0b78fcaff6f947e3593e964e4b9a4f25d99abe62558fc02319f6492255d36`
|
| 143 |
+
- Main ONNX, v6 fp32: `5ad26dc65068ad652d6fea3f00de8921bffd6b7da7e3cea0f7ed99ce7f309e08`
|
| 144 |
+
- Main checkpoint, fp32: `a196a6eeec9c5391c7645bf74003474d14e496dba9a9eaed8f913af0cb5c6d75`
|
| 145 |
- N-gram model: `0dec246def96c6f0668d22349b5abb60bba37d54fb9340ea7fb3b2bf6c6b0a03`
|
| 146 |
+
- Tokenizer: `197d4cc5406ee12cc50c8b5511f2393cc32d9db321545979ce041c1199178356`
|
| 147 |
|
| 148 |
## Usage
|
| 149 |
|
|
|
|
| 165 |
|
| 166 |
## Limitations
|
| 167 |
|
| 168 |
+
- False walls are 6.9% on the project benchmark, mostly benign Flemish slang, profanity, dark humour and a few username/password/photo messages.
|
| 169 |
- `het woord kanker` remains allowed.
|
| 170 |
- The final unseen holdout is 89.4% caught.
|
| 171 |
- The model is binary; there is no review band or explanation.
|
| 172 |
+
- The under 500 MB target is met on disk, not in resident memory. The ONNX session is about 0.85 GiB RSS.
|
| 173 |
- The benchmark and custom suites contain judgement calls.
|
| 174 |
|
| 175 |
## License
|
config.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"model_type": "student_gate_pure",
|
| 3 |
-
"version": "
|
| 4 |
"backbone": "jhu-clsp/mmBERT-base",
|
| 5 |
"backbone_params": "307M",
|
| 6 |
"pooling": "cls",
|
|
@@ -12,6 +12,13 @@
|
|
| 12 |
"ngram_threshold": 0.95,
|
| 13 |
"ngram_model": "ngram_model.npz",
|
| 14 |
"branches": ["main", "char_ngram"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"regex": false,
|
| 16 |
"word_lists": false,
|
| 17 |
"languages": ["nl", "en"],
|
|
|
|
| 1 |
{
|
| 2 |
"model_type": "student_gate_pure",
|
| 3 |
+
"version": "pure_v7",
|
| 4 |
"backbone": "jhu-clsp/mmBERT-base",
|
| 5 |
"backbone_params": "307M",
|
| 6 |
"pooling": "cls",
|
|
|
|
| 12 |
"ngram_threshold": 0.95,
|
| 13 |
"ngram_model": "ngram_model.npz",
|
| 14 |
"branches": ["main", "char_ngram"],
|
| 15 |
+
"precision": {
|
| 16 |
+
"encoder": "fp16",
|
| 17 |
+
"token_embedding": "int8",
|
| 18 |
+
"char_embedding": "int8",
|
| 19 |
+
"vocabulary": "full 256k, no pruning, no remapping"
|
| 20 |
+
},
|
| 21 |
+
"model_onnx_bytes": 418972704,
|
| 22 |
"regex": false,
|
| 23 |
"word_lists": false,
|
| 24 |
"languages": ["nl", "en"],
|
gate.py
CHANGED
|
@@ -33,10 +33,14 @@ class SendGate:
|
|
| 33 |
threshold: float = DEFAULT_THRESHOLD,
|
| 34 |
ngram_threshold: float = DEFAULT_NGRAM_THRESHOLD,
|
| 35 |
ngram_model_path: str | Path = "ngram_model.npz",
|
|
|
|
| 36 |
providers: list[str] | None = None):
|
| 37 |
self.tok = Tokenizer.from_file(str(tokenizer_path))
|
|
|
|
|
|
|
| 38 |
self.sess = ort.InferenceSession(
|
| 39 |
-
str(model_path),
|
|
|
|
| 40 |
)
|
| 41 |
self.threshold = threshold
|
| 42 |
self.ngram_threshold = ngram_threshold
|
|
@@ -46,9 +50,15 @@ class SendGate:
|
|
| 46 |
z = np.load(str(ngram_model_path))
|
| 47 |
self.ngram_coef = z["coef"]
|
| 48 |
self.ngram_intercept = float(z["intercept"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
def _encode(self, text: str):
|
| 51 |
ids = self.tok.encode(text).ids[:MAX_LEN]
|
|
|
|
|
|
|
| 52 |
n = len(ids)
|
| 53 |
ids = ids + [0] * (MAX_LEN - n)
|
| 54 |
mask = [1] * n + [0] * (MAX_LEN - n)
|
|
|
|
| 33 |
threshold: float = DEFAULT_THRESHOLD,
|
| 34 |
ngram_threshold: float = DEFAULT_NGRAM_THRESHOLD,
|
| 35 |
ngram_model_path: str | Path = "ngram_model.npz",
|
| 36 |
+
vocab_map_path: str | Path | None = None,
|
| 37 |
providers: list[str] | None = None):
|
| 38 |
self.tok = Tokenizer.from_file(str(tokenizer_path))
|
| 39 |
+
sess_options = ort.SessionOptions()
|
| 40 |
+
sess_options.log_severity_level = 3
|
| 41 |
self.sess = ort.InferenceSession(
|
| 42 |
+
str(model_path), sess_options=sess_options,
|
| 43 |
+
providers=providers or ["CPUExecutionProvider"]
|
| 44 |
)
|
| 45 |
self.threshold = threshold
|
| 46 |
self.ngram_threshold = ngram_threshold
|
|
|
|
| 50 |
z = np.load(str(ngram_model_path))
|
| 51 |
self.ngram_coef = z["coef"]
|
| 52 |
self.ngram_intercept = float(z["intercept"])
|
| 53 |
+
self.vocab_map = None
|
| 54 |
+
if vocab_map_path is not None:
|
| 55 |
+
z = np.load(str(vocab_map_path))
|
| 56 |
+
self.vocab_map = z["old_to_new"]
|
| 57 |
|
| 58 |
def _encode(self, text: str):
|
| 59 |
ids = self.tok.encode(text).ids[:MAX_LEN]
|
| 60 |
+
if self.vocab_map is not None:
|
| 61 |
+
ids = self.vocab_map[np.asarray(ids, dtype=np.int64)].tolist()
|
| 62 |
n = len(ids)
|
| 63 |
ids = ids + [0] * (MAX_LEN - n)
|
| 64 |
mask = [1] * n + [0] * (MAX_LEN - n)
|
gate_pure.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure send-gate classifier: tokenizer -> ONNX -> sigmoid -> threshold.
|
| 2 |
+
|
| 3 |
+
No regex. No canonicalisation. No word lists. No model-view routing.
|
| 4 |
+
The classifier sees the raw message plus its UTF-8 bytes for the character CNN.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import onnxruntime as ort
|
| 13 |
+
from tokenizers import Tokenizer
|
| 14 |
+
|
| 15 |
+
DEFAULT_THRESHOLD = 0.82
|
| 16 |
+
MAX_LEN = 128
|
| 17 |
+
CHAR_LEN = 256
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class Result:
|
| 22 |
+
blocked: bool
|
| 23 |
+
score: float
|
| 24 |
+
reason: str | None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class PureGate:
|
| 28 |
+
def __init__(self, model_path: str | Path = "model.onnx",
|
| 29 |
+
tokenizer_path: str | Path = "tokenizer.json",
|
| 30 |
+
threshold: float = DEFAULT_THRESHOLD,
|
| 31 |
+
providers: list[str] | None = None):
|
| 32 |
+
self.tok = Tokenizer.from_file(str(tokenizer_path))
|
| 33 |
+
self.sess = ort.InferenceSession(
|
| 34 |
+
str(model_path), providers=providers or ["CPUExecutionProvider"]
|
| 35 |
+
)
|
| 36 |
+
self.threshold = threshold
|
| 37 |
+
|
| 38 |
+
def _encode(self, text: str):
|
| 39 |
+
ids = self.tok.encode(text).ids[:MAX_LEN]
|
| 40 |
+
n = len(ids)
|
| 41 |
+
ids = ids + [0] * (MAX_LEN - n)
|
| 42 |
+
mask = [1] * n + [0] * (MAX_LEN - n)
|
| 43 |
+
raw = text.encode("utf-8", errors="ignore")[:CHAR_LEN]
|
| 44 |
+
chars = list(raw) + [0] * (CHAR_LEN - len(raw))
|
| 45 |
+
cmask = [1 if b else 0 for b in chars]
|
| 46 |
+
return (np.array([ids], dtype=np.int64), np.array([mask], dtype=np.int64),
|
| 47 |
+
np.array([chars], dtype=np.int64), np.array([cmask], dtype=np.int64))
|
| 48 |
+
|
| 49 |
+
def score(self, text: str) -> float:
|
| 50 |
+
ids, mask, chars, cmask = self._encode(text)
|
| 51 |
+
logit = self.sess.run(None, {
|
| 52 |
+
"input_ids": ids,
|
| 53 |
+
"attention_mask": mask,
|
| 54 |
+
"char_ids": chars,
|
| 55 |
+
"char_mask": cmask,
|
| 56 |
+
})[0].flatten()[0]
|
| 57 |
+
return float(1.0 / (1.0 + np.exp(-logit)))
|
| 58 |
+
|
| 59 |
+
def check(self, text: str) -> Result:
|
| 60 |
+
p = self.score(text)
|
| 61 |
+
return Result(blocked=p >= self.threshold, score=p,
|
| 62 |
+
reason="model" if p >= self.threshold else None)
|
model.onnx
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d1e0b78fcaff6f947e3593e964e4b9a4f25d99abe62558fc02319f6492255d36
|
| 3 |
+
size 418972704
|
model.pt
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:a196a6eeec9c5391c7645bf74003474d14e496dba9a9eaed8f913af0cb5c6d75
|
| 3 |
-
size 1229743482
|
|
|
|
|
|
|
|
|
|
|
|
server.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Minimal pure send-gate demo server."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
import json, os, sys, time, threading
|
| 5 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 6 |
+
HERE=os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0,HERE)
|
| 7 |
+
from gate import SendGate
|
| 8 |
+
BASE=os.environ.get("GATE_DIR", "/tmp/opencode/hf_pure_v7")
|
| 9 |
+
def _ensure(name):
|
| 10 |
+
path=os.path.join(BASE,name)
|
| 11 |
+
if not os.path.exists(path):
|
| 12 |
+
os.makedirs(BASE,exist_ok=True)
|
| 13 |
+
from huggingface_hub import hf_hub_download
|
| 14 |
+
hf_hub_download(repo_id="yamura4/send-gate-307m",filename=name,local_dir=BASE)
|
| 15 |
+
return path
|
| 16 |
+
gate=SendGate(model_path=_ensure("model.onnx"), tokenizer_path=_ensure("tokenizer.json"), ngram_model_path=_ensure("ngram_model.npz"))
|
| 17 |
+
gate.score("hallo")
|
| 18 |
+
_lock=threading.Lock()
|
| 19 |
+
class H(BaseHTTPRequestHandler):
|
| 20 |
+
def _send(self, code, body, ctype="application/json"):
|
| 21 |
+
self.send_response(code); self.send_header("Content-Type",ctype); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body)
|
| 22 |
+
def do_GET(self):
|
| 23 |
+
if self.path=="/healthz": self._send(200,b'{"ok":true}')
|
| 24 |
+
elif self.path=="/":
|
| 25 |
+
html=b"""<!doctype html><meta charset=utf-8><title>send-gate pure v7</title>
|
| 26 |
+
<body style="font-family:system-ui;max-width:760px;margin:40px auto"><h2>send-gate pure v7</h2>
|
| 27 |
+
<p>Binary safety gate. Model-only routing, no slur list.</p>
|
| 28 |
+
<textarea id=t style="width:100%;height:80px">kga ni liegen dit is een mopje ma lowkey niggerr</textarea><br>
|
| 29 |
+
<button onclick="go()">check</button><pre id=o></pre>
|
| 30 |
+
<script>async function go(){let r=await fetch('/api/check',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:document.getElementById('t').value})});document.getElementById('o').textContent=JSON.stringify(await r.json(),null,2)}</script>"""
|
| 31 |
+
self._send(200,html,"text/html; charset=utf-8")
|
| 32 |
+
else: self._send(404,b'{"error":"not found"}')
|
| 33 |
+
def do_POST(self):
|
| 34 |
+
if self.path!="/api/check": self._send(404,b'{"error":"not found"}'); return
|
| 35 |
+
try:
|
| 36 |
+
n=int(self.headers.get("Content-Length") or 0)
|
| 37 |
+
if n<=0 or n>64000: raise ValueError("bad body")
|
| 38 |
+
data=json.loads(self.rfile.read(n)); text=data.get("text")
|
| 39 |
+
if not isinstance(text,str) or not text.strip(): raise ValueError("text must be a non-empty string")
|
| 40 |
+
if len(text)>2000: raise ValueError("text too long")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
self._send(400,json.dumps({"error":str(e)}).encode()); return
|
| 43 |
+
t0=time.perf_counter(); r=gate.check(text); ms=(time.perf_counter()-t0)*1000
|
| 44 |
+
self._send(200,json.dumps({"blocked":r.blocked,"score":round(r.score,5),"reason":r.reason,"threshold":gate.threshold,"ngram_threshold":gate.ngram_threshold,"latency_ms":round(ms,2)}).encode())
|
| 45 |
+
def log_message(self,fmt,*args): sys.stderr.write("%s %s\n"%(self.log_date_time_string(),fmt%args))
|
| 46 |
+
if __name__=="__main__":
|
| 47 |
+
srv=ThreadingHTTPServer(("127.0.0.1",7860),H); srv.daemon_threads=True; print("listening 7860",flush=True); srv.serve_forever()
|