File size: 8,316 Bytes
7905a7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325259e
7905a7e
 
 
 
 
325259e
7905a7e
325259e
7905a7e
325259e
 
7905a7e
 
 
325259e
7905a7e
325259e
 
7905a7e
 
 
 
 
 
325259e
7905a7e
325259e
7905a7e
325259e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef51f9a
325259e
 
 
 
7905a7e
 
325259e
 
 
 
ef51f9a
325259e
 
 
 
 
 
 
7905a7e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
---
license: apache-2.0
datasets:
- ai4privacy/pii-masking-200k
- ai4privacy/pii-masking-400k
- ai4privacy/pii-masking-openpii-1m
- piimb/privy
- gretelai/gretel-pii-masking-en-v1
- nvidia/Nemotron-PII
language:
- ar
- bn
- de
- en
- es
- fr
- hi
- it
- ja
- ko
- nl
- pt
- te
- tr
- vi
- zh
base_model:
- openai/privacy-filter
- OpenMed/privacy-filter-multilingual-v2
pipeline_tag: token-classification
library_name: transformers
tags:
- token-classification
- pii
- multilingual
- openai-privacy-filter
- ner
---
# OpenMed Privacy Filter Multilingual v2 - ONNX

This is an [ONNX](https://onnx.ai/) export of [`OpenMed/privacy-filter-multilingual-v2`](https://huggingface.co/OpenMed/privacy-filter-multilingual-v2), converted for CPU inference with [`onnxruntime`](https://onnxruntime.ai/)'s `CPUExecutionProvider`. No fine-tuning was performed - this repo only changes the runtime format, not the model's weights or behavior.

## About the base model

All credit for the model itself goes to **[OpenMed](https://huggingface.co/OpenMed)**. From their model card: `privacy-filter-multilingual-v2` performs fine-grained PII extraction across **54 entity categories in 16 languages**, built on top of **OpenAI's Privacy Filter** (a Mixture-of-Experts transformer, 128 experts with top-4 routing per token) with a BIOES token-classification head, and extended with multilingual coverage and expanded entity types by OpenMed and the AI4Privacy community. See the [original model card](https://huggingface.co/OpenMed/privacy-filter-multilingual-v2) for the full entity list, training details, and citation.

This conversion exists only because no ONNX export was published alongside the original safetensors weights.

## What was done for this conversion

The checkpoint's `config.json` declares a custom `model_type`/architecture name (`openai_privacy_filter` / `OpenAIPrivacyFilterForTokenClassification`). Depending on your installed `transformers` version this may already be a recognized, first-class architecture (loadable directly via `AutoModelForTokenClassification`/`AutoTokenizer`) - that's what this export uses. If you're on an older `transformers` release without native support, do **not** substitute `transformers`' generic `gpt_oss` classes as a stand-in: despite matching tensor shapes (the checkpoint loads with zero missing/unexpected weight keys either way), the two architectures differ in a way that silently produces near-total false negatives - this model is **bidirectional** with sliding-window attention, while `gpt_oss` is causal/decoder-style. That mismatch doesn't error out; it just quietly predicts "O" (no entity) almost everywhere, while looking numerically self-consistent. If you hit that, upgrade `transformers` instead of working around it.

Two things don't just fall out of a plain `torch.onnx.export` call, either way:

1. **MoE routing.** The expert-routing module has two forward code paths: a data-dependent, per-batch Python loop (`.nonzero()`-driven "which experts got hit") used at runtime, which isn't traceable for export; and a fully vectorized dense computation over all 128 experts (weighted by mostly-zero routing weights), which is traceable and mathematically identical. This export monkeypatches the routing module to always use the dense path. Getting this exactly right matters: the routing weights arrive as a **sparse** `(num_tokens, top_k)` pair (`router_indices`, `routing_weights`), not a dense `(num_tokens, num_experts)` tensor, so they have to be scattered into a dense per-expert weight matrix first; and this checkpoint's gate/up projection uses a **concatenated** split (`gate, up = gate_up.chunk(2, dim=-1)`), not the interleaved split (`gate_up[..., ::2]`/`[..., 1::2]`) some sibling MoE architectures use. Either bug produces an export that's internally self-consistent (matches its own incorrect PyTorch reference to ~1e-5) but wrong relative to the true model - only comparing against a real forward pass on a known example (e.g. does it actually tag "John Smith" as a name?) catches it.
2. **Precision.** The checkpoint's `config.json` declares a default of bf16. Loading in that precision and exporting produces `Where` nodes with bf16 scalar operands that `onnxruntime`'s `CPUExecutionProvider` doesn't have a kernel for (a `NOT_IMPLEMENTED` error at session-load time, not export time). This export explicitly loads and exports in full **fp32**.

## Verification

The exported graph was validated against a native, unpatched PyTorch forward pass of the original checkpoint on the same inputs (both loaded fp32), running on `onnxruntime`'s `CPUExecutionProvider`:

- **Max absolute logit difference (ONNX vs. true native PyTorch): ~1.8e-5**
- Spot-checked against a plain-English sentence containing a name, phone number, and email - all three correctly tagged with their entity types and BIOES boundaries.

## Usage

```python
import numpy as np
import onnxruntime as ort
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("lemonade-sdk/openmed-privacy-filter-multilingual-v2-onnx")
session = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])

text = "My name is John Smith, call me at 555-123-4567 or email john@example.com."
encoded = tokenizer(text, return_tensors="np")
(logits,) = session.run(None, {
    "input_ids": encoded["input_ids"],
    "attention_mask": encoded["attention_mask"],
})  # logits: [batch, seq_len, 217] (BIOES over 54 entity types + "O")
```

No custom mask construction needed - the graph takes a plain 2D `input_ids` + `attention_mask`, same convention as a standard BERT-family token classifier.

## Using this with the Lemonade router

This repo is self-contained for [Lemonade](https://github.com/lemonade-sdk/lemonade)'s `onnxruntime` backend (via [`ort-server`](https://github.com/lemonade-sdk/ort-server)) - `model.onnx` + `model.onnx.data` + `tokenizer.json` + `config.json` + `manifest.json` all sit together at the repo root, so it can be registered directly as a checkpoint with no extra file staging.

**1. Register the classifier:**

```bash
curl -X POST http://localhost:13305/v1/pull -H "Content-Type: application/json" -d '{
  "model_name": "user.privacy-filter-ml-v2-onnx",
  "checkpoint": "lemonade-sdk/openmed-privacy-filter-multilingual-v2-onnx",
  "recipe": "onnxruntime"
}'
```

**2. Register the router policy** - a ready-to-use `collection.router` policy that routes to a local model whenever any of this classifier's 216 non-"O" labels crosses `min_score: 0.5`, and to a cloud model otherwise, is published separately at [`lemonade-sdk/pii_policy_openmed-privacy-filter-multilingual-v2-onnx`](https://huggingface.co/lemonade-sdk/pii_policy_openmed-privacy-filter-multilingual-v2-onnx):

```bash
hf download lemonade-sdk/pii_policy_openmed-privacy-filter-multilingual-v2-onnx --local-dir .
curl -X POST http://localhost:13305/v1/pull -H "Content-Type: application/json" \
  --data-binary @pii_policy_openmed-privacy-filter-multilingual-v2-onnx.json
```

The policy's `routing.candidates` (`Qwen3.5-0.8B-GGUF` local / `fireworks.kimi-k2p6` cloud) and `min_score` are starting points, not fixed requirements - swap either to whatever local/cloud models you have configured. On the Nemotron-PII benchmark (20,000 PII-bearing prompts), this policy scored a 0% leak rate (zero PII prompts routed to the cloud candidate).

For building your own routing policy from scratch, or a more sophisticated one (multiple classifiers, LLM-based routing, custom match logic) rather than adapting this one, see the [`lemonade-router-builder`](https://github.com/amd/skills/tree/main/skills/lemonade-router-builder) skill - it turns a natural-language description of routing intent into a valid `collection.router` policy JSON.

## License

Apache 2.0, inherited from the base model.

## Citation

Please cite the original model and its underlying work:

```bibtex
@misc{openmed-privacy-filter-v2,
  title  = {OpenMed Privacy Filter Multilingual v2},
  author = {OpenMed},
  year   = {2026},
  url    = {https://huggingface.co/OpenMed/privacy-filter-multilingual-v2}
}
```

See the [original model card](https://huggingface.co/OpenMed/privacy-filter-multilingual-v2) for the complete BibTeX entry crediting OpenMed, OpenAI, and the AI4Privacy contributors.