OpenAI Privacy Filter fine-tune for Brazilian-Portuguese PII
This is a fine-tune of OpenAI Privacy Filter (openai/privacy-filter), not a GLiNER model; the repo name gliner-opf-ptbr-pii-v1 is historical. It was trained on 914,452 rows of natural upstream text in a 9-round chunked schedule (3 epochs × 3 chunks) and evaluated on the same 5,000-row PT-BR val set as the GLiNER family. It has the best partial F1 on that val set (0.897) and by far the fewest false positives on text with no PII (64 on 2,419 rows, vs 8,666 for mmBERT-small), but trails the mmBERT-small GLiNER model on held-out sources. Try it in the demo.
Related repos
| Repo | Type | Role |
|---|---|---|
| arthrod/gliner-ettin-32m-ptbr-pii-full-3x-v1 | model | GLiNER on jhu-clsp/ettin-encoder-32m, full ~984k-row train set, 69,000 steps. Smallest model in the family. |
| arthrod/gliner-ettin-68m-ptbr-pii-full-3x-v1 | model | GLiNER on jhu-clsp/ettin-encoder-68m, full ~984k-row train set, 69,000 steps (trainer state uploaded through step 58,650). |
| gliner-ettin-68m-ptbr-pii-full-3x-v1-checkpoints/gliner-ettin-68m-ptbr-pii-top50k-v1 | model | GLiNER on jhu-clsp/ettin-encoder-68m, 50k curated subset, 11,700 steps. Ablation: shows the curated subset under-generalizes. |
| arthrod/gliner-mmbert-small-ptbr-pii-full-3x-v1 | model | GLiNER on jhu-clsp/mmBERT-small, full train set + 2k Ritápolis rows, 69,000 steps. Strongest GLiNER model of the family. |
| arthrod/gliner-opf-ptbr-pii-v1 (this repo) | model | Fine-tune of OpenAI Privacy Filter (not GLiNER despite the name). Best partial F1 on the in-distribution val set and by far the fewest false positives on no-PII text. |
| arthrod/gliner-opf-ptbr-pii-bench-v1 | dataset | Benchmark: predictions and metrics for the mmBERT-small and Privacy Filter models (plus two unreleased ettin variants) on the PT-BR val set, held-out sources and no-PII spam/phishing text. |
| arthrod/gliner-opf-ptbr-pii-demo | space | Gradio demo running the Privacy Filter fine-tune. |
arthrod/gliner_eval_folder (private) |
dataset | Earlier (Feb 2026) sweep: 18 GLiNER models x 9 eval sets. |
| arthrod/gliner_review_comparison | dataset | Earlier GLiNER PII review/comparison data. |
Quick start
Needs the opf package and a GPU. The repo root holds the released checkpoint e3_c3 (final round).
from huggingface_hub import snapshot_download
from opf import OPF
path = snapshot_download("arthrod/gliner-opf-ptbr-pii-v1",
allow_patterns=["config.json", "model.safetensors"])
runtime = OPF(model=path, device="cuda", output_mode="typed", output_text_only=False)
print(runtime.redact("Meu CPF é 123.456.789-09 e meu telefone é (11) 91234-5678."))
Command line (from USAGE.txt):
opf --checkpoint <checkpoint_dir> --device cuda "Alice was born on 1990-01-02."
opf eval /path/to/eval.jsonl --checkpoint <checkpoint_dir> --device cuda
An intermediate round loads the same way with allow_patterns=["checkpoint-e2_c3/*"] and model=f"{path}/checkpoint-e2_c3".
Files and checkpoint layout
| path | content |
|---|---|
config.json, model.safetensors |
released model = round 9, e3_c3 (1.40B parameters, bf16, 2.8 GB) |
finetune_summary.json, USAGE.txt |
opf train summary and usage for the released round |
checkpoint-e1_c1/ … checkpoint-e3_c2/ (8 folders) |
rounds 1–8, each with config.json, model.safetensors, finetune_summary.json, USAGE.txt |
oai_pf_label_space.json |
label space ptbr_pii_v1: O + 72 entity classes (24 canonical PT-BR, 47 cross-source, berco_de_tiradentes) |
comparison_report.md |
head-to-head with the GLiNER models on the shared val set (headline and per-entity partial F1) |
learning_curves.png |
typed F1 per round vs mmBERT-small's partial F1 curve |
training_rounds.parquet |
per-round training/validation metrics extracted from the 9 finetune_summary.json files (see Evaluation) |
Training data
- Train:
arthrod/oai-pf-ptbr-chunked-v2(private) — 914,452 rows, 100% upstream raw text- 99.8% from
ai4privacy/open-pii-masking-500k-ai4privacy - 100% from
ai4privacy/pii-masking-400k - 84.6% from
arthrod/gliner2-pii-ptbr-reward-split(private, synthetic) - 93.4% from
nvidia/Nemotron-PII - 4 small spam/phishing sources at 100% (negative evidence)
- 3 sources dropped entirely (schema mismatches, ~18.6k rows)
- 99.8% from
- Val: same 5,000 PT-BR rows used for the GLiNER models — direct head-to-head comparison
- Chunking: three deterministic chunk files (
oai_pf_train_natural_chunk{1,2,3}.jsonl, 304,816–304,818 rows each, 914,452 total), each seen once per epoch (seetraining_rounds.parquet).
Training recipe
From config.json and the finetune_summary.json files, plus the original run notes:
| setting | value |
|---|---|
| backbone | openai/privacy-filter: 8-layer sparse-MoE transformer, 128 experts, 4 active per token, hidden 640, 14 attention heads (2 KV heads), bidirectional banded attention (128 left / 128 right), vocab 200,064 (o200k_base); 1.40B parameters total (safetensors header) |
| schedule | 3 epochs × 3 chunks = 9 sequential opf train --epochs 1 invocations, each on a deterministic 1/3 chunk, resuming from the previous round's checkpoint |
| optimizer | AdamW, LR 1e-5, weight decay 0.01, max grad norm 1.0 |
| batch | 32 windows × 4 grad-accum = effective 128 |
| context | n-ctx 256 |
| precision | bf16 weights, fp32 accumulators |
| loss | cross-entropy on BIESO token labels (1 + 72 entities × 4 = 289 token labels) |
| decoding | constrained Viterbi |
| hardware | AMD MI300X single-GPU partition, ROCm 7.2; ~22 min per round |
Evaluation
Headline
opf eval --eval-mode typed on the 5,000-row natural PT-BR val set: detection.span typed F1 = 0.885 (P 0.894, R 0.876) for e3_c3.
Shared PT-BR val benchmark vs the GLiNER family (same val, same 24 labels, nervaluate)
Source: comparison_report.md in this repo (which also has the per-entity partial F1 of every model). GLiNER predictions at threshold 0.5, this model with default Viterbi decoding.
| model | partial P | partial R | partial F1 | exact P | exact R | exact F1 |
|---|---|---|---|---|---|---|
| Privacy Filter fine-tune e3_c3 | 0.917 | 0.879 | 0.897 | 0.871 | 0.835 | 0.853 |
| mmBERT-small full-3x (41400) | 0.951 | 0.832 | 0.888 | 0.932 | 0.815 | 0.870 |
| ettin-68m easter-egg (not public) | 0.893 | 0.761 | 0.822 | 0.857 | 0.731 | 0.789 |
| ettin-32m easter-egg (not public) | 0.905 | 0.729 | 0.808 | 0.862 | 0.694 | 0.769 |
The ettin rows are separate easter-egg fine-tunes, not the public ettin repos of the family. opf-base (untyped) scores 0.000 here because its native vocabulary lacks these labels; its untyped span F1 on the val set is 0.537.
On this in-distribution val set this model wins partial F1 by +0.01 and mmBERT wins exact F1 by +0.017, with different strengths:
opf wins on free-text sensitive descriptors (medical +0.16, organizational +0.34, political +0.24, sexual +0.14, religious +0.04, ethnicity +0.03) mmBERT wins on structured PII + names (first/middle/last names by 0.04–0.16, locations by 0.05–0.14, full address) Both perfect on cpf/rg/pis/credit_card/phone/email/zip (F1 ≥ 0.99)
Held-out sources and text with no PII
The val set above is in-distribution. On held-out sources the ranking changes, and on text with no personal data the two families behave very differently (benchmark dataset quality metrics, GLiNER models at threshold 0.5):
| this model | mmBERT-small × 3 | |
|---|---|---|
Partial F1, held-out pt-BR source (gliner2_pii_ptbr_reward_split) |
0.704 | 0.900 |
| Partial F1, mean of the four held-out sources | 0.712 | 0.861 |
| False positives on 2,419 spam and phishing rows with no PII | 64 | 8,666 |
| Share of those rows with at least one false positive | 2.0% | 63.8% |
Prefer this model when false positives are costly (a redaction pass a person then reads); prefer mmBERT-small when recall on names and locations matters more.
Learning curve (typed span F1 on the val set, per round)
From the original run notes; plotted in learning_curves.png.
| ckpt | P | R | F1 |
|---|---|---|---|
| baseline (untyped) | 0.633 | 0.466 | 0.537 |
| e1_c1 | 0.746 | 0.749 | 0.748 |
| e1_c2 | 0.831 | 0.808 | 0.819 |
| e1_c3 (epoch 1) | 0.852 | 0.836 | 0.844 |
| e2_c1 | 0.876 | 0.835 | 0.855 |
| e2_c2 | 0.889 | 0.844 | 0.866 |
| e2_c3 (epoch 2) | 0.895 | 0.851 | 0.872 |
| e3_c1 | 0.896 | 0.860 | 0.878 |
| e3_c2 | 0.880 | 0.880 | 0.880 |
| e3_c3 (final, released) | 0.894 | 0.876 | 0.885 |
Per-round training metrics (training_rounds.parquet)
Validation loss falls monotonically across the 9 rounds; round 9 (e3_c3, repo root) has the lowest validation loss and the highest validation token accuracy. Source: the finetune_summary.json of each round. Load with pd.read_parquet("hf://arthrod/gliner-opf-ptbr-pii-v1/training_rounds.parquet").
| round | checkpoint | location | train chunk | train examples | optimizer steps | train loss | val loss | val token acc |
|---|---|---|---|---|---|---|---|---|
| 1 | e1_c1 | checkpoint-e1_c1/ |
oai_pf_train_natural_chunk1.jsonl |
304,818 | 2,552 | 0.7040 | 0.4212 | 0.8610 |
| 2 | e1_c2 | checkpoint-e1_c2/ |
oai_pf_train_natural_chunk2.jsonl |
304,818 | 2,550 | 0.2679 | 0.2995 | 0.9000 |
| 3 | e1_c3 | checkpoint-e1_c3/ |
oai_pf_train_natural_chunk3.jsonl |
304,816 | 2,549 | 0.2107 | 0.2580 | 0.9116 |
| 4 | e2_c1 | checkpoint-e2_c1/ |
oai_pf_train_natural_chunk1.jsonl |
304,818 | 2,552 | 0.1838 | 0.2382 | 0.9189 |
| 5 | e2_c2 | checkpoint-e2_c2/ |
oai_pf_train_natural_chunk2.jsonl |
304,818 | 2,550 | 0.1663 | 0.2249 | 0.9224 |
| 6 | e2_c3 | checkpoint-e2_c3/ |
oai_pf_train_natural_chunk3.jsonl |
304,816 | 2,549 | 0.1543 | 0.2184 | 0.9244 |
| 7 | e3_c1 | checkpoint-e3_c1/ |
oai_pf_train_natural_chunk1.jsonl |
304,818 | 2,552 | 0.1460 | 0.2058 | 0.9273 |
| 8 | e3_c2 | checkpoint-e3_c2/ |
oai_pf_train_natural_chunk2.jsonl |
304,818 | 2,550 | 0.1394 | 0.2043 | 0.9271 |
| 9 | e3_c3 | repo root | oai_pf_train_natural_chunk3.jsonl |
304,816 | 2,549 | 0.1333 | 0.1971 | 0.9306 |
Schema:
| column | type | description |
|---|---|---|
round |
Int32 | 1–9, order of the sequential opf train --epochs 1 invocations |
checkpoint |
String | checkpoint name, e<epoch>_c<chunk> |
path |
String | folder holding that round's weights (. = repo root) |
train_dataset |
String | chunk file trained on in that round (relative path in the training repo) |
num_train_examples |
Int64 | rows in the chunk |
num_train_windows |
Int64 | 256-token windows after chunking |
optimizer_steps |
Int64 | optimizer steps in the round (batch 32 × grad-accum 4) |
train_tokens |
Int64 | tokens seen in the round |
train_loss |
Float64 | mean training loss |
train_token_accuracy |
Float64 | training token accuracy |
validation_loss |
Float64 | loss on the 5,000-row natural PT-BR val set |
validation_token_accuracy |
Float64 | token accuracy on the val set |
elapsed_s |
Float64 | wall-clock seconds for the round |
Example rows (first and last round):
[
{
"round": 1,
"checkpoint": "e1_c1",
"path": "checkpoint-e1_c1/",
"train_dataset": "data/splits/oai_pf_train_natural_chunk1.jsonl",
"num_train_examples": 304818,
"num_train_windows": 326610,
"optimizer_steps": 2552,
"train_tokens": 27073808,
"train_loss": 0.704006,
"train_token_accuracy": 0.819779,
"validation_loss": 0.421247,
"validation_token_accuracy": 0.861019,
"elapsed_s": 1310.870837
},
{
"round": 9,
"checkpoint": "e3_c3",
"path": ".",
"train_dataset": "data/splits/oai_pf_train_natural_chunk3.jsonl",
"num_train_examples": 304816,
"num_train_windows": 326238,
"optimizer_steps": 2549,
"train_tokens": 26925469,
"train_loss": 0.133315,
"train_token_accuracy": 0.956574,
"validation_loss": 0.197111,
"validation_token_accuracy": 0.930637,
"elapsed_s": 1306.647574
}
]
Per-entity F1 (e3_c3, span-typed, opf eval)
| label | P | R | F1 |
|---|---|---|---|
| cpf_document_number | 0.998 | 1.000 | 0.999 |
| rg_document_number | 0.999 | 0.999 | 0.999 |
| phone_number | 0.997 | 0.999 | 0.998 |
| pis_document_number | 0.999 | 0.996 | 0.997 |
| dob | 0.995 | 0.999 | 0.997 |
| email_address | 0.996 | 0.996 | 0.996 |
| location_zip | 0.990 | 1.000 | 0.995 |
| credit_card | 1.000 | 0.990 | 0.995 |
| location_building_number | 0.967 | 0.972 | 0.969 |
| last_name | 0.949 | 0.965 | 0.957 |
| location_street | 0.926 | 0.954 | 0.940 |
| location_state_abbreviation | 0.921 | 0.861 | 0.890 |
| first_name | 0.881 | 0.874 | 0.878 |
| personal_description_of_ethnicity | 0.847 | 0.842 | 0.844 |
| personal_description_of_religious_convictions | 0.846 | 0.805 | 0.825 |
| personal_description_of_sexual_information | 0.853 | 0.788 | 0.819 |
| personal_description_of_political_opinion | 0.821 | 0.815 | 0.818 |
| personal_description_of_organizational_affiliation | 0.800 | 0.794 | 0.797 |
| personal_description_of_medical_conditions | 0.816 | 0.755 | 0.784 |
| location_state | 0.775 | 0.763 | 0.769 |
| location_neighborhood | 0.792 | 0.636 | 0.705 |
| location_city | 0.666 | 0.591 | 0.626 |
| middle_name | 0.600 | 0.530 | 0.563 |
(Entries with zero gold in val are omitted.)
Comparison across the family
GLiNER holdout protocol (strict F1). This model was not evaluated with the GLiNER 11-source holdout protocol; for reference, the GLiNER models' released checkpoints:
| model | released step | PT-BR | Nemotron (EN) | open-pii-500k | pii-masking-400k | mean of 4 |
|---|---|---|---|---|---|---|
| ettin-32m full-3x | 65550 | 0.6326 | 0.4945 | 0.4081 | 0.4193 | 0.4886 |
| ettin-68m full-3x | 41400 | 0.7076 | 0.6149 | 0.4902 | 0.4701 | 0.5707 |
| gliner-ettin-68m-ptbr-pii-full-3x-v1-checkpoints/gliner-ettin-68m-ptbr-pii-top50k-v1 | 9945 | 0.2732 | 0.0345 | 0.0609 | 0.0755 | 0.1110 |
| mmBERT-small full-3x | 41400 | 0.7678 | 0.8158 | 0.7217 | 0.7076 | 0.7532 |
| Privacy Filter fine-tune | e3_c3 | not evaluated with this protocol |
Limitations
- All evaluation data is synthetic or from public PII datasets; there is no human-annotated real-world test set yet. Do not use it as the sole source of truth for redaction without human review.
- Weaker than mmBERT-small GLiNER on held-out sources (partial F1 0.712 vs 0.861 over four held-out sources) and on names, cities and middle names.
location full addressscores 0 on the val benchmark for every model.- The label
berco_de_tiradentesis in this model's label space but was not trained; use the mmBERT-small model for it. - The training set (
arthrod/oai-pf-ptbr-chunked-v2) is private, so the run cannot be reproduced from public data alone. - Needs the
opfpackage (installed from GitHub) and a GPU; it does not load withtransformersorgliner.
License
Apache-2.0, same as the base model. Training/evaluation sources keep their own licenses (see their dataset cards).