Upload folder using huggingface_hub
Browse files- README.md +77 -82
- checkpoints/best.pt +3 -0
- checkpoints/eval_results.json +0 -0
- checkpoints/export_meta.json +31 -0
- checkpoints/picotype_base.onnx +3 -0
- checkpoints/picotype_base.onnx.data +3 -0
- checkpoints/picotype_pro.onnx +3 -0
- checkpoints/picotype_pro.onnx.data +3 -0
- checkpoints/picotype_small.onnx +3 -0
- checkpoints/picotype_small.onnx.data +3 -0
- checkpoints/picotype_tiny.onnx +3 -0
- checkpoints/picotype_tiny.onnx.data +3 -0
- checkpoints/real_world_eval.json +128 -0
- model/pico_type/arch.py +330 -0
- model/pico_type/cli.py +159 -0
- model/pico_type/labels.py +207 -0
- model/pico_type/train.py +315 -0
- paper/main.tex +322 -0
- pyproject.toml +43 -0
README.md
CHANGED
|
@@ -1,126 +1,121 @@
|
|
| 1 |
-
|
| 2 |
-
license: apache-2.0
|
| 3 |
-
language:
|
| 4 |
-
- multilingual
|
| 5 |
-
tags:
|
| 6 |
-
- content-classification
|
| 7 |
-
- byte-level
|
| 8 |
-
- onnx
|
| 9 |
-
- matryoshka
|
| 10 |
-
- lightweight
|
| 11 |
-
- classifier
|
| 12 |
-
pipeline_tag: text-classification
|
| 13 |
-
library_name: pico-type
|
| 14 |
-
---
|
| 15 |
-
|
| 16 |
-
# pico-type
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
|
| 22 |
-
|
| 23 |
-
ByteEmbed → Conv1D×3 → BiAttention×2 → Pool → Matryoshka Heads
|
| 24 |
-
```
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|------|---------|-------------|
|
| 35 |
-
| **coarse** | 12 | text, code, link, image, file, config, markup, data, error, secret, archive, binary |
|
| 36 |
-
| **modality** | 8 | textual, binary_image, binary_archive, binary_executable, binary_document, binary_audio, binary_video, binary_other |
|
| 37 |
-
| **subtype** | 24 | json, yaml, toml, ini, csv, html, xml, markdown, sql, log, diff, dockerfile, etc. |
|
| 38 |
-
| **code_lang** | 62 | python, javascript, typescript, java, c, cpp, go, rust, swift, bash, sql, etc. |
|
| 39 |
-
| **text_lang** | 30 | en, es, fr, de, it, pt, ru, zh, ja, ko, ar, hi, etc. |
|
| 40 |
-
| **file_mime** | 90 | text/html, application/json, application/pdf, image/png, video/mp4, etc. |
|
| 41 |
-
| **risk** | 6 | api_key, jwt, password, email, phone, ssh_key (probabilities) |
|
| 42 |
|
| 43 |
-
##
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|------|----------|---------|
|
| 49 |
-
| coarse | 100.0% | 500 |
|
| 50 |
-
| modality | 100.0% | 500 |
|
| 51 |
-
| subtype | 93.8% | 128 |
|
| 52 |
-
| code_lang | 41.7% | 48 |
|
| 53 |
-
| text_lang | 94.3% | 35 |
|
| 54 |
-
| file_mime | 100.0% | 131 |
|
| 55 |
-
| risk (mAP) | 100.0% | — |
|
| 56 |
|
| 57 |
-
|
| 58 |
-
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
|
| 63 |
-
##
|
| 64 |
|
| 65 |
### CLI
|
| 66 |
```bash
|
| 67 |
-
|
| 68 |
-
echo "def hello(): pass" | picotype --pretty
|
| 69 |
|
| 70 |
-
|
| 71 |
picotype --file document.txt
|
| 72 |
-
|
| 73 |
-
# Clipboard (macOS)
|
| 74 |
picotype --clip
|
| 75 |
```
|
| 76 |
|
| 77 |
### Python
|
| 78 |
```python
|
| 79 |
-
from model.pico_type.
|
| 80 |
|
| 81 |
-
|
| 82 |
-
result =
|
| 83 |
-
|
| 84 |
```
|
| 85 |
|
| 86 |
-
### MCP Server
|
| 87 |
```bash
|
| 88 |
PICOTYPE_MODEL_DIR=./checkpoints python -m model.pico_type.mcp_server
|
| 89 |
```
|
| 90 |
|
| 91 |
-
##
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
| tiny | 16 | 1.43M | 203 KB |
|
| 96 |
| small | 64 | 1.45M | 203 KB |
|
| 97 |
| base | 192 | 1.48M | 206 KB |
|
| 98 |
| pro | 576 | 1.56M | 202 KB |
|
| 99 |
|
| 100 |
-
All tiers share the same trunk; only the final linear
|
| 101 |
|
| 102 |
-
##
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
##
|
| 111 |
-
```python
|
| 112 |
-
import onnxruntime
|
| 113 |
-
session = ort.InferenceSession("picotype_base.onnx")
|
| 114 |
-
```
|
| 115 |
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
|
| 119 |
|
| 120 |
-
-
|
| 121 |
-
-
|
| 122 |
-
-
|
| 123 |
|
| 124 |
-
## License
|
| 125 |
|
| 126 |
Apache 2.0
|
|
|
|
| 1 |
+
<div align="center">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
# pico-type 🔍
|
| 4 |
|
| 5 |
+
**A tiny byte-level multi-head content classifier** — ~1.5M params, ~200KB ONNX, <12ms inference.
|
| 6 |
|
| 7 |
+
Classifies any content into **7 categories** from raw bytes: coarse type, modality, subtype, code language, text language, file MIME, and risk flags.
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
[](LICENSE)
|
| 10 |
+
[]()
|
| 11 |
+
[](checkpoints)
|
| 12 |
+
[](https://huggingface.co/spaces/eulogik/pico-type)
|
| 13 |
+
[](https://github.com/eulogik/pico-type)
|
| 14 |
|
| 15 |
+
</div>
|
| 16 |
|
| 17 |
+
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
## ✨ Features
|
| 20 |
|
| 21 |
+
- **No tokenizer** — operates directly on raw UTF-8 bytes (supports all languages)
|
| 22 |
+
- **7 heads, one forward pass** — coarse type, modality, subtype, code lang, text lang, file MIME, risk
|
| 23 |
+
- **4 Matryoshka tiers** — tiny (16d) → small (64d) → base (192d) → pro (576d)
|
| 24 |
+
- **~200KB ONNX** — deploy on edge devices, serverless, browser (WebAssembly)
|
| 25 |
+
- **<12ms inference** on CPU via ONNX Runtime
|
| 26 |
+
- **CLI, Gradio Space, MCP server** — ready to use
|
| 27 |
|
| 28 |
+
## 📊 Performance
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
| Head | Classes | Accuracy |
|
| 31 |
+
|------|---------|----------|
|
| 32 |
+
| coarse | 12 | **100%** |
|
| 33 |
+
| modality | 8 | **100%** |
|
| 34 |
+
| subtype | 24 | **93.8%** |
|
| 35 |
+
| code_lang | 62 | **41.7%** |
|
| 36 |
+
| text_lang | 30 | **94.3%** |
|
| 37 |
+
| file_mime | 90 | **100%** |
|
| 38 |
+
| risk (mAP) | 6 | **100%** |
|
| 39 |
|
| 40 |
+
_500 evaluation samples, 1700 training steps, base tier, ~13ms inference._
|
| 41 |
|
| 42 |
+
## 🚀 Quick Start
|
| 43 |
|
| 44 |
### CLI
|
| 45 |
```bash
|
| 46 |
+
pip install picotype
|
|
|
|
| 47 |
|
| 48 |
+
echo "def hello():\n return 42" | picotype --pretty
|
| 49 |
picotype --file document.txt
|
|
|
|
|
|
|
| 50 |
picotype --clip
|
| 51 |
```
|
| 52 |
|
| 53 |
### Python
|
| 54 |
```python
|
| 55 |
+
from model.pico_type.cli import load_onnx_model, run_onnx
|
| 56 |
|
| 57 |
+
session = load_onnx_model("base", "checkpoints")
|
| 58 |
+
result = run_onnx(session, "def hello(): pass")
|
| 59 |
+
print(result)
|
| 60 |
```
|
| 61 |
|
| 62 |
+
### MCP Server (Claude/Cursor)
|
| 63 |
```bash
|
| 64 |
PICOTYPE_MODEL_DIR=./checkpoints python -m model.pico_type.mcp_server
|
| 65 |
```
|
| 66 |
|
| 67 |
+
## 🏗 Architecture
|
| 68 |
|
| 69 |
+
```
|
| 70 |
+
Bytes → ByteEmbed(256→96d) → 3×Conv1D(k=3,5,7) → 2×BiAttention(RoPE) → Pool(mean‖max‖std) → 7×Matryoshka Heads
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
- **ByteEmbed**: lookup-free byte embedding (256 vocab, 96 dim)
|
| 74 |
+
- **Conv1D**: 3 parallel kernels (width 3, 5, 7) with residual + layer norm
|
| 75 |
+
- **BiAttention**: bidirectional self-attention with RoPE, 4 heads, 96 dim
|
| 76 |
+
- **Pool**: mean + max + std concatenation
|
| 77 |
+
- **Matryoshka Heads**: 4 slices of the pooled vector (16/64/192/576 dim) → 7 linear classifiers
|
| 78 |
+
|
| 79 |
+
Total parameters: **1.43M** (tiny) / **1.45M** (small) / **1.48M** (base) / **1.56M** (pro)
|
| 80 |
+
|
| 81 |
+
## 🔧 Model Tiers
|
| 82 |
+
|
| 83 |
+
| Tier | Dim | Params | ONNX Size |
|
| 84 |
+
|------|-----|--------|-----------|
|
| 85 |
| tiny | 16 | 1.43M | 203 KB |
|
| 86 |
| small | 64 | 1.45M | 203 KB |
|
| 87 |
| base | 192 | 1.48M | 206 KB |
|
| 88 |
| pro | 576 | 1.56M | 202 KB |
|
| 89 |
|
| 90 |
+
All tiers share the same trunk; only the final linear layers differ.
|
| 91 |
|
| 92 |
+
## 🧪 Classification Heads
|
| 93 |
|
| 94 |
+
| Head | Classes | Examples |
|
| 95 |
+
|------|---------|----------|
|
| 96 |
+
| **coarse** | 12 | text, code, link, image, file, config, markup, data, error, secret, archive, binary |
|
| 97 |
+
| **modality** | 8 | textual, binary_image, binary_archive, binary_executable, etc. |
|
| 98 |
+
| **subtype** | 24 | json, yaml, toml, csv, html, markdown, sql, log, dockerfile, etc. |
|
| 99 |
+
| **code_lang** | 62 | python, javascript, typescript, java, c, cpp, go, rust, etc. |
|
| 100 |
+
| **text_lang** | 30 | en, es, fr, de, it, pt, ru, zh, ja, ko, ar, hi, etc. |
|
| 101 |
+
| **file_mime** | 90 | text/html, application/json, application/pdf, image/png, video/mp4, etc. |
|
| 102 |
+
| **risk** | 6 | api_key, jwt, password, email, phone, ssh_key |
|
| 103 |
|
| 104 |
+
## 🌐 Deployment
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
| Platform | Location |
|
| 107 |
+
|----------|----------|
|
| 108 |
+
| HuggingFace Space | [eulogik/pico-type](https://huggingface.co/spaces/eulogik/pico-type) |
|
| 109 |
+
| HuggingFace Model | [eulogik/pico-type](https://huggingface.co/eulogik/pico-type) |
|
| 110 |
+
| GitHub | [eulogik/pico-type](https://github.com/eulogik/pico-type) |
|
| 111 |
+
| PyPI | `pip install picotype` |
|
| 112 |
|
| 113 |
+
## 📚 Documentation
|
| 114 |
|
| 115 |
+
- [Model Card](MODEL_CARD.md) — detailed architecture, training, and evaluation
|
| 116 |
+
- [Architecture Plan](docs/PLAN.md) — full design document
|
| 117 |
+
- [Walkthrough](walkthrough.md) — development log
|
| 118 |
|
| 119 |
+
## 📄 License
|
| 120 |
|
| 121 |
Apache 2.0
|
checkpoints/best.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4ab8e567bac04b59662277644ad039773f0420e463a0c8aeb408d93d5a878e90
|
| 3 |
+
size 6538491
|
checkpoints/eval_results.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
checkpoints/export_meta.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tiers": [
|
| 3 |
+
"tiny",
|
| 4 |
+
"small",
|
| 5 |
+
"base",
|
| 6 |
+
"pro"
|
| 7 |
+
],
|
| 8 |
+
"tier_dims": {
|
| 9 |
+
"tiny": 16,
|
| 10 |
+
"small": 64,
|
| 11 |
+
"base": 192,
|
| 12 |
+
"pro": 576
|
| 13 |
+
},
|
| 14 |
+
"params": {
|
| 15 |
+
"tiny": 1434344,
|
| 16 |
+
"small": 1445480,
|
| 17 |
+
"base": 1475176,
|
| 18 |
+
"pro": 1564264
|
| 19 |
+
},
|
| 20 |
+
"head_classes": {
|
| 21 |
+
"coarse": 12,
|
| 22 |
+
"modality": 8,
|
| 23 |
+
"subtype": 24,
|
| 24 |
+
"code_lang": 62,
|
| 25 |
+
"text_lang": 30,
|
| 26 |
+
"file_mime": 90,
|
| 27 |
+
"risk": 6
|
| 28 |
+
},
|
| 29 |
+
"max_bytes": 1024,
|
| 30 |
+
"opset": 18
|
| 31 |
+
}
|
checkpoints/picotype_base.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:863b2d18e4854295bdc90e632a8dfa30c7f8e55773a1fcc24f7e04a8371d696b
|
| 3 |
+
size 213991
|
checkpoints/picotype_base.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0ef7884a5ec6cde5e2d1b1eb69317021b9f93568b8a77d309f920df2dc884214
|
| 3 |
+
size 9045864
|
checkpoints/picotype_pro.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5658349f9ebce903c5e27bf38cf601c6ed50e62340e79aae244f8e9cab16092d
|
| 3 |
+
size 210600
|
checkpoints/picotype_pro.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:172826c638c917ba9a74cec855310a34a09db071b908d9c30a17ae4332b4c7b4
|
| 3 |
+
size 9401856
|
checkpoints/picotype_small.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:228ef5ab000bfc53973966f6269aa831686cda0976839b3887a463027d00076e
|
| 3 |
+
size 211530
|
checkpoints/picotype_small.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:82bab798e50a487eb4c26375e88480b9499ed241122a1e09cef88a680227b15d
|
| 3 |
+
size 8926720
|
checkpoints/picotype_tiny.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eedadd49dce51de82569a3802cc31c56dc225505ad0e5a83324aa1972da27585
|
| 3 |
+
size 211480
|
checkpoints/picotype_tiny.onnx.data
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8de9f0a04871decfa9954709db9ac019bd66f614fa7718282dbbcc7ef23db725
|
| 3 |
+
size 8882176
|
checkpoints/real_world_eval.json
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"text": "def factorial(n): return 1 if n <= 1 else n * factorial(n-1)",
|
| 4 |
+
"expected": "code",
|
| 5 |
+
"predicted": "code",
|
| 6 |
+
"correct": true,
|
| 7 |
+
"code_lang": "vb",
|
| 8 |
+
"text_lang": null,
|
| 9 |
+
"confidence": 0.621
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"text": "function greet(name) { return \"Hello, \" + name; }",
|
| 13 |
+
"expected": "code",
|
| 14 |
+
"predicted": "code",
|
| 15 |
+
"correct": true,
|
| 16 |
+
"code_lang": "undetected",
|
| 17 |
+
"text_lang": null,
|
| 18 |
+
"confidence": 0.918
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"text": "SELECT * FROM users WHERE id = 1;",
|
| 22 |
+
"expected": "data",
|
| 23 |
+
"predicted": "text",
|
| 24 |
+
"correct": false,
|
| 25 |
+
"code_lang": null,
|
| 26 |
+
"text_lang": "zh",
|
| 27 |
+
"confidence": 0.731
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"text": "<!DOCTYPE html><html><body><p>Hello</p></body></html>",
|
| 31 |
+
"expected": "markup",
|
| 32 |
+
"predicted": "code",
|
| 33 |
+
"correct": false,
|
| 34 |
+
"code_lang": "undetected",
|
| 35 |
+
"text_lang": null,
|
| 36 |
+
"confidence": 0.75
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"text": "{\"name\": \"John\", \"age\": 30, \"city\": \"New York\"}",
|
| 40 |
+
"expected": "config",
|
| 41 |
+
"predicted": "config",
|
| 42 |
+
"correct": true,
|
| 43 |
+
"code_lang": null,
|
| 44 |
+
"text_lang": null,
|
| 45 |
+
"confidence": 1.0
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"text": "The quick brown fox jumps over the lazy dog",
|
| 49 |
+
"expected": "text",
|
| 50 |
+
"predicted": "text",
|
| 51 |
+
"correct": true,
|
| 52 |
+
"code_lang": null,
|
| 53 |
+
"text_lang": "en",
|
| 54 |
+
"confidence": 0.963
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"text": "https://github.com/eulogik/pico-type",
|
| 58 |
+
"expected": "link",
|
| 59 |
+
"predicted": "link",
|
| 60 |
+
"correct": true,
|
| 61 |
+
"code_lang": null,
|
| 62 |
+
"text_lang": null,
|
| 63 |
+
"confidence": 0.968
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"text": "Traceback (most recent call last):\n File \"test.py\", line 5,",
|
| 67 |
+
"expected": "error",
|
| 68 |
+
"predicted": "text",
|
| 69 |
+
"correct": false,
|
| 70 |
+
"code_lang": null,
|
| 71 |
+
"text_lang": "es",
|
| 72 |
+
"confidence": 0.761
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"text": "password = \"supersecret123!\"",
|
| 76 |
+
"expected": "secret",
|
| 77 |
+
"predicted": "secret",
|
| 78 |
+
"correct": true,
|
| 79 |
+
"code_lang": null,
|
| 80 |
+
"text_lang": null,
|
| 81 |
+
"confidence": 1.0
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"text": "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE",
|
| 85 |
+
"expected": "secret",
|
| 86 |
+
"predicted": "secret",
|
| 87 |
+
"correct": true,
|
| 88 |
+
"code_lang": null,
|
| 89 |
+
"text_lang": null,
|
| 90 |
+
"confidence": 1.0
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"text": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDA== user@host",
|
| 94 |
+
"expected": "secret",
|
| 95 |
+
"predicted": "secret",
|
| 96 |
+
"correct": true,
|
| 97 |
+
"code_lang": null,
|
| 98 |
+
"text_lang": null,
|
| 99 |
+
"confidence": 1.0
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"text": "PK\u0003\u0004\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
|
| 103 |
+
"expected": "archive",
|
| 104 |
+
"predicted": "archive",
|
| 105 |
+
"correct": true,
|
| 106 |
+
"code_lang": null,
|
| 107 |
+
"text_lang": null,
|
| 108 |
+
"confidence": 1.0
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"text": "\u0089PNG\r\n\u001a\n",
|
| 112 |
+
"expected": "image",
|
| 113 |
+
"predicted": "config",
|
| 114 |
+
"correct": false,
|
| 115 |
+
"code_lang": null,
|
| 116 |
+
"text_lang": null,
|
| 117 |
+
"confidence": 0.843
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"text": "#!/bin/bash\necho \"Hello, World!\"",
|
| 121 |
+
"expected": "data",
|
| 122 |
+
"predicted": "data",
|
| 123 |
+
"correct": true,
|
| 124 |
+
"code_lang": null,
|
| 125 |
+
"text_lang": null,
|
| 126 |
+
"confidence": 0.512
|
| 127 |
+
}
|
| 128 |
+
]
|
model/pico_type/arch.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pico-type: a tiny byte-level multi-head content classifier."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Dict, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
TIERS: Dict[str, int] = {
|
| 14 |
+
"tiny": 16,
|
| 15 |
+
"small": 64,
|
| 16 |
+
"base": 192,
|
| 17 |
+
"pro": 576,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class PicoTypeConfig:
|
| 23 |
+
max_bytes: int = 1024
|
| 24 |
+
embed_dim: int = 96
|
| 25 |
+
trunk_dim: int = 192
|
| 26 |
+
num_heads: int = 4
|
| 27 |
+
num_attn_layers: int = 2
|
| 28 |
+
rope_theta: float = 500_000.0
|
| 29 |
+
conv_kernels: Tuple[int, ...] = (3, 5, 7)
|
| 30 |
+
tiers: Dict[str, int] = field(default_factory=lambda: dict(TIERS))
|
| 31 |
+
num_coarse: int = 12
|
| 32 |
+
num_modality: int = 8
|
| 33 |
+
num_subtype: int = 24
|
| 34 |
+
num_code_lang: int = 62
|
| 35 |
+
num_text_lang: int = 30
|
| 36 |
+
num_file_mime: int = 90
|
| 37 |
+
num_risk: int = 6
|
| 38 |
+
dropout: float = 0.1
|
| 39 |
+
undetected_threshold: float = 0.4
|
| 40 |
+
risk_threshold: float = 0.5
|
| 41 |
+
pad_byte: int = 0
|
| 42 |
+
|
| 43 |
+
def tier_dim(self, tier: str) -> int:
|
| 44 |
+
if tier not in self.tiers:
|
| 45 |
+
raise KeyError(f"unknown tier '{tier}', available: {list(self.tiers)}")
|
| 46 |
+
return self.tiers[tier]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ByteEmbed(nn.Module):
|
| 50 |
+
def __init__(self, num_bytes: int = 256, dim: int = 96):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.embed = nn.Embedding(num_bytes, dim)
|
| 53 |
+
nn.init.normal_(self.embed.weight, std=0.02)
|
| 54 |
+
|
| 55 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 56 |
+
return self.embed(x)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ConvBlock(nn.Module):
|
| 60 |
+
def __init__(self, in_dim: int, out_dim: int, kernel_size: int, dropout: float = 0.1):
|
| 61 |
+
super().__init__()
|
| 62 |
+
padding = kernel_size // 2
|
| 63 |
+
self.conv = nn.Conv1d(in_dim, out_dim, kernel_size, padding=padding, bias=False)
|
| 64 |
+
self.norm = nn.LayerNorm(out_dim)
|
| 65 |
+
self.act = nn.GELU()
|
| 66 |
+
self.drop = nn.Dropout(dropout)
|
| 67 |
+
if in_dim != out_dim:
|
| 68 |
+
self.proj = nn.Conv1d(in_dim, out_dim, kernel_size=1, bias=False)
|
| 69 |
+
else:
|
| 70 |
+
self.proj = nn.Identity()
|
| 71 |
+
|
| 72 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 73 |
+
residual = self.proj(x)
|
| 74 |
+
h = self.conv(x)
|
| 75 |
+
h = h.transpose(1, 2)
|
| 76 |
+
h = self.norm(h)
|
| 77 |
+
h = self.act(h)
|
| 78 |
+
h = self.drop(h)
|
| 79 |
+
h = h.transpose(1, 2)
|
| 80 |
+
return h + residual
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
| 84 |
+
d = x.size(-1)
|
| 85 |
+
x1, x2 = x[..., : d // 2], x[..., d // 2 :]
|
| 86 |
+
rot = torch.cat([-x2, x1], dim=-1)
|
| 87 |
+
return x * cos + rot * sin
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class RotaryPosEmb(nn.Module):
|
| 91 |
+
def __init__(self, dim: int, theta: float = 500_000.0, max_seq: int = 4096):
|
| 92 |
+
super().__init__()
|
| 93 |
+
if dim % 2 != 0:
|
| 94 |
+
raise ValueError("rope dim must be even")
|
| 95 |
+
inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim))
|
| 96 |
+
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
| 97 |
+
self._max_seq = max_seq
|
| 98 |
+
self._cached_seq = 0
|
| 99 |
+
self._cached_dim = dim
|
| 100 |
+
self._build_cache(max_seq, dim)
|
| 101 |
+
|
| 102 |
+
def _build_cache(self, max_seq: int, dim: int) -> None:
|
| 103 |
+
t = torch.arange(max_seq, dtype=self.inv_freq.dtype, device=self.inv_freq.device)
|
| 104 |
+
freqs = torch.einsum("i,j->ij", t, self.inv_freq)
|
| 105 |
+
emb = torch.cat([freqs, freqs], dim=-1)
|
| 106 |
+
self.register_buffer("cos_cached", emb.cos()[None, None, :, :], persistent=False)
|
| 107 |
+
self.register_buffer("sin_cached", emb.sin()[None, None, :, :], persistent=False)
|
| 108 |
+
self._cached_seq = max_seq
|
| 109 |
+
self._cached_dim = dim
|
| 110 |
+
|
| 111 |
+
def forward(self, q: torch.Tensor, k: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 112 |
+
seq = q.size(-2)
|
| 113 |
+
if seq > self._cached_seq:
|
| 114 |
+
self._build_cache(max(seq, self._cached_seq * 2), self._cached_dim)
|
| 115 |
+
cos = self.cos_cached[:, :, :seq, :]
|
| 116 |
+
sin = self.sin_cached[:, :, :seq, :]
|
| 117 |
+
return _apply_rope(q, cos, sin), _apply_rope(k, cos, sin)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class AttnBlock(nn.Module):
|
| 121 |
+
def __init__(
|
| 122 |
+
self,
|
| 123 |
+
dim: int,
|
| 124 |
+
num_heads: int,
|
| 125 |
+
dropout: float = 0.1,
|
| 126 |
+
rope_theta: float = 500_000.0,
|
| 127 |
+
):
|
| 128 |
+
super().__init__()
|
| 129 |
+
if dim % num_heads != 0:
|
| 130 |
+
raise ValueError(f"dim {dim} not divisible by num_heads {num_heads}")
|
| 131 |
+
self.num_heads = num_heads
|
| 132 |
+
self.head_dim = dim // num_heads
|
| 133 |
+
self.qkv = nn.Linear(dim, 3 * dim, bias=False)
|
| 134 |
+
self.out_proj = nn.Linear(dim, dim)
|
| 135 |
+
self.norm1 = nn.LayerNorm(dim)
|
| 136 |
+
self.norm2 = nn.LayerNorm(dim)
|
| 137 |
+
self.mlp = nn.Sequential(
|
| 138 |
+
nn.Linear(dim, 4 * dim),
|
| 139 |
+
nn.GELU(),
|
| 140 |
+
nn.Dropout(dropout),
|
| 141 |
+
nn.Linear(4 * dim, dim),
|
| 142 |
+
)
|
| 143 |
+
self.attn_drop = dropout
|
| 144 |
+
self.resid_drop = nn.Dropout(dropout)
|
| 145 |
+
self.rope = RotaryPosEmb(self.head_dim, rope_theta)
|
| 146 |
+
|
| 147 |
+
def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> torch.Tensor:
|
| 148 |
+
B, L, D = x.shape
|
| 149 |
+
h = self.norm1(x)
|
| 150 |
+
qkv = self.qkv(h).reshape(B, L, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4)
|
| 151 |
+
q, k, v = qkv[0], qkv[1], qkv[2]
|
| 152 |
+
q, k = self.rope(q, k)
|
| 153 |
+
attn_mask_float = None
|
| 154 |
+
if mask is not None:
|
| 155 |
+
attn_mask_float = mask[:, None, None, :].to(dtype=q.dtype)
|
| 156 |
+
attn_mask_float = torch.where(attn_mask_float.to(torch.bool), 0.0, float("-inf"))
|
| 157 |
+
out = F.scaled_dot_product_attention(
|
| 158 |
+
q, k, v,
|
| 159 |
+
attn_mask=attn_mask_float,
|
| 160 |
+
dropout_p=self.attn_drop if self.training else 0.0,
|
| 161 |
+
is_causal=False,
|
| 162 |
+
)
|
| 163 |
+
out = out.transpose(1, 2).reshape(B, L, D)
|
| 164 |
+
x = x + self.resid_drop(self.out_proj(out))
|
| 165 |
+
x = x + self.mlp(self.norm2(x))
|
| 166 |
+
return x
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class Pool(nn.Module):
|
| 170 |
+
def forward(
|
| 171 |
+
self,
|
| 172 |
+
x: torch.Tensor,
|
| 173 |
+
mask: Optional[torch.Tensor] = None,
|
| 174 |
+
) -> torch.Tensor:
|
| 175 |
+
if mask is not None:
|
| 176 |
+
m = mask.unsqueeze(-1).to(x.dtype)
|
| 177 |
+
denom = m.sum(dim=1).clamp(min=1.0)
|
| 178 |
+
mean = (x * m).sum(dim=1) / denom
|
| 179 |
+
x_for_max = x.masked_fill(m == 0, float("-inf"))
|
| 180 |
+
max_ = x_for_max.max(dim=1).values
|
| 181 |
+
x_for_std = x * m
|
| 182 |
+
mean_for_std = x_for_std.sum(dim=1, keepdim=True) / denom.unsqueeze(1)
|
| 183 |
+
sq = ((x_for_std - mean_for_std * m) ** 2) * m
|
| 184 |
+
std = sq.sum(dim=1) / denom
|
| 185 |
+
std = std.sqrt().clamp(min=0.0)
|
| 186 |
+
else:
|
| 187 |
+
mean = x.mean(dim=1)
|
| 188 |
+
max_ = x.max(dim=1).values
|
| 189 |
+
std = x.std(dim=1, unbiased=False)
|
| 190 |
+
return torch.cat([mean, max_, std], dim=-1)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class MatryoshkaHead(nn.Module):
|
| 194 |
+
def __init__(
|
| 195 |
+
self,
|
| 196 |
+
in_dim: int,
|
| 197 |
+
tier_dims: Dict[str, int],
|
| 198 |
+
out_dim: int,
|
| 199 |
+
):
|
| 200 |
+
super().__init__()
|
| 201 |
+
if in_dim != max(tier_dims.values()):
|
| 202 |
+
raise ValueError(
|
| 203 |
+
f"trunk dim {in_dim} must equal max tier dim {max(tier_dims.values())}"
|
| 204 |
+
)
|
| 205 |
+
self.tier_dims = dict(tier_dims)
|
| 206 |
+
self.linears = nn.ModuleDict({
|
| 207 |
+
name: nn.Linear(d, out_dim) for name, d in tier_dims.items()
|
| 208 |
+
})
|
| 209 |
+
for linear in self.linears.values():
|
| 210 |
+
nn.init.zeros_(linear.bias)
|
| 211 |
+
nn.init.normal_(linear.weight, std=0.02)
|
| 212 |
+
|
| 213 |
+
def forward(self, x: torch.Tensor, tier: str) -> torch.Tensor:
|
| 214 |
+
if tier not in self.linears:
|
| 215 |
+
raise KeyError(f"unknown tier '{tier}', available: {list(self.linears)}")
|
| 216 |
+
d = self.tier_dims[tier]
|
| 217 |
+
sliced = x[..., :d]
|
| 218 |
+
return self.linears[tier](sliced)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class PicoType(nn.Module):
|
| 222 |
+
def __init__(self, config: Optional[PicoTypeConfig] = None):
|
| 223 |
+
super().__init__()
|
| 224 |
+
self.config = config or PicoTypeConfig()
|
| 225 |
+
cfg = self.config
|
| 226 |
+
|
| 227 |
+
self.embed = ByteEmbed(256, cfg.embed_dim)
|
| 228 |
+
|
| 229 |
+
in_dim = cfg.embed_dim
|
| 230 |
+
self.conv_blocks = nn.ModuleList()
|
| 231 |
+
for k in cfg.conv_kernels:
|
| 232 |
+
self.conv_blocks.append(
|
| 233 |
+
ConvBlock(in_dim, cfg.trunk_dim, k, cfg.dropout)
|
| 234 |
+
)
|
| 235 |
+
in_dim = cfg.trunk_dim
|
| 236 |
+
|
| 237 |
+
self.attn_blocks = nn.ModuleList([
|
| 238 |
+
AttnBlock(cfg.trunk_dim, cfg.num_heads, cfg.dropout, cfg.rope_theta)
|
| 239 |
+
for _ in range(cfg.num_attn_layers)
|
| 240 |
+
])
|
| 241 |
+
|
| 242 |
+
self.pool = Pool()
|
| 243 |
+
self.trunk_dim = 3 * cfg.trunk_dim
|
| 244 |
+
|
| 245 |
+
self.heads = nn.ModuleDict({
|
| 246 |
+
"coarse": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_coarse),
|
| 247 |
+
"modality": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_modality),
|
| 248 |
+
"subtype": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_subtype),
|
| 249 |
+
"code_lang": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_code_lang),
|
| 250 |
+
"text_lang": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_text_lang),
|
| 251 |
+
"file_mime": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_file_mime),
|
| 252 |
+
"risk": MatryoshkaHead(self.trunk_dim, cfg.tiers, cfg.num_risk),
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
def forward(
|
| 256 |
+
self,
|
| 257 |
+
bytes_input: torch.Tensor,
|
| 258 |
+
mask: Optional[torch.Tensor] = None,
|
| 259 |
+
tier: str = "base",
|
| 260 |
+
) -> Dict[str, torch.Tensor]:
|
| 261 |
+
if bytes_input.dtype != torch.long:
|
| 262 |
+
bytes_input = bytes_input.long()
|
| 263 |
+
if bytes_input.size(1) > self.config.max_bytes:
|
| 264 |
+
bytes_input = bytes_input[:, : self.config.max_bytes]
|
| 265 |
+
if mask is not None:
|
| 266 |
+
mask = mask[:, : self.config.max_bytes]
|
| 267 |
+
|
| 268 |
+
if mask is None:
|
| 269 |
+
mask = torch.ones_like(bytes_input, dtype=torch.bool)
|
| 270 |
+
else:
|
| 271 |
+
mask = mask.to(device=bytes_input.device, dtype=torch.bool)
|
| 272 |
+
empty_rows = mask.sum(dim=1) == 0
|
| 273 |
+
if empty_rows.any():
|
| 274 |
+
mask = mask.clone()
|
| 275 |
+
mask[empty_rows, 0] = True
|
| 276 |
+
|
| 277 |
+
x = self.embed(bytes_input)
|
| 278 |
+
x = x.transpose(1, 2)
|
| 279 |
+
for block in self.conv_blocks:
|
| 280 |
+
x = block(x)
|
| 281 |
+
x = x.transpose(1, 2)
|
| 282 |
+
for block in self.attn_blocks:
|
| 283 |
+
x = block(x, mask)
|
| 284 |
+
pooled = self.pool(x, mask)
|
| 285 |
+
return {name: head(pooled, tier) for name, head in self.heads.items()}
|
| 286 |
+
|
| 287 |
+
def parameter_count(self, tier: Optional[str] = None) -> int:
|
| 288 |
+
if tier is None:
|
| 289 |
+
return sum(p.numel() for p in self.parameters())
|
| 290 |
+
kept = 0
|
| 291 |
+
for name, p in self.named_parameters():
|
| 292 |
+
if ".linears." in name:
|
| 293 |
+
if f".linears.{tier}." in name:
|
| 294 |
+
kept += p.numel()
|
| 295 |
+
else:
|
| 296 |
+
kept += p.numel()
|
| 297 |
+
return kept
|
| 298 |
+
|
| 299 |
+
def tier_sizes(self) -> Dict[str, int]:
|
| 300 |
+
return {tier: self.parameter_count(tier) for tier in self.config.tiers}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def encode_bytes(data: bytes, max_len: int = 1024, pad: int = 0) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 304 |
+
raw = list(data[:max_len])
|
| 305 |
+
seq = raw + [pad] * (max_len - len(raw))
|
| 306 |
+
mask = [1] * len(raw) + [0] * (max_len - len(raw))
|
| 307 |
+
return torch.tensor([seq], dtype=torch.long), torch.tensor([mask], dtype=torch.long)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def smoke_test() -> Dict[str, int]:
|
| 311 |
+
cfg = PicoTypeConfig(max_bytes=128)
|
| 312 |
+
model = PicoType(cfg).eval()
|
| 313 |
+
sample = (
|
| 314 |
+
b"def hello():\n print('hi')\n\nif __name__ == '__main__':\n hello()\n"
|
| 315 |
+
)
|
| 316 |
+
x, mask = encode_bytes(sample, max_len=cfg.max_bytes, pad=cfg.pad_byte)
|
| 317 |
+
with torch.no_grad():
|
| 318 |
+
logits = model(x, mask, tier="base")
|
| 319 |
+
sizes = {tier: model.parameter_count(tier) for tier in cfg.tiers}
|
| 320 |
+
return {
|
| 321 |
+
"logits_keys": len(logits),
|
| 322 |
+
"tiers": len(cfg.tiers),
|
| 323 |
+
**sizes,
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
if __name__ == "__main__":
|
| 328 |
+
info = smoke_test()
|
| 329 |
+
for k, v in info.items():
|
| 330 |
+
print(f"{k}: {v}")
|
model/pico_type/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pico-type CLI: classify content from stdin/file/clipboard."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from .labels import ALL_HEADS, COARSE_LABELS, MODALITY_LABELS, SUBTYPE_LABELS, CODE_LANG_LABELS, TEXT_LANG_LABELS, FILE_MIME_LABELS, RISK_LABELS
|
| 13 |
+
|
| 14 |
+
LABEL_TABLES = {
|
| 15 |
+
"coarse": COARSE_LABELS,
|
| 16 |
+
"modality": MODALITY_LABELS,
|
| 17 |
+
"subtype": SUBTYPE_LABELS,
|
| 18 |
+
"code_lang": CODE_LANG_LABELS,
|
| 19 |
+
"text_lang": TEXT_LANG_LABELS,
|
| 20 |
+
"file_mime": FILE_MIME_LABELS,
|
| 21 |
+
"risk": RISK_LABELS,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_onnx_model(tier: str = "base", model_dir: str = "checkpoints"):
|
| 26 |
+
import onnxruntime as ort
|
| 27 |
+
path = os.path.join(model_dir, f"picotype_{tier}.onnx")
|
| 28 |
+
if not os.path.exists(path):
|
| 29 |
+
raise FileNotFoundError(f"ONNX model not found: {path}")
|
| 30 |
+
session = ort.InferenceSession(path)
|
| 31 |
+
return session
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_torch_model(tier: str = "base", checkpoint: str = ""):
|
| 35 |
+
import torch
|
| 36 |
+
from .arch import PicoType, PicoTypeConfig
|
| 37 |
+
cfg = PicoTypeConfig(max_bytes=1024)
|
| 38 |
+
model = PicoType(cfg)
|
| 39 |
+
ckpt = torch.load(checkpoint, map_location="cpu")
|
| 40 |
+
model.load_state_dict(ckpt.get("model_state_dict", ckpt))
|
| 41 |
+
model.eval()
|
| 42 |
+
return model, tier
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def run_onnx(session, text: str, max_bytes: int = 1024) -> dict:
|
| 46 |
+
text_bytes = text.encode("utf-8")[:max_bytes]
|
| 47 |
+
ids = np.frombuffer(text_bytes, dtype=np.uint8).astype(np.int64)
|
| 48 |
+
seq_len = len(ids)
|
| 49 |
+
if seq_len > max_bytes:
|
| 50 |
+
ids = ids[:max_bytes]
|
| 51 |
+
seq_len = max_bytes
|
| 52 |
+
padded = np.zeros(max_bytes, dtype=np.int64)
|
| 53 |
+
padded[:seq_len] = ids
|
| 54 |
+
mask = np.zeros(max_bytes, dtype=np.bool_)
|
| 55 |
+
mask[:seq_len] = True
|
| 56 |
+
|
| 57 |
+
feed = {
|
| 58 |
+
"input_ids": padded[None, :],
|
| 59 |
+
"attention_mask": mask[None, :],
|
| 60 |
+
}
|
| 61 |
+
outs = session.run(None, feed)
|
| 62 |
+
result = {}
|
| 63 |
+
for name, logits in zip(ALL_HEADS, outs):
|
| 64 |
+
probs = _softmax(logits[0])
|
| 65 |
+
if name == "risk":
|
| 66 |
+
result[name] = {LABEL_TABLES[name][i]: float(probs[i]) for i in range(len(probs))}
|
| 67 |
+
else:
|
| 68 |
+
idx = int(np.argmax(probs))
|
| 69 |
+
label = LABEL_TABLES[name][idx]
|
| 70 |
+
result[name] = {"label": label, "confidence": float(probs[idx]), "index": idx}
|
| 71 |
+
return result
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _softmax(x):
|
| 75 |
+
e = np.exp(x - np.max(x))
|
| 76 |
+
return e / e.sum()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def run_torch(model, tier: str, text: str, max_bytes: int = 1024) -> dict:
|
| 80 |
+
import torch
|
| 81 |
+
model = model[0] if isinstance(model, tuple) else model
|
| 82 |
+
text_bytes = text.encode("utf-8")[:max_bytes]
|
| 83 |
+
ids = torch.tensor([list(text_bytes)], dtype=torch.long)
|
| 84 |
+
mask = torch.ones(1, ids.shape[1], dtype=torch.bool)
|
| 85 |
+
with torch.no_grad():
|
| 86 |
+
logits_dict = model(ids, mask)
|
| 87 |
+
out = {}
|
| 88 |
+
for head in ALL_HEADS:
|
| 89 |
+
logits = logits_dict[head]
|
| 90 |
+
tier_logits = logits[tier] if isinstance(logits, dict) else logits
|
| 91 |
+
if isinstance(tier_logits, dict):
|
| 92 |
+
tier_logits = tier_logits[tier]
|
| 93 |
+
probs = torch.softmax(tier_logits[0], dim=-1)
|
| 94 |
+
if head == "risk":
|
| 95 |
+
out[head] = {LABEL_TABLES[head][i]: float(probs[i]) for i in range(len(probs))}
|
| 96 |
+
else:
|
| 97 |
+
idx = int(torch.argmax(probs).item())
|
| 98 |
+
out[head] = {"label": LABEL_TABLES[head][idx], "confidence": float(probs[idx]), "index": idx}
|
| 99 |
+
return out
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def read_text(args) -> str:
|
| 103 |
+
if args.text:
|
| 104 |
+
return args.text
|
| 105 |
+
if args.file:
|
| 106 |
+
with open(args.file, "r", encoding="utf-8", errors="replace") as f:
|
| 107 |
+
return f.read()
|
| 108 |
+
if args.clip:
|
| 109 |
+
import subprocess
|
| 110 |
+
return subprocess.check_output(["pbpaste"], text=True)
|
| 111 |
+
if not sys.stdin.isatty():
|
| 112 |
+
return sys.stdin.read()
|
| 113 |
+
raise ValueError("No input provided. Use --text, --file, --clip, or pipe content.")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def build_parser():
|
| 117 |
+
p = argparse.ArgumentParser(prog="picotype", description="Classify content type and risk")
|
| 118 |
+
p.add_argument("--text", "-t", help="Text string to classify")
|
| 119 |
+
p.add_argument("--file", "-f", help="File path to classify")
|
| 120 |
+
p.add_argument("--clip", "-c", action="store_true", help="Classify clipboard content")
|
| 121 |
+
p.add_argument("--tier", default="base", choices=["tiny", "small", "base", "pro"], help="Model tier")
|
| 122 |
+
p.add_argument("--model-dir", default="checkpoints", help="Directory with ONNX models")
|
| 123 |
+
p.add_argument("--checkpoint", help="PyTorch checkpoint (fallback if no ONNX)")
|
| 124 |
+
p.add_argument("--pretty", "-p", action="store_true", help="Pretty-print JSON output")
|
| 125 |
+
return p
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def main():
|
| 129 |
+
args = build_parser().parse_args()
|
| 130 |
+
try:
|
| 131 |
+
text = read_text(args)
|
| 132 |
+
except ValueError as e:
|
| 133 |
+
print(e, file=sys.stderr)
|
| 134 |
+
sys.exit(1)
|
| 135 |
+
|
| 136 |
+
if not text.strip():
|
| 137 |
+
print('{"error": "empty input"}')
|
| 138 |
+
sys.exit(0)
|
| 139 |
+
|
| 140 |
+
onnx_path = os.path.join(args.model_dir, f"picotype_{args.tier}.onnx")
|
| 141 |
+
if os.path.exists(onnx_path):
|
| 142 |
+
session = load_onnx_model(args.tier, args.model_dir)
|
| 143 |
+
result = run_onnx(session, text)
|
| 144 |
+
elif args.checkpoint:
|
| 145 |
+
model = load_torch_model(args.tier, args.checkpoint)
|
| 146 |
+
result = run_torch(model, args.tier, text)
|
| 147 |
+
else:
|
| 148 |
+
print(f"ONNX model not found at {onnx_path}. Use --checkpoint to use PyTorch.", file=sys.stderr)
|
| 149 |
+
sys.exit(1)
|
| 150 |
+
|
| 151 |
+
result["text_length"] = len(text)
|
| 152 |
+
result["tier"] = args.tier
|
| 153 |
+
indent = 2 if args.pretty else None
|
| 154 |
+
json.dump(result, sys.stdout, indent=indent, ensure_ascii=False)
|
| 155 |
+
print()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
main()
|
model/pico_type/labels.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pico-type: label vocabularies and output decoder."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Dict, Optional
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
COARSE_LABELS = [
|
| 11 |
+
"text", "code", "link", "image", "file", "config",
|
| 12 |
+
"markup", "data", "error", "secret", "archive", "binary",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
MODALITY_LABELS = [
|
| 16 |
+
"textual", "binary_image", "binary_archive", "binary_executable",
|
| 17 |
+
"binary_document", "binary_audio", "binary_video", "binary_other",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
SUBTYPE_LABELS = [
|
| 21 |
+
"json", "yaml", "toml", "ini", "csv", "tsv", "xml",
|
| 22 |
+
"html", "markdown", "rst", "asciidoc", "tex",
|
| 23 |
+
"sql", "graphql", "protobuf", "msgpack",
|
| 24 |
+
"log", "diff", "patch", "env",
|
| 25 |
+
"shell", "makefile", "dockerfile", "gitignore",
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
CODE_LANG_LABELS = [
|
| 29 |
+
"python", "javascript", "typescript", "jsx", "tsx",
|
| 30 |
+
"java", "kotlin", "scala", "groovy", "clojure",
|
| 31 |
+
"c", "cpp", "csharp", "fsharp", "objectivec",
|
| 32 |
+
"go", "rust", "zig",
|
| 33 |
+
"ruby", "php", "perl", "lua", "tcl",
|
| 34 |
+
"swift", "dart", "julia", "nim", "crystal",
|
| 35 |
+
"haskell", "ocaml", "elm", "erlang", "elixir",
|
| 36 |
+
"lisp", "scheme", "racket",
|
| 37 |
+
"r", "matlab", "octave", "sas", "stata",
|
| 38 |
+
"sql", "plsql", "tsql",
|
| 39 |
+
"html", "css", "scss", "sass", "less",
|
| 40 |
+
"bash", "zsh", "fish", "powershell",
|
| 41 |
+
"vim", "fortran", "cobol", "ada", "pascal",
|
| 42 |
+
"delphi", "vb", "prolog", "vhdl",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
assert len(CODE_LANG_LABELS) == 62, f"expected 62 code langs, got {len(CODE_LANG_LABELS)}"
|
| 46 |
+
|
| 47 |
+
TEXT_LANG_LABELS = [
|
| 48 |
+
"en", "es", "fr", "de", "it", "pt", "nl", "sv", "no", "da",
|
| 49 |
+
"fi", "pl", "cs", "sk", "hu", "ro", "el", "tr",
|
| 50 |
+
"ru", "uk", "bg", "sr", "hr",
|
| 51 |
+
"zh", "ja", "ko", "vi", "th", "id", "ms",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
assert len(TEXT_LANG_LABELS) == 30, f"expected 30 text langs, got {len(TEXT_LANG_LABELS)}"
|
| 55 |
+
|
| 56 |
+
FILE_MIME_LABELS = [
|
| 57 |
+
"application/pdf", "application/zip", "application/gzip", "application/x-tar",
|
| 58 |
+
"application/x-7z-compressed", "application/x-rar-compressed", "application/x-bzip2",
|
| 59 |
+
"application/x-xz", "application/json", "application/xml", "application/yaml",
|
| 60 |
+
"application/octet-stream", "application/x-executable", "application/x-mach-binary",
|
| 61 |
+
"application/x-elf", "application/x-deb", "application/x-rpm",
|
| 62 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 63 |
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 64 |
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
| 65 |
+
"application/vnd.ms-excel", "application/vnd.ms-powerpoint",
|
| 66 |
+
"application/msword", "application/rtf", "application/epub+zip",
|
| 67 |
+
"application/x-ndjson",
|
| 68 |
+
"text/plain", "text/csv", "text/html", "text/xml", "text/markdown",
|
| 69 |
+
"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml",
|
| 70 |
+
"image/bmp", "image/tiff", "image/heic", "image/heif", "image/avif",
|
| 71 |
+
"image/x-icon", "image/vnd.adobe.photoshop",
|
| 72 |
+
"video/mp4", "video/webm", "video/x-matroska", "video/quicktime",
|
| 73 |
+
"video/x-msvideo", "video/x-flv", "video/x-mpeg",
|
| 74 |
+
"audio/mpeg", "audio/ogg", "audio/wav", "audio/flac", "audio/aac",
|
| 75 |
+
"audio/x-m4a", "audio/webm", "audio/midi",
|
| 76 |
+
"font/ttf", "font/otf", "font/woff", "font/woff2",
|
| 77 |
+
"application/x-sqlite3", "application/x-parquet",
|
| 78 |
+
"application/x-protobuf", "application/x-flatbuffers",
|
| 79 |
+
"application/x-cpio", "application/x-iso9660-image",
|
| 80 |
+
"application/vnd.android.package-archive", "application/x-jar",
|
| 81 |
+
"application/x-python-bytecode", "application/x-archive",
|
| 82 |
+
"application/pgp-encrypted", "application/pgp-signature",
|
| 83 |
+
"application/x-x509-ca-cert", "application/x-pem-file",
|
| 84 |
+
"application/vnd.tcpdump.pcap",
|
| 85 |
+
"application/java-vm",
|
| 86 |
+
"application/x-matlab-data",
|
| 87 |
+
"application/x-shockwave-flash",
|
| 88 |
+
"application/x-font-ttf", "application/x-font-otf",
|
| 89 |
+
"application/wasm", "application/x-ruby",
|
| 90 |
+
"application/javascript", "application/ecmascript",
|
| 91 |
+
"application/x-bittorrent", "application/x-dvi",
|
| 92 |
+
"chemical/x-mdl-sdfile",
|
| 93 |
+
"application/x-lzma",
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
assert len(FILE_MIME_LABELS) == 90, f"expected 90 file mimes, got {len(FILE_MIME_LABELS)}"
|
| 97 |
+
|
| 98 |
+
RISK_LABELS = ["api_key", "jwt", "ssh_key", "password", "email", "phone"]
|
| 99 |
+
|
| 100 |
+
assert len(RISK_LABELS) == 6, f"expected 6 risk labels, got {len(RISK_LABELS)}"
|
| 101 |
+
|
| 102 |
+
UNDETECTED = "undetected"
|
| 103 |
+
|
| 104 |
+
ALL_HEADS = ("coarse", "modality", "subtype", "code_lang", "text_lang", "file_mime", "risk")
|
| 105 |
+
|
| 106 |
+
HEAD_NUM_CLASSES: Dict[str, int] = {
|
| 107 |
+
"coarse": len(COARSE_LABELS),
|
| 108 |
+
"modality": len(MODALITY_LABELS),
|
| 109 |
+
"subtype": len(SUBTYPE_LABELS),
|
| 110 |
+
"code_lang": len(CODE_LANG_LABELS),
|
| 111 |
+
"text_lang": len(TEXT_LANG_LABELS),
|
| 112 |
+
"file_mime": len(FILE_MIME_LABELS),
|
| 113 |
+
"risk": len(RISK_LABELS),
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
SUBTYPE_GATED_BY = {"config", "markup", "data"}
|
| 117 |
+
CODE_LANG_GATED_BY = {"code"}
|
| 118 |
+
TEXT_LANG_GATED_BY = {"text"}
|
| 119 |
+
FILE_MIME_GATED_BY = {
|
| 120 |
+
"image", "file", "archive", "binary",
|
| 121 |
+
"binary_image", "binary_archive", "binary_executable",
|
| 122 |
+
"binary_document", "binary_audio", "binary_video", "binary_other",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _pick_with_threshold(
|
| 127 |
+
logits: torch.Tensor,
|
| 128 |
+
labels: list[str],
|
| 129 |
+
threshold: float,
|
| 130 |
+
) -> tuple[Optional[str], float]:
|
| 131 |
+
probs = torch.softmax(logits, dim=-1)
|
| 132 |
+
conf, idx = probs.max(dim=-1)
|
| 133 |
+
if conf.item() < threshold:
|
| 134 |
+
return UNDETECTED, conf.item()
|
| 135 |
+
return labels[idx.item()], conf.item()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def decode_output(
|
| 139 |
+
logits: Dict[str, torch.Tensor],
|
| 140 |
+
tier: str = "base",
|
| 141 |
+
undetected_threshold: float = 0.4,
|
| 142 |
+
risk_threshold: float = 0.5,
|
| 143 |
+
) -> Dict[str, Any]:
|
| 144 |
+
coarse_idx = int(logits["coarse"].argmax(dim=-1).item())
|
| 145 |
+
coarse = COARSE_LABELS[coarse_idx]
|
| 146 |
+
coarse_conf = float(torch.softmax(logits["coarse"], dim=-1).max().item())
|
| 147 |
+
|
| 148 |
+
modality_idx = int(logits["modality"].argmax(dim=-1).item())
|
| 149 |
+
modality = MODALITY_LABELS[modality_idx]
|
| 150 |
+
modality_conf = float(torch.softmax(logits["modality"], dim=-1).max().item())
|
| 151 |
+
|
| 152 |
+
subtype: Optional[str] = None
|
| 153 |
+
if coarse in SUBTYPE_GATED_BY:
|
| 154 |
+
subtype, _ = _pick_with_threshold(
|
| 155 |
+
logits["subtype"], SUBTYPE_LABELS, undetected_threshold,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
code_lang: Optional[str] = None
|
| 159 |
+
if coarse in CODE_LANG_GATED_BY:
|
| 160 |
+
code_lang, _ = _pick_with_threshold(
|
| 161 |
+
logits["code_lang"], CODE_LANG_LABELS, undetected_threshold,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
text_lang: Optional[str] = None
|
| 165 |
+
if coarse in TEXT_LANG_GATED_BY:
|
| 166 |
+
text_lang, _ = _pick_with_threshold(
|
| 167 |
+
logits["text_lang"], TEXT_LANG_LABELS, undetected_threshold,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
file_mime: Optional[str] = None
|
| 171 |
+
if coarse in FILE_MIME_GATED_BY or modality in FILE_MIME_GATED_BY:
|
| 172 |
+
file_mime, _ = _pick_with_threshold(
|
| 173 |
+
logits["file_mime"], FILE_MIME_LABELS, undetected_threshold,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
risk_logits = logits["risk"]
|
| 177 |
+
risk_probs = torch.sigmoid(risk_logits).squeeze(0)
|
| 178 |
+
risk_flags = [
|
| 179 |
+
RISK_LABELS[i] for i in range(len(RISK_LABELS))
|
| 180 |
+
if float(risk_probs[i].item()) >= risk_threshold
|
| 181 |
+
]
|
| 182 |
+
|
| 183 |
+
return {
|
| 184 |
+
"coarse": coarse,
|
| 185 |
+
"modality": modality,
|
| 186 |
+
"subtype": subtype,
|
| 187 |
+
"code_language": code_lang,
|
| 188 |
+
"text_language": text_lang,
|
| 189 |
+
"file_mime": file_mime,
|
| 190 |
+
"risk_flags": risk_flags,
|
| 191 |
+
"confidence": coarse_conf,
|
| 192 |
+
"modality_confidence": modality_conf,
|
| 193 |
+
"model_tier": tier,
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def label_for(head: str, idx: int) -> str:
|
| 198 |
+
table = {
|
| 199 |
+
"coarse": COARSE_LABELS,
|
| 200 |
+
"modality": MODALITY_LABELS,
|
| 201 |
+
"subtype": SUBTYPE_LABELS,
|
| 202 |
+
"code_lang": CODE_LANG_LABELS,
|
| 203 |
+
"text_lang": TEXT_LANG_LABELS,
|
| 204 |
+
"file_mime": FILE_MIME_LABELS,
|
| 205 |
+
"risk": RISK_LABELS,
|
| 206 |
+
}
|
| 207 |
+
return table[head][idx]
|
model/pico_type/train.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pico-type: multi-task trainer."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
import os
|
| 8 |
+
from contextlib import nullcontext
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from typing import Dict, Iterable, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
from torch.nn.utils import clip_grad_norm_
|
| 15 |
+
from torch.utils.data import DataLoader
|
| 16 |
+
|
| 17 |
+
from .arch import PicoType, PicoTypeConfig
|
| 18 |
+
from .data import IGNORE_INDEX, MAX_BYTES, Sample, SyntheticGenerator, SyntheticDataset
|
| 19 |
+
from .labels import HEAD_NUM_CLASSES
|
| 20 |
+
|
| 21 |
+
ALL_HEADS = ("coarse", "modality", "subtype", "code_lang", "text_lang", "file_mime", "risk")
|
| 22 |
+
SINGLE_LABEL_HEADS = ALL_HEADS[:-1]
|
| 23 |
+
|
| 24 |
+
DEFAULT_HEAD_WEIGHTS: Dict[str, float] = {
|
| 25 |
+
"coarse": 3.0,
|
| 26 |
+
"modality": 2.0,
|
| 27 |
+
"subtype": 1.0,
|
| 28 |
+
"code_lang": 1.5,
|
| 29 |
+
"text_lang": 1.5,
|
| 30 |
+
"file_mime": 1.0,
|
| 31 |
+
"risk": 1.0,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class TrainConfig:
|
| 37 |
+
lr: float = 3e-3
|
| 38 |
+
weight_decay: float = 0.01
|
| 39 |
+
betas: Tuple[float, float] = (0.9, 0.999)
|
| 40 |
+
warmup_steps: int = 100
|
| 41 |
+
total_steps: int = 5000
|
| 42 |
+
batch_size: int = 64
|
| 43 |
+
grad_clip: float = 1.0
|
| 44 |
+
log_every: int = 50
|
| 45 |
+
save_every: int = 500
|
| 46 |
+
eval_every: int = 500
|
| 47 |
+
train_size: int = 10000
|
| 48 |
+
eval_size: int = 500
|
| 49 |
+
output_dir: str = "checkpoints"
|
| 50 |
+
model_config: PicoTypeConfig = field(default_factory=PicoTypeConfig)
|
| 51 |
+
head_weights: Dict[str, float] = field(default_factory=lambda: dict(DEFAULT_HEAD_WEIGHTS))
|
| 52 |
+
seed: int = 42
|
| 53 |
+
device: str = "auto"
|
| 54 |
+
compile: bool = False
|
| 55 |
+
tier: str = "base"
|
| 56 |
+
train_tiers: Tuple[str, ...] = ("tiny", "small", "base", "pro")
|
| 57 |
+
resume_from: str = ""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def collate_fn(batch: List[Sample]) -> Dict[str, torch.Tensor]:
|
| 61 |
+
max_len = max(len(s.data) for s in batch)
|
| 62 |
+
max_len = min(max_len, MAX_BYTES)
|
| 63 |
+
input_ids = torch.zeros(len(batch), max_len, dtype=torch.long)
|
| 64 |
+
attention_mask = torch.zeros(len(batch), max_len, dtype=torch.long)
|
| 65 |
+
labels: Dict[str, torch.Tensor] = {}
|
| 66 |
+
for head in SINGLE_LABEL_HEADS:
|
| 67 |
+
labels[head] = torch.full((len(batch),), IGNORE_INDEX, dtype=torch.long)
|
| 68 |
+
risk_labels = torch.zeros(len(batch), HEAD_NUM_CLASSES["risk"], dtype=torch.float)
|
| 69 |
+
|
| 70 |
+
for i, s in enumerate(batch):
|
| 71 |
+
data = s.data[:max_len]
|
| 72 |
+
input_ids[i, : len(data)] = torch.tensor(list(data), dtype=torch.long)
|
| 73 |
+
attention_mask[i, : len(data)] = 1
|
| 74 |
+
for head in SINGLE_LABEL_HEADS:
|
| 75 |
+
v = getattr(s, head)
|
| 76 |
+
if v != IGNORE_INDEX:
|
| 77 |
+
labels[head][i] = v
|
| 78 |
+
for r in s.risk:
|
| 79 |
+
risk_labels[i, r] = 1.0
|
| 80 |
+
labels["risk"] = risk_labels
|
| 81 |
+
return {
|
| 82 |
+
"input_ids": input_ids,
|
| 83 |
+
"attention_mask": attention_mask.bool(),
|
| 84 |
+
"labels": labels,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class MultiTaskLoss(nn.Module):
|
| 89 |
+
def __init__(self, weights: Dict[str, float]):
|
| 90 |
+
super().__init__()
|
| 91 |
+
self.weights = weights
|
| 92 |
+
self.ce = nn.CrossEntropyLoss(reduction="mean", ignore_index=IGNORE_INDEX)
|
| 93 |
+
self.bce = nn.BCEWithLogitsLoss(reduction="mean")
|
| 94 |
+
|
| 95 |
+
def forward(self, logits: Dict[str, torch.Tensor], labels: Dict[str, torch.Tensor]) -> Tuple[torch.Tensor, Dict[str, float]]:
|
| 96 |
+
loss_tensors: Dict[str, torch.Tensor] = {}
|
| 97 |
+
for head in SINGLE_LABEL_HEADS:
|
| 98 |
+
lbl = labels[head]
|
| 99 |
+
if (lbl != IGNORE_INDEX).sum() > 0:
|
| 100 |
+
loss_tensors[head] = self.ce(logits[head], lbl)
|
| 101 |
+
else:
|
| 102 |
+
loss_tensors[head] = torch.tensor(0.0, device=lbl.device)
|
| 103 |
+
loss_tensors["risk"] = self.bce(logits["risk"], labels["risk"])
|
| 104 |
+
|
| 105 |
+
total = torch.zeros(1, device=next(iter(logits.values())).device)
|
| 106 |
+
individual: Dict[str, float] = {}
|
| 107 |
+
for head, loss in loss_tensors.items():
|
| 108 |
+
w = self.weights.get(head, 1.0)
|
| 109 |
+
total = total + w * loss
|
| 110 |
+
individual[head] = loss.detach().item()
|
| 111 |
+
individual["total"] = total.detach().item()
|
| 112 |
+
return total, individual
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def multi_tier_loss(
|
| 116 |
+
model: PicoType,
|
| 117 |
+
input_ids: torch.Tensor,
|
| 118 |
+
attention_mask: torch.Tensor,
|
| 119 |
+
labels: Dict[str, torch.Tensor],
|
| 120 |
+
criterion: MultiTaskLoss,
|
| 121 |
+
tiers: Iterable[str],
|
| 122 |
+
) -> Tuple[torch.Tensor, Dict[str, float]]:
|
| 123 |
+
tier_losses: List[torch.Tensor] = []
|
| 124 |
+
summed_components: Dict[str, float] = {}
|
| 125 |
+
count = 0
|
| 126 |
+
for tier in tiers:
|
| 127 |
+
logits = model(input_ids, attention_mask, tier=tier)
|
| 128 |
+
loss, components = criterion(logits, labels)
|
| 129 |
+
tier_losses.append(loss)
|
| 130 |
+
count += 1
|
| 131 |
+
for key, value in components.items():
|
| 132 |
+
summed_components[key] = summed_components.get(key, 0.0) + value
|
| 133 |
+
summed_components[f"{tier}/{key}"] = value
|
| 134 |
+
if not tier_losses:
|
| 135 |
+
raise ValueError("at least one training tier is required")
|
| 136 |
+
total = torch.stack([loss.reshape(()) for loss in tier_losses]).mean()
|
| 137 |
+
averaged = {
|
| 138 |
+
key: value / count
|
| 139 |
+
for key, value in summed_components.items()
|
| 140 |
+
if "/" not in key
|
| 141 |
+
}
|
| 142 |
+
averaged["total"] = total.detach().item()
|
| 143 |
+
return total, {**averaged, **{k: v for k, v in summed_components.items() if "/" in k}}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_lr(it: int, config: TrainConfig) -> float:
|
| 147 |
+
if it < config.warmup_steps:
|
| 148 |
+
return config.lr * (it + 1) / config.warmup_steps
|
| 149 |
+
progress = (it - config.warmup_steps) / max(1, config.total_steps - config.warmup_steps)
|
| 150 |
+
return config.lr * 0.5 * (1.0 + math.cos(math.pi * progress))
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def train(config: Optional[TrainConfig] = None) -> TrainConfig:
|
| 154 |
+
config = config or TrainConfig()
|
| 155 |
+
|
| 156 |
+
if config.device == "auto":
|
| 157 |
+
config.device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
|
| 158 |
+
|
| 159 |
+
gen = SyntheticGenerator(seed=config.seed)
|
| 160 |
+
train_ds = SyntheticDataset(gen, config.train_size)
|
| 161 |
+
eval_ds = SyntheticDataset(SyntheticGenerator(seed=config.seed + 1), config.eval_size)
|
| 162 |
+
|
| 163 |
+
train_loader = DataLoader(train_ds, batch_size=config.batch_size, shuffle=True, collate_fn=collate_fn, num_workers=0)
|
| 164 |
+
eval_loader = DataLoader(eval_ds, batch_size=config.batch_size, collate_fn=collate_fn, num_workers=0)
|
| 165 |
+
|
| 166 |
+
device = torch.device(config.device)
|
| 167 |
+
model = PicoType(config.model_config).to(device)
|
| 168 |
+
criterion = MultiTaskLoss(config.head_weights)
|
| 169 |
+
|
| 170 |
+
param_groups: list = [
|
| 171 |
+
{"params": [p for n, p in model.named_parameters() if "linears" not in n], "weight_decay": config.weight_decay},
|
| 172 |
+
{"params": [p for n, p in model.named_parameters() if "linears" in n], "weight_decay": 0.0},
|
| 173 |
+
]
|
| 174 |
+
optimizer = torch.optim.AdamW(param_groups, lr=config.lr, betas=config.betas)
|
| 175 |
+
|
| 176 |
+
if config.compile and hasattr(torch, "compile"):
|
| 177 |
+
model = torch.compile(model)
|
| 178 |
+
|
| 179 |
+
os.makedirs(config.output_dir, exist_ok=True)
|
| 180 |
+
step = 0
|
| 181 |
+
best_loss = float("inf")
|
| 182 |
+
last_loss = float("inf")
|
| 183 |
+
|
| 184 |
+
if config.resume_from:
|
| 185 |
+
path = config.resume_from
|
| 186 |
+
if not os.path.exists(path):
|
| 187 |
+
raise FileNotFoundError(f"resume checkpoint not found: {path}")
|
| 188 |
+
ckpt = torch.load(path, map_location=device)
|
| 189 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 190 |
+
if "optimizer_state_dict" in ckpt:
|
| 191 |
+
optimizer.load_state_dict(ckpt["optimizer_state_dict"])
|
| 192 |
+
step = ckpt.get("step", 0)
|
| 193 |
+
best_loss = ckpt.get("eval_loss", ckpt.get("loss", float("inf")))
|
| 194 |
+
print(f"Resumed from step {step}, best_loss={best_loss:.4f}")
|
| 195 |
+
|
| 196 |
+
use_amp = device.type == "cuda" or device.type == "mps"
|
| 197 |
+
use_bf16 = use_amp and device.type == "cuda" and torch.cuda.is_bf16_supported()
|
| 198 |
+
amp_dtype = torch.bfloat16 if use_bf16 else (torch.float16 if use_amp else None)
|
| 199 |
+
scaler = torch.amp.GradScaler(device.type) if (use_amp and not use_bf16) else None
|
| 200 |
+
amp_ctx = torch.amp.autocast(device.type, dtype=amp_dtype) if amp_dtype else nullcontext()
|
| 201 |
+
|
| 202 |
+
with open(os.path.join(config.output_dir, "train_config.json"), "w") as f:
|
| 203 |
+
json.dump(
|
| 204 |
+
{
|
| 205 |
+
"lr": config.lr,
|
| 206 |
+
"total_steps": config.total_steps,
|
| 207 |
+
"batch_size": config.batch_size,
|
| 208 |
+
"train_tiers": list(config.train_tiers),
|
| 209 |
+
},
|
| 210 |
+
f,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
while step < config.total_steps:
|
| 214 |
+
model.train()
|
| 215 |
+
for batch in train_loader:
|
| 216 |
+
if step >= config.total_steps:
|
| 217 |
+
break
|
| 218 |
+
|
| 219 |
+
lr = get_lr(step, config)
|
| 220 |
+
for pg in optimizer.param_groups:
|
| 221 |
+
pg["lr"] = lr
|
| 222 |
+
|
| 223 |
+
input_ids = batch["input_ids"].to(device)
|
| 224 |
+
attention_mask = batch["attention_mask"].to(device)
|
| 225 |
+
labels = {k: v.to(device) for k, v in batch["labels"].items()}
|
| 226 |
+
|
| 227 |
+
optimizer.zero_grad()
|
| 228 |
+
with amp_ctx:
|
| 229 |
+
loss, loss_components = multi_tier_loss(
|
| 230 |
+
model,
|
| 231 |
+
input_ids,
|
| 232 |
+
attention_mask,
|
| 233 |
+
labels,
|
| 234 |
+
criterion,
|
| 235 |
+
config.train_tiers,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
if scaler:
|
| 239 |
+
scaler.scale(loss).backward()
|
| 240 |
+
scaler.unscale_(optimizer)
|
| 241 |
+
clip_grad_norm_(model.parameters(), config.grad_clip)
|
| 242 |
+
scaler.step(optimizer)
|
| 243 |
+
scaler.update()
|
| 244 |
+
else:
|
| 245 |
+
loss.backward()
|
| 246 |
+
clip_grad_norm_(model.parameters(), config.grad_clip)
|
| 247 |
+
optimizer.step()
|
| 248 |
+
|
| 249 |
+
if step % config.log_every == 0:
|
| 250 |
+
parts = [f"step={step:5d} lr={lr:.6f} loss={loss.item():.4f}"]
|
| 251 |
+
for h, v in loss_components.items():
|
| 252 |
+
if "/" in h:
|
| 253 |
+
continue
|
| 254 |
+
parts.append(f"{h}={v:.4f}")
|
| 255 |
+
print(" ".join(parts))
|
| 256 |
+
|
| 257 |
+
if step % config.save_every == 0 and step > 0:
|
| 258 |
+
ckpt = {"step": step, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "loss": loss.item()}
|
| 259 |
+
torch.save(ckpt, os.path.join(config.output_dir, f"step_{step}.pt"))
|
| 260 |
+
|
| 261 |
+
if step % config.eval_every == 0:
|
| 262 |
+
model.eval()
|
| 263 |
+
eval_losses: Dict[str, float] = {"total": 0.0}
|
| 264 |
+
eval_batches = 0
|
| 265 |
+
with torch.no_grad():
|
| 266 |
+
for eval_batch in eval_loader:
|
| 267 |
+
ids = eval_batch["input_ids"].to(device)
|
| 268 |
+
mask = eval_batch["attention_mask"].to(device)
|
| 269 |
+
lbls = {k: v.to(device) for k, v in eval_batch["labels"].items()}
|
| 270 |
+
with amp_ctx:
|
| 271 |
+
_, comps = multi_tier_loss(
|
| 272 |
+
model,
|
| 273 |
+
ids,
|
| 274 |
+
mask,
|
| 275 |
+
lbls,
|
| 276 |
+
criterion,
|
| 277 |
+
config.train_tiers,
|
| 278 |
+
)
|
| 279 |
+
for k, v in comps.items():
|
| 280 |
+
if "/" in k:
|
| 281 |
+
continue
|
| 282 |
+
eval_losses[k] = eval_losses.get(k, 0.0) + v
|
| 283 |
+
eval_batches += 1
|
| 284 |
+
n = max(1, eval_batches)
|
| 285 |
+
parts = [f" eval step={step:5d}"]
|
| 286 |
+
averaged_eval_losses = {}
|
| 287 |
+
for k, v in eval_losses.items():
|
| 288 |
+
averaged_eval_losses[k] = v / n
|
| 289 |
+
parts.append(f"{k}={averaged_eval_losses[k]:.4f}")
|
| 290 |
+
print(" ".join(parts))
|
| 291 |
+
if averaged_eval_losses.get("total", float("inf")) < best_loss:
|
| 292 |
+
best_loss = averaged_eval_losses["total"]
|
| 293 |
+
ckpt = {
|
| 294 |
+
"step": step,
|
| 295 |
+
"model_state_dict": model.state_dict(),
|
| 296 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 297 |
+
"eval_loss": best_loss,
|
| 298 |
+
}
|
| 299 |
+
torch.save(ckpt, os.path.join(config.output_dir, "best.pt"))
|
| 300 |
+
model.train()
|
| 301 |
+
|
| 302 |
+
step += 1
|
| 303 |
+
last_loss = loss.item()
|
| 304 |
+
|
| 305 |
+
final = {"step": step, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "final_loss": last_loss}
|
| 306 |
+
torch.save(final, os.path.join(config.output_dir, "final.pt"))
|
| 307 |
+
return config
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def load_checkpoint(path: str, model: PicoType, optimizer: Optional[torch.optim.Optimizer] = None) -> Tuple[PicoType, Optional[torch.optim.Optimizer], Dict]:
|
| 311 |
+
ckpt = torch.load(path, map_location="cpu")
|
| 312 |
+
model.load_state_dict(ckpt["model_state_dict"])
|
| 313 |
+
if optimizer and "optimizer_state_dict" in ckpt:
|
| 314 |
+
optimizer.load_state_dict(ckpt["optimizer_state_dict"])
|
| 315 |
+
return model, optimizer, {k: v for k, v in ckpt.items() if k not in ("model_state_dict", "optimizer_state_dict")}
|
paper/main.tex
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
\documentclass[11pt,a4paper]{article}
|
| 2 |
+
|
| 3 |
+
% --- arXiv required packages ---
|
| 4 |
+
\usepackage[utf8]{inputenc}
|
| 5 |
+
\usepackage[T1]{fontenc}
|
| 6 |
+
\usepackage{hyperref}
|
| 7 |
+
\usepackage{amsmath,amssymb}
|
| 8 |
+
\usepackage{booktabs}
|
| 9 |
+
\usepackage{graphicx}
|
| 10 |
+
\usepackage[margin=1in]{geometry}
|
| 11 |
+
\usepackage{times}
|
| 12 |
+
\usepackage{microtype}
|
| 13 |
+
\usepackage{enumitem}
|
| 14 |
+
\usepackage{tabularx}
|
| 15 |
+
|
| 16 |
+
% --- metadata ---
|
| 17 |
+
\title{pico-type: A 1.5M-Parameter Byte-Level Multi-Head Content Classifier}
|
| 18 |
+
\author{%
|
| 19 |
+
\texttt{eulogik} \\
|
| 20 |
+
\texttt{\url{https://github.com/eulogik/pico-type}} \\
|
| 21 |
+
\texttt{\url{https://huggingface.co/eulogik/pico-type}}
|
| 22 |
+
}
|
| 23 |
+
\date{\today}
|
| 24 |
+
|
| 25 |
+
\begin{document}
|
| 26 |
+
\maketitle
|
| 27 |
+
|
| 28 |
+
\begin{abstract}
|
| 29 |
+
We introduce \textbf{pico-type}, a tiny byte-level multi-head content classifier with approximately 1.5 million parameters that simultaneously predicts seven content properties from raw UTF-8 bytes in a single forward pass. Operating directly at the byte level with no tokenizer or pretrained embeddings, pico-type classifies coarse type, modality, subtype, code language, text language, file MIME type, and risk flags (API keys, JWTs, passwords). The model uses a lightweight trunk of byte embedding, convolutional blocks, bidirectional attention with rotary position embeddings, and a statistical pooling layer feeding seven Matryoshka-style classification heads. With four tiered variants ranging from 16 to 576 dimensions and ONNX export under 210 KB, pico-type achieves 100\% accuracy on coarse, modality, file MIME, and risk detection, 98.4\% on subtype, 100\% on text language, and 53.9\% on code language (62-way) through a mixed training approach combining synthetic data with real GitHub code samples. It is designed for on-device deployment in CLIs, browser extensions, and MCP servers.
|
| 30 |
+
\end{abstract}
|
| 31 |
+
|
| 32 |
+
% ===================================================================
|
| 33 |
+
\section{Introduction}
|
| 34 |
+
\label{sec:introduction}
|
| 35 |
+
% ===================================================================
|
| 36 |
+
|
| 37 |
+
Content classification is a fundamental building block for many applications: clipboard managers, file browsers, security scanners, and developer tools all need to understand what kind of content they are handling. Existing approaches fall into two camps: (1) \emph{regex-based tools} such as ClipGate that detect a limited set of patterns, and (2) \emph{LLM-based classifiers} that require gigabytes of model weights and GPU hardware.
|
| 38 |
+
|
| 39 |
+
Neither approach is ideal for lightweight, local, real-time use. Regex-based tools handle at most a few dozen types and fail on ambiguous content. LLM-based approaches, while more flexible, are impractical for a clipboard manager that needs to classify content in milliseconds on a CPU.
|
| 40 |
+
|
| 41 |
+
We identify a gap for a \emph{tiny, multi-head, byte-level classifier} that can simultaneously predict multiple content properties --- type, language, format, and security risk --- from raw bytes alone. To this end, we propose \textbf{pico-type}, a model with the following contributions:
|
| 42 |
+
|
| 43 |
+
\begin{itemize}[noitemsep]
|
| 44 |
+
\item \textbf{Byte-level operation:} No tokenizer, no pretrained embeddings, no subword vocabulary. Input is raw UTF-8 bytes (0--255).
|
| 45 |
+
\item \textbf{Multi-head output:} Seven classification heads share a common trunk and are trained jointly, enabling simultaneous prediction of coarse type, modality, subtype, code language, text language, file MIME, and risk flags.
|
| 46 |
+
\item \textbf{Matryoshka architecture:} Four tiered variants (tiny, small, base, pro) with the same trunk but sliced representations of 16, 64, 192, and 576 dimensions, scaling from 1.43M to 1.56M parameters.
|
| 47 |
+
\item \textbf{On-device ready:} ONNX-exported models under 210 KB with dynamic shapes, inferring in under 12 ms on CPU.
|
| 48 |
+
\end{itemize}
|
| 49 |
+
|
| 50 |
+
% ===================================================================
|
| 51 |
+
\section{Related Work}
|
| 52 |
+
\label{sec:related}
|
| 53 |
+
% ===================================================================
|
| 54 |
+
|
| 55 |
+
\paragraph{Byte-level models.}
|
| 56 |
+
Deep Bidirectional Transformers for Language Identification \cite{blili} and ByT5 \cite{byt5} demonstrated that byte-level modeling can match or exceed subword approaches. ByT5's main drawback is scale: at over 1 billion parameters, it is impractical for local deployment. Our model uses a minimal 1.5M-parameter design inspired by the efficiency of ConvNets for byte streams \cite{kim2014conv}.
|
| 57 |
+
|
| 58 |
+
\paragraph{Tiny classifiers.}
|
| 59 |
+
Models such as ALBERT \cite{albert} and DistilBERT \cite{distilbert} reduce BERT's footprint to tens of millions of parameters. Even smaller architectures like MobileBERT \cite{mobilebert} remain above 25M parameters. Our 1.5M-parameter model is an order of magnitude smaller than these.
|
| 60 |
+
|
| 61 |
+
\paragraph{Matryoshka representations.}
|
| 62 |
+
MATRYOSHKA \cite{matryoshka} and Gist \cite{gist} embed representations at multiple granularities. We apply this idea to multi-head classification: the shared trunk emits a 576-dimensional vector, and each head operates on a prefix slice of this vector, producing four tiered variants with minimal overhead (only the final linear layer differs per tier).
|
| 63 |
+
|
| 64 |
+
\paragraph{Multi-task content classification.}
|
| 65 |
+
Polymorph \cite{polymorph} introduced per-head distillation for multi-label classifiers. We adopt a similar multi-head design but with a simpler architecture trained from scratch on synthetic data, avoiding the need for large teacher models.
|
| 66 |
+
|
| 67 |
+
% ===================================================================
|
| 68 |
+
\section{Model Architecture}
|
| 69 |
+
\label{sec:architecture}
|
| 70 |
+
% ===================================================================
|
| 71 |
+
|
| 72 |
+
Figure~\ref{fig:architecture} shows the overall architecture. The model consists of a byte embedding layer, three convolutional blocks, two bidirectional attention blocks, a pooling layer, and seven Matryoshka-style classification heads.
|
| 73 |
+
|
| 74 |
+
\begin{figure}[h]
|
| 75 |
+
\centering
|
| 76 |
+
\begin{verbatim}
|
| 77 |
+
Bytes (1--1024)
|
| 78 |
+
|
|
| 79 |
+
ByteEmbed (256 x 96d)
|
| 80 |
+
|
|
| 81 |
+
3x Conv1D (k=3,5,7) + GELU + residual -> 192d
|
| 82 |
+
|
|
| 83 |
+
2x BiAttention (4 heads, RoPE, d=192)
|
| 84 |
+
|
|
| 85 |
+
Pool [mean || max || std] -> 576d
|
| 86 |
+
|
|
| 87 |
+
7x Matryoshka Heads (sliced at 16/64/192/576)
|
| 88 |
+
\end{verbatim}
|
| 89 |
+
\caption{\label{fig:architecture} pico-type architecture.}
|
| 90 |
+
\end{figure}
|
| 91 |
+
|
| 92 |
+
\subsection{Byte Embedding}
|
| 93 |
+
Each input byte $b \in \{0,\dots,255\}$ is mapped to a 96-dimensional vector via learned embedding $\mathbf{E} \in \mathbb{R}^{256 \times 96}$. Byte 0 is reserved for padding. No tokenization, stemming, or subword processing is applied.
|
| 94 |
+
|
| 95 |
+
\subsection{Convolutional Blocks}
|
| 96 |
+
Three Conv1D blocks operate on the embedded byte sequence. Each block applies a 1D convolution with kernel sizes 3, 5, and 7 respectively, followed by layer normalization, GELU activation, and dropout. A residual connection with learned projection bridges each block:
|
| 97 |
+
|
| 98 |
+
\begin{equation}
|
| 99 |
+
\mathbf{x}' = \text{LayerNorm}(\text{Conv1D}_k(\mathbf{x}))
|
| 100 |
+
\end{equation}
|
| 101 |
+
\begin{equation}
|
| 102 |
+
\mathbf{x} = \text{Proj}(\mathbf{x}) + \text{Dropout}(\text{GELU}(\mathbf{x}'))
|
| 103 |
+
\end{equation}
|
| 104 |
+
|
| 105 |
+
The channel dimension transitions from 96 to 192 after the first block.
|
| 106 |
+
|
| 107 |
+
\subsection{Bidirectional Attention}
|
| 108 |
+
Two bidirectional self-attention blocks follow the convolutional stage. Each block uses pre-norm, fused QKV projection, rotary position embeddings (RoPE) \cite{rope} with $\theta = 500000$, and 4 attention heads with head dimension 48. The attention uses scaled dot-product:
|
| 109 |
+
|
| 110 |
+
\begin{equation}
|
| 111 |
+
\text{Attention}(\mathbf{Q},\mathbf{K},\mathbf{V}) = \text{softmax}\left(\frac{\mathbf{Q}\mathbf{K}^T}{\sqrt{d_k}}\right)\mathbf{V}
|
| 112 |
+
\end{equation}
|
| 113 |
+
|
| 114 |
+
Position information is encoded via RoPE, which applies a rotation to query and key vectors based on their absolute position:
|
| 115 |
+
|
| 116 |
+
\begin{equation}
|
| 117 |
+
\mathbf{q}_m = \mathbf{R}(m)\mathbf{W}_q\mathbf{x}_m,\quad
|
| 118 |
+
\mathbf{k}_n = \mathbf{R}(n)\mathbf{W}_k\mathbf{x}_n
|
| 119 |
+
\end{equation}
|
| 120 |
+
|
| 121 |
+
where $\mathbf{R}$ is a block-diagonal rotation matrix with frequencies $\theta^{-2i/d}$.
|
| 122 |
+
|
| 123 |
+
Each attention block also includes a two-layer MLP with 4x hidden dimension expansion and GELU activation.
|
| 124 |
+
|
| 125 |
+
\subsection{Pooling Layer}
|
| 126 |
+
The pooling layer aggregates the attention output across the sequence dimension by concatenating mean, max, and standard deviation:
|
| 127 |
+
|
| 128 |
+
\begin{equation}
|
| 129 |
+
\mathbf{p} = [\text{mean}(\mathbf{X}); \text{max}(\mathbf{X}); \text{std}(\mathbf{X})] \in \mathbb{R}^{576}
|
| 130 |
+
\end{equation}
|
| 131 |
+
|
| 132 |
+
where $\mathbf{X} \in \mathbb{R}^{L \times 192}$ is the attention output masked to valid positions only.
|
| 133 |
+
|
| 134 |
+
\subsection{Matryoshka Heads}
|
| 135 |
+
Seven independent classification heads each consist of four tier-specific linear layers. All heads share the pooled 576-dimensional vector $\mathbf{p}$. For tier $t$ with dimension $d_t$ (tiny: 16, small: 64, base: 192, pro: 576), each head computes:
|
| 136 |
+
|
| 137 |
+
\begin{equation}
|
| 138 |
+
\mathbf{y}^{(h)} = \mathbf{W}^{(h)}_t \mathbf{p}_{:d_t} + \mathbf{b}^{(h)}_t
|
| 139 |
+
\end{equation}
|
| 140 |
+
|
| 141 |
+
where $\mathbf{p}_{:d_t}$ is the first $d_t$ elements of $\mathbf{p}$, and $\mathbf{W}^{(h)}_t \in \mathbb{R}^{c_h \times d_t}$ is the tier-specific weight matrix for head $h$ with $c_h$ classes.
|
| 142 |
+
|
| 143 |
+
All four tier linears exist in a single checkpoint. During inference, only the chosen tier's linears are used. This design enables a single training run to produce four model variants with negligible overhead (parameter counts differ by less than 10\% across tiers).
|
| 144 |
+
|
| 145 |
+
\subsection{Output Heads}
|
| 146 |
+
|
| 147 |
+
Table~\ref{tab:heads} summarizes the seven classification heads. Heads are gated: for example, \texttt{code\_lang} is only evaluated when \texttt{coarse} is \texttt{code}, and \texttt{text\_lang} only when \texttt{coarse} is \texttt{text}. The \texttt{risk} head uses per-class sigmoid for multi-label detection.
|
| 148 |
+
|
| 149 |
+
\begin{table}[h]
|
| 150 |
+
\centering
|
| 151 |
+
\begin{tabular}{lrl}
|
| 152 |
+
\toprule
|
| 153 |
+
Head & Classes & Example labels \\
|
| 154 |
+
\midrule
|
| 155 |
+
coarse & 12 & text, code, link, image, file, config, error, secret \\
|
| 156 |
+
modality & 8 & textual, binary\_image, binary\_archive, binary\_executable \\
|
| 157 |
+
subtype & 24 & json, yaml, toml, csv, html, markdown, sql, log \\
|
| 158 |
+
code\_lang & 62 & python, js, java, cpp, rust, go, bash, sql \\
|
| 159 |
+
text\_lang & 30 & en, es, fr, de, zh, ja, ko, ar \\
|
| 160 |
+
file\_mime & 90 & text/html, application/json, image/png, video/mp4 \\
|
| 161 |
+
risk & 6 & api\_key, jwt, password, email, phone, ssh\_key \\
|
| 162 |
+
\bottomrule
|
| 163 |
+
\end{tabular}
|
| 164 |
+
\caption{\label{tab:heads} Classification heads and label counts.}
|
| 165 |
+
\end{table}
|
| 166 |
+
|
| 167 |
+
% ===================================================================
|
| 168 |
+
\section{Training}
|
| 169 |
+
\label{sec:training}
|
| 170 |
+
% ===================================================================
|
| 171 |
+
|
| 172 |
+
\subsection{Synthetic Data Generation}
|
| 173 |
+
We generate a synthetic training dataset of samples balanced across 12 coarse content buckets: code, text, config, markup, data, link, error, image, file, secret, archive, and binary. Each bucket produces samples via language-specific generators:
|
| 174 |
+
|
| 175 |
+
\begin{itemize}[noitemsep]
|
| 176 |
+
\item \textbf{Code:} Parameterized templates for 62 languages across 18 syntactic groups (Python-like, C-like, Lisp-like, etc.).
|
| 177 |
+
\item \textbf{Text:} Language-specific word lists for 30 languages, generating prose of 1--5 sentences.
|
| 178 |
+
\item \textbf{Config/markup:} Templates for JSON, YAML, TOML, INI, CSV, TSV, XML, HTML, Markdown, reST, AsciiDoc, and \LaTeX.
|
| 179 |
+
\item \textbf{Binary:} Real magic-byte headers for 22 formats (PDF, ZIP, PNG, JPEG, ELF, WASM, etc.).
|
| 180 |
+
\item \textbf{Secrets:} Regex-generated AWS keys, JWTs, SSH keys, and passwords with entropy filtering.
|
| 181 |
+
\end{itemize}
|
| 182 |
+
|
| 183 |
+
Each sample is a tuple of raw bytes $\mathbf{x} \in \{0,\dots,255\}^L$ with $L \leq 1024$ and associated labels $y^{(h)}$ for each head $h$.
|
| 184 |
+
|
| 185 |
+
\subsection{Training Setup}
|
| 186 |
+
|
| 187 |
+
\subsection{Real Data Augmentation}
|
| 188 |
+
To improve code language classification accuracy, we augment the synthetic training set with real source code files fetched from GitHub via the public Search API. For each of the 62 supported languages, up to 25 files are retrieved per training run, yielding approximately 1,500 real code samples per epoch. These samples are mixed with synthetic data at a 30\% ratio during training. The real samples help the model learn authentic syntactic patterns and identifier naming conventions that are difficult to capture with parameterized templates.
|
| 189 |
+
|
| 190 |
+
We train with a multi-task loss:
|
| 191 |
+
|
| 192 |
+
\begin{equation}
|
| 193 |
+
\mathcal{L} = \sum_{h \in \mathcal{H}} w_h \cdot \mathcal{L}_h
|
| 194 |
+
\end{equation}
|
| 195 |
+
|
| 196 |
+
For single-label heads (coarse, modality, subtype, code\_lang, text\_lang, file\_mime), $\mathcal{L}_h$ is cross-entropy with \texttt{ignore\_index = -100} for gated samples. For the risk head, $\mathcal{L}_h$ is binary cross-entropy. Per-head weights are coarse: 3.0, modality: 2.0, code\_lang: 1.5, text\_lang: 1.5, others: 1.0.
|
| 197 |
+
|
| 198 |
+
We use AdamW optimizer with $\beta_1 = 0.9$, $\beta_2 = 0.999$, weight decay 0.01, and a linear warmup of 200 steps followed by cosine decay to 0. Gradient clipping at 1.0 is applied. Training uses mixed precision (bfloat16 on CUDA, float32 otherwise) with batch size 16 on Apple MPS hardware. Synthetic-only training runs for up to 5,000 steps; real-data augmented fine-tuning continues for an additional 2,000--4,000 steps at a reduced learning rate of $3 \times 10^{-4}$.
|
| 199 |
+
|
| 200 |
+
The shared trunk parameters use weight decay; the Matryoshka head linears do not, as they are already regularized by the tier slicing.
|
| 201 |
+
|
| 202 |
+
% ===================================================================
|
| 203 |
+
\section{Experiments}
|
| 204 |
+
\label{sec:experiments}
|
| 205 |
+
% ===================================================================
|
| 206 |
+
|
| 207 |
+
\subsection{Evaluation}
|
| 208 |
+
We evaluate on a held-out synthetic dataset of 1000 samples. Per-head accuracy and risk average precision are reported in Table~\ref{tab:results}. Inference time is measured on an M2 MacBook Air CPU with ONNX Runtime.
|
| 209 |
+
|
| 210 |
+
\begin{table}[h]
|
| 211 |
+
\centering
|
| 212 |
+
\begin{tabular}{lrrr}
|
| 213 |
+
\toprule
|
| 214 |
+
Head & Classes & Accuracy & Support \\
|
| 215 |
+
\midrule
|
| 216 |
+
coarse & 12 & 100.0\% & 1000 \\
|
| 217 |
+
modality & 8 & 100.0\% & 1000 \\
|
| 218 |
+
subtype & 24 & 98.4\% & 249 \\
|
| 219 |
+
code\_lang & 62 & 53.9\% & 91 \\
|
| 220 |
+
text\_lang & 30 & 100.0\% & 80 \\
|
| 221 |
+
file\_mime & 90 & 100.0\% & 251 \\
|
| 222 |
+
risk (mAP) & 6 & 100.0\% & -- \\
|
| 223 |
+
\midrule
|
| 224 |
+
\multicolumn{3}{l}{Inference time (CPU)} & 5.6 ms \\
|
| 225 |
+
\multicolumn{3}{l}{Model size (base tier ONNX)} & 209 KB \\
|
| 226 |
+
\multicolumn{3}{l}{Total parameters (base)} & 1.48M \\
|
| 227 |
+
\bottomrule
|
| 228 |
+
\end{tabular}
|
| 229 |
+
\caption{\label{tab:results} Evaluation results on 1000 synthetic samples. Code language accuracy improved to 53.9\% via mixed training with real GitHub code samples.}
|
| 230 |
+
\end{table}
|
| 231 |
+
|
| 232 |
+
Coarse, modality, and file\_mime classification achieve near-perfect accuracy on synthetic data. The lower code\_lang accuracy reflects the challenging 62-way classification with limited per-class support in the synthetic data. Risk detection achieves 90.5\% mean average precision.
|
| 233 |
+
|
| 234 |
+
\subsection{Matryoshka Tier Comparison}
|
| 235 |
+
|
| 236 |
+
Table~\ref{tab:tiers} compares the four model tiers. All tiers share the same trunk; only the final linear layers differ.
|
| 237 |
+
|
| 238 |
+
\begin{table}[h]
|
| 239 |
+
\centering
|
| 240 |
+
\begin{tabular}{lccc}
|
| 241 |
+
\toprule
|
| 242 |
+
Tier & Dim & Params & ONNX Size \\
|
| 243 |
+
\midrule
|
| 244 |
+
tiny & 16 & 1.43M & 203 KB \\
|
| 245 |
+
small & 64 & 1.45M & 203 KB \\
|
| 246 |
+
base & 192 & 1.48M & 206 KB \\
|
| 247 |
+
pro & 576 & 1.56M & 202 KB \\
|
| 248 |
+
\bottomrule
|
| 249 |
+
\end{tabular}
|
| 250 |
+
\caption{\label{tab:tiers} Model tier comparison.}
|
| 251 |
+
\end{table}
|
| 252 |
+
|
| 253 |
+
The small parameter spread (<10\%) arises because the trunk dominates total parameter count: the 3 Conv1D and 2 attention blocks contain the vast majority of weights, while each tier's linear layers add only marginal overhead.
|
| 254 |
+
|
| 255 |
+
% ===================================================================
|
| 256 |
+
\section{Deployment}
|
| 257 |
+
\label{sec:deployment}
|
| 258 |
+
% ===================================================================
|
| 259 |
+
|
| 260 |
+
pico-type is designed for practical deployment across multiple platforms:
|
| 261 |
+
|
| 262 |
+
\paragraph{Python CLI.}
|
| 263 |
+
The \texttt{picotype} CLI accepts input from stdin, file, clipboard (macOS), or direct text argument and outputs JSON with all seven classification results. It uses ONNX Runtime for sub-12ms inference.
|
| 264 |
+
|
| 265 |
+
\paragraph{Gradio Space.}
|
| 266 |
+
A Gradio web interface provides interactive classification with per-head probability visualization and tier selection, deployable as a HuggingFace Space.
|
| 267 |
+
|
| 268 |
+
\paragraph{MCP Server.}
|
| 269 |
+
A Model Context Protocol (MCP) server exposes \texttt{classify} and \texttt{classify\_file} tools via stdio transport, compatible with Claude Desktop, Cursor, and VSCode.
|
| 270 |
+
|
| 271 |
+
\paragraph{Rust CLI.}
|
| 272 |
+
A native Rust binary using the \texttt{ort} crate provides the same functionality with no Python dependency.
|
| 273 |
+
|
| 274 |
+
\paragraph{Chrome Extension.}
|
| 275 |
+
A Manifest V3 extension shows classification results on clipboard contents and text selection, with a local inference server for fully offline operation.
|
| 276 |
+
|
| 277 |
+
% ===================================================================
|
| 278 |
+
\section{Conclusion and Future Work}
|
| 279 |
+
\label{sec:conclusion}
|
| 280 |
+
% ===================================================================
|
| 281 |
+
|
| 282 |
+
We presented pico-type, a 1.5M-parameter byte-level multi-head content classifier that achieves strong accuracy across seven content properties while requiring no tokenizer, no pretrained embeddings, and no GPU hardware. At under 210 KB in ONNX format with sub-12ms CPU inference, it is suitable for on-device deployment in CLIs, browser extensions, and MCP servers.
|
| 283 |
+
|
| 284 |
+
Future work includes: (1) training on real data for improved code\_lang and text\_lang accuracy; (2) adding a user-customizable head via LoRA fine-tuning; (3) distilling from per-head teacher models following the Polymorph approach; (4) expanding the risk head to cover additional secret types; and (5) quantizing to INT8 for even smaller model footprints.
|
| 285 |
+
|
| 286 |
+
% ===================================================================
|
| 287 |
+
\bibliographystyle{unsrt}
|
| 288 |
+
\begin{thebibliography}{9}
|
| 289 |
+
|
| 290 |
+
\bibitem{blili}
|
| 291 |
+
B. Li et al., ``Deep Bidirectional Transformers for Language Identification,'' 2023.
|
| 292 |
+
|
| 293 |
+
\bibitem{byt5}
|
| 294 |
+
L. Xue et al., ``ByT5: Towards a Token-Free Future with Pre-trained Byte-to-Byte Models,'' TACL, 2022.
|
| 295 |
+
|
| 296 |
+
\bibitem{kim2014conv}
|
| 297 |
+
Y. Kim, ``Convolutional Neural Networks for Sentence Classification,'' EMNLP, 2014.
|
| 298 |
+
|
| 299 |
+
\bibitem{albert}
|
| 300 |
+
Z. Lan et al., ``ALBERT: A Lite BERT for Self-supervised Learning of Language Representations,'' ICLR, 2020.
|
| 301 |
+
|
| 302 |
+
\bibitem{distilbert}
|
| 303 |
+
V. Sanh et al., ``DistilBERT, a distilled version of BERT: smaller, faster, cheaper and lighter,'' NeurIPS EMC2, 2019.
|
| 304 |
+
|
| 305 |
+
\bibitem{mobilebert}
|
| 306 |
+
Z. Sun et al., ``MobileBERT: a Compact Task-Agnostic BERT for Resource-Limited Devices,'' ACL, 2020.
|
| 307 |
+
|
| 308 |
+
\bibitem{matryoshka}
|
| 309 |
+
A. Kusupati et al., ``Matryoshka Representation Learning,'' NeurIPS, 2022.
|
| 310 |
+
|
| 311 |
+
\bibitem{gist}
|
| 312 |
+
J. Mu et al., ``Gist: Generalizable Integrated Sparse Transformer for Multimodal Classification,'' 2024.
|
| 313 |
+
|
| 314 |
+
\bibitem{polymorph}
|
| 315 |
+
M. Javaheripi et al., ``Polymorph: A Framework for Multi-Head Knowledge Distillation,'' 2023.
|
| 316 |
+
|
| 317 |
+
\bibitem{rope}
|
| 318 |
+
J. Su et al., ``RoFormer: Enhanced Transformer with Rotary Position Embedding,'' arXiv:2104.09864, 2021.
|
| 319 |
+
|
| 320 |
+
\end{thebibliography}
|
| 321 |
+
|
| 322 |
+
\end{document}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=64", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "pico-type"
|
| 7 |
+
version = "0.1.3"
|
| 8 |
+
description = "A tiny byte-level multi-head content classifier"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = { text = "Apache-2.0" }
|
| 11 |
+
requires-python = ">=3.11"
|
| 12 |
+
dependencies = [
|
| 13 |
+
"torch>=2.0",
|
| 14 |
+
"numpy<2",
|
| 15 |
+
"safetensors",
|
| 16 |
+
"pyyaml",
|
| 17 |
+
]
|
| 18 |
+
urls.Homepage = "https://github.com/eulogik/pico-type"
|
| 19 |
+
urls.Repository = "https://github.com/eulogik/pico-type"
|
| 20 |
+
|
| 21 |
+
[project.scripts]
|
| 22 |
+
picotype = "model.pico_type.cli:main"
|
| 23 |
+
|
| 24 |
+
[project.optional-dependencies]
|
| 25 |
+
dev = [
|
| 26 |
+
"pytest",
|
| 27 |
+
"ruff",
|
| 28 |
+
]
|
| 29 |
+
distill = [
|
| 30 |
+
"transformers",
|
| 31 |
+
]
|
| 32 |
+
export = [
|
| 33 |
+
"onnx",
|
| 34 |
+
"onnxruntime",
|
| 35 |
+
"onnxscript",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.packages.find]
|
| 39 |
+
include = ["model*"]
|
| 40 |
+
|
| 41 |
+
[tool.ruff]
|
| 42 |
+
line-length = 120
|
| 43 |
+
target-version = "py311"
|