File size: 11,857 Bytes
3c924ca
 
 
 
 
 
 
 
 
 
dc50d03
3c924ca
 
dc50d03
3c924ca
dc50d03
3c924ca
 
 
 
 
 
 
 
dc50d03
3c924ca
dc50d03
 
 
3c924ca
dc50d03
 
 
a293bea
 
dc50d03
 
 
3c924ca
a293bea
 
3c924ca
dc50d03
3c924ca
dc50d03
 
 
3c924ca
 
 
 
 
dc50d03
3c924ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a293bea
 
3c924ca
 
 
 
 
 
 
 
 
 
 
 
 
dc50d03
 
 
 
 
 
 
 
 
 
 
 
3c924ca
dc50d03
3c924ca
dc50d03
3c924ca
dc50d03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a293bea
 
dc50d03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a293bea
 
dc50d03
 
 
 
 
 
 
a293bea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc50d03
 
 
 
 
 
 
 
 
 
3c924ca
 
 
dc50d03
3c924ca
dc50d03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c924ca
dc50d03
 
 
 
 
a293bea
3c924ca
dc50d03
 
 
 
 
 
 
 
 
 
 
 
 
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
---
license: apache-2.0
base_model: FacebookAI/xlm-roberta-base
library_name: transformers
pipeline_tag: token-classification
language:
- en
- zh
tags:
- pii
- pii-detection
- de-identification
- anonymization
- redaction
- legal
- ner
datasets:
- nvidia/Nemotron-PII
- gravitee-io/pii-detection-dataset
- gretelai/synthetic_pii_finance_multilingual
- xusenlin/clue-ner
- ildpil/text-anonymization-benchmark
---

# veil-detect

**veil-detect** finds personal information in text: names, organizations, places, contact details and usernames,
ID and case numbers, financial and credential strings, dates and dates of birth. It is the span detector from
*veil*, the de-identification pipeline we use at Libra OS, released on its own under Apache-2.0.

- **Built for recall.** It proposes everything that might need masking. On three blind samples of US court opinions it
  misses 3–5% of mentions, against 21–24% for the strongest open alternative.
- **Small and fast.** 278M parameters (xlm-roberta-base), 1.9 GiB of GPU memory, about ten times the throughput of
  the GLiNER- and LLM-based detectors we compared it with. It also runs on CPU alone: about 0.6 s per 1,000
  characters on 2 cores, in 1.6 GB of RAM.
- **English and Chinese**, including mainland formats (身份证, mobile numbers, 统一社会信用代码, 案号).
- **Clean training lineage.** Permissively licensed datasets and our own template generators only. No user or chat
  logs, and no output of a third-party language model was used to label anything.

Version **1.0.0** (2026-10-21; card revised 2026-10-23, weights unchanged). Use the `v1.0.0` tag to pin the weights:
`from_pretrained("libraos/veil-detect", revision="v1.0.0")`.

## Quick start

The model reads documents in 384-token windows, the way it was trained. The helper below handles documents of any
length and returns character spans. The `transformers` token-classification pipeline is not recommended: with this
tokenizer its aggregation modes split or merge spans wrongly.

```python
import torch
from transformers import AutoTokenizer, AutoModelForTokenClassification

REPO = "libraos/veil-detect"
tok = AutoTokenizer.from_pretrained(REPO)
model = AutoModelForTokenClassification.from_pretrained(REPO).eval()


def detect(text, window=384, stride=64):
    """Character spans [{start, end, label, text}] for one document of any length."""
    enc = tok(text, add_special_tokens=False, return_offsets_mapping=True, verbose=False)
    ids, offs = enc["input_ids"], enc["offset_mapping"]
    body = window - 2; tag_at = {}
    for w0 in range(0, max(1, len(ids) - stride), body - stride):
        x = torch.tensor([[tok.cls_token_id] + ids[w0:w0 + body] + [tok.sep_token_id]])
        with torch.no_grad():
            pred = model(input_ids=x).logits[0, 1:-1].argmax(-1).tolist()
        for (a, b), p in zip(offs[w0:w0 + body], pred):
            if a < b: tag_at[(a, b)] = model.config.id2label[p]
    spans = []
    for (a, b), tag in sorted(tag_at.items()):
        if tag == "O": continue
        label = tag.split("-", 1)[1]
        prev = spans[-1] if spans else None
        # continue the previous span when the label matches and only spaces separate them (a line break ends a span)
        if prev and prev["label"] == label and not text[prev["end"]:a].strip() and "\n" not in text[prev["end"]:a]:
            prev["end"] = b
        else:
            spans.append({"start": a, "end": b, "label": label})
    for s in spans: s["text"] = text[s["start"]:s["end"]]
    return spans


email = ("Hi team,\n\nMaria Gonzalez from Brightline Hosting called about the lease at 12 Baker Street, London. "
         "She can be reached at +1 415 555 0132 or maria.gonzalez@example.com.\n\nThanks,\nOmar")
for span in detect(email):
    print(span["label"], span["text"])
```

```
person Maria Gonzalez
organization Brightline Hosting
location 12 Baker Street
location London
contact +1 415 555 0132
contact maria.gonzalez@example.com
person Omar
```

Masking is a separate decision. The model tags judges, courts, public bodies and well-known names as readily as
private ones, by design. Mask everything it returns for maximum privacy, or put your own policy or review step after it.

## Labels

| label | covers | examples |
|---|---|---|
| `person` | names, including surname-only and initials | Maria Gonzalez, Mr Kowalski, A.L.Y., 张伟 |
| `organization` | companies, firms, courts, agencies, schools | Brightline Hosting, Fla. 4th DCA |
| `location` | addresses, streets, cities, regions | 12 Baker Street, 杭州市西湖区文三路100号 |
| `contact` | e-mail, phone, URL, usernames and handles | +1 415 555 0132, kai_rivers92, wxid_maple199 |
| `id` | national IDs, passports, account, docket and application numbers | 110101199003071234, No. 4D17-248, 12345/09 |
| `financial` | card numbers, IBANs, bank accounts | DE89 3704 0044 0532 0130 00 |
| `credential` | passwords, API keys, PINs, card security codes, recovery phrases | |
| `date` | dates and times | 3 May 2009, 2024-08-06 07:27 |
| `date_of_birth` | dates that are a person's birth date | born 14 March 1987 |
| `demographic` | occupation, nationality, age, gender, religion, health conditions and similar attributes | Senior Manager, aged 42 |

Tags are BIO over these ten labels (21 classes with `O`).

## Evaluation

### Blind legal samples

![Share of personal-data mentions missed on three blind samples of US court opinions: veil-detect 4.1%, nvidia/gliner-PII 22.3%, OpenMed nemotron 41.4%, openai/privacy-filter 50.3%](https://huggingface.co/libraos/veil-detect/resolve/main/assets/blind-miss-rate.png)

Three samples of US court opinions that no training source contains, from the Caselaw Access Project. Gold is the
mentions two independent annotators agreed on. *Miss rate* counts a gold mention as found when any predicted span
overlaps it. *Must-mask recall* is the same over the mentions that identify someone directly or indirectly. veil-detect
was scored together with veil's rule layer for exact formats, as it runs in our pipeline (the rules are not part of
this release). The other detectors were scored as published.

| detector | miss rate, samples 1 / 2 / 3 | must-mask recall, samples 1 / 2 / 3 |
|---|---|---|
| **veil-detect 1.0** | **4.9% / 3.9% / 3.4%** | **0.949 / 0.955 / 0.972** |
| nvidia/gliner-PII | 21.2% / 24.2% / 21.4% | 0.844 / 0.841 / 0.832 |
| OpenMed nemotron | 42.5% / 43.5% / 38.1% | 0.701 / 0.686 / 0.703 |
| openai/privacy-filter | 55.2% / 56.3% / 39.3% | 0.618 / 0.575 / 0.715 |

Sample 1 is 1973 federal appeals (1,865 mentions), sample 2 is 1979–80 federal appeals (1,694) and sample 3 is
2017–18 state courts (1,842).

### Mixed corpus

81,864 gold mentions from the test splits of Nemotron-PII, gravitee, Gretel finance, pii-bench-zh and TAB. The model
was trained on the train splits of several of these, so read this as an in-distribution figure, not a blind one.

| | all | person | contact | address | numbers | TAB (ECHR) |
|---|---:|---:|---:|---:|---:|---:|
| miss rate | 3.2% | 6.4% | 0.4% | 2.5% | 1.0% | 2.1% |

Throughput on the same corpus, one RTX 3080 (veil with its rule layer):

![Throughput in MB/s and GPU memory in GiB on one RTX 3080: veil-detect 0.24 MB/s and 1.9 GiB; nvidia/gliner-PII 0.019 and 6.8; OpenMed nemotron 0.024 and 8.8; openai/privacy-filter 0.015 and 8.8](https://huggingface.co/libraos/veil-detect/resolve/main/assets/throughput-memory.png)

| detector | MB/s | GPU memory |
|---|---:|---:|
| **veil-detect** | **0.24** | **1.9 GiB** |
| nvidia/gliner-PII | 0.019 | 6.8 GiB |
| OpenMed nemotron | 0.024 | 8.8 GiB |
| openai/privacy-filter | 0.015 | 8.8 GiB |

### Running on CPU

No GPU is needed. Measured with the same 384-token windows as `detect()` above (batched, with veil's rule layer), fp32
PyTorch, one AMD EPYC 7402 and a 7,900-character bilingual contract, one document at a time:

| CPU cores | seconds per 1,000 characters | peak memory |
|---:|---:|---:|
| 1 | 1.07 | 1.6 GB |
| 2 | 0.57 | 1.6 GB |
| 4 | 0.42 | 1.6 GB |

A typical 1,500-character contract takes about 0.9 s on 2 cores. Set `torch.set_num_threads()` to the cores the process
is given; otherwise PyTorch sizes its thread pool to the whole machine. We do not recommend dynamic int8 quantization of
the linear layers: it cut CPU time by about a quarter, but on our contract test set the share of direct identifiers
found fell from 0.94 to 0.71.

## Intended use

- First stage of a redaction or pseudonymization pipeline over documents, support tickets, chat and logs, before text
  is stored, shared or sent to a third-party model.
- Finding personal data in a corpus for review, deletion requests or data mapping.

Not intended for:
- **Guaranteed anonymization on its own.** A 3–5% miss rate means a long document will usually contain at least one
  missed mention. For legal or regulated release, keep a human review step.
- Decisions about the people it finds. It labels spans of text; it does not identify anyone.

## Limitations

- **Give it context.** Fed one sentence at a time instead of whole documents, recall on a 1,287-mention sample falls
  from 0.95 to 0.88, most of it on phone numbers, e-mails and case numbers. Pass paragraphs or whole documents.
- **Usernames in fiction or roleplay** are found less reliably than in chat, support or forum text (0.94 recall on a
  synthetic set of out-of-character inserts).
- **Addresses in older legal prose** are the weakest class on the blind samples (7–11% missed).
- **No label for quantities or miscellaneous identifiers** (TAB's QUANTITY / MISC categories).
- **Languages.** English and Chinese are evaluated. The finance data adds French, German, Dutch, Spanish and Italian, which
  are not separately measured. Other languages rely on the base model alone.
- **Over-tagging is expected.** Judges, counsel, courts and public figures are tagged as people and organizations.

## Training

Fine-tuned from `FacebookAI/xlm-roberta-base` in stages: general PII and Chinese data from scratch, then legal text,
then a `date_of_birth` label, then the final mix (83,337 documents in 100,603 windows of 384 tokens, 8,000 steps,
batch 16, learning rate 1.5e-5, 19 minutes on one RTX 3080). Long documents are cut into overlapping windows by hand.
Upper-case augmentation (p = 0.3) covers captions like "Peter J. BRENNAN, Secretary of Labor".

| source | licence | used for |
|---|---|---|
| nvidia/Nemotron-PII | CC-BY-4.0 | English PII across industries |
| gravitee-io/pii-detection-dataset | Apache-2.0 | English PII, harmonised from several public sets |
| gretelai/synthetic_pii_finance_multilingual | Apache-2.0 | finance documents in six languages |
| CLUENER2020 (xusenlin/clue-ner) | Apache-2.0 | Chinese names, places, organizations |
| Text Anonymization Benchmark, train split | MIT | ECHR judgments |
| template generators | written for this model | Chinese forms and legal documents, US court opinions and dockets, exchange and wallet text, structured birth-date records, usernames in chat and support text, ECHR application numbers |

Blind test documents, the pii-bench-zh benchmark and every test split were excluded from training.

## Versions

| version | date | notes |
|---|---|---|
| 1.0.0 | 2026-10-21 | first public release |
| 1.0.0, card revision | 2026-10-23 | comparison charts, CPU figures; `detect()` no longer joins spans across a line break (a signature line and the role line under it came out as one name). Weights unchanged. |

## Licence and citation

Apache-2.0. The NOTICE file carries the attribution that Nemotron-PII's CC-BY-4.0 licence requires.

```bibtex
@misc{veil_detect_2026,
  title  = {veil-detect: a recall-first PII span detector},
  author = {{Libra OS}},
  year   = {2026},
  url    = {https://huggingface.co/libraos/veil-detect},
  note   = {Version 1.0.0}
}
```