PiiScan — multilingual PII detection with exact character offsets

For any input text, PiiScan returns every personal-data span it finds, with the exact character offsets into your original string. It ships as a CPU-only INT8 ONNX model (~139 MB with tokenizer) that runs fully offline — no GPU, no network.

The guarantee everything rests on: original_text[entity.start:entity.end] == entity.value.

  • Base model: distilbert-base-multilingual-cased
  • Task: token classification, BIO over 27 labels (13 entity types)
  • Types: 13 model-trained + 11 rule-only = 24 usable; 3 experimental, 12 blocked
  • Languages: 30 (no Indic scripts — see limitations)
  • Runtime: onnxruntime (CPU), ~13 ms / document
  • Offsets: Unicode code points, zero-based, end-exclusive

Results — held-out test set (policy balanced, exact-span match)

Type Precision Recall F1 Support
POSTAL_CODE 0.997 0.995 0.996 2,566
PAYMENT_CARD_NUMBER 0.995 0.993 0.994 1,894
CITY 0.993 0.991 0.992 4,146
PHONE_NUMBER 0.988 0.996 0.992 3,361
EMAIL_ADDRESS 0.987 0.988 0.988 4,400
STREET_ADDRESS 0.989 0.978 0.983 3,909
AGE 0.992 0.962 0.977 2,991
TAX_ID 0.990 0.941 0.965 1,540
DRIVER_LICENSE_NUMBER 0.984 0.940 0.961 1,662
PERSON_NAME 0.976 0.931 0.953 8,810
DATE_TIME 0.980 0.922 0.950 9,592
PASSPORT_NUMBER 0.977 0.917 0.946 1,260
GOVERNMENT_ID 0.977 0.912 0.943 3,439
macro-F1 0.972
micro-F1 0.985 0.954 0.969 49,570

Scored at document level on 8,328 held-out documents, exact [start, end) and type match.

These are in-distribution numbers. The test split comes from the same generator as the training data. On 127 human-annotated real court judgments in the same split, the two types those documents annotate score considerably lower:

Slice Precision Recall F1
Same generator as training 0.990 0.978 0.984
Real human-annotated documents 0.889 0.641 0.745

Expect the lower number on your own data. Validate in your domain before relying on it.

Entity tiers — what is actually supported

Not all types are equal, and this card says so rather than advertising one big number.

Tier Count Types
Model-trained 13 AGE, CITY, DATE_TIME, DRIVER_LICENSE_NUMBER, EMAIL_ADDRESS, GOVERNMENT_ID, PASSPORT_NUMBER, PAYMENT_CARD_NUMBER, PERSON_NAME, PHONE_NUMBER, POSTAL_CODE, STREET_ADDRESS, TAX_ID
Rule-only (checksums/format) 11 AADHAAR_NUMBER, CRYPTO_WALLET, DEVICE_IDENTIFIER, FAX_NUMBER, GEO_COORDINATES, IBAN, IP_ADDRESS, MAC_ADDRESS, SOCIAL_SECURITY_NUMBER, SWIFT_BIC, VEHICLE_IDENTIFIER
Experimental (opt-in) 3 COUNTRY, URL, USERNAME
Blocked (insufficient data) 12 ADDRESS, BANK_ACCOUNT_NUMBER, BIOMETRIC_IDENTIFIER, COUNTY_DISTRICT, DATE_OF_BIRTH, HEALTH_PLAN_ID, LICENSE_PLATE, MEDICAL_RECORD_NUMBER, OTHER_UNIQUE_ID, PROVIDER_ID, STATE_PROVINCE, VOTER_ID

Rule-only types are excluded from the model by design. MOD-97 validates an IBAN exactly; training a network to approximate a checksum would be strictly worse.

Quick start (Python + onnxruntime)

pip install onnxruntime tokenizers huggingface_hub numpy
import json, numpy as np, onnxruntime as ort
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer

REPO   = "vectorsense/piiscan"
sess   = ort.InferenceSession(hf_hub_download(REPO, "model.int8.onnx"),
                              providers=["CPUExecutionProvider"])
tok    = Tokenizer.from_file(hf_hub_download(REPO, "tokenizer.json"))
labels = json.loads(open(hf_hub_download(REPO, "labels.json"), encoding="utf-8").read())

def detect(text: str, threshold: float = 0.75) -> list[dict]:
    enc    = tok.encode(text)
    ids    = np.array([enc.ids], dtype=np.int64)
    mask   = np.array([enc.attention_mask], dtype=np.int64)
    feeds  = {i.name: (mask if "mask" in i.name else ids) for i in sess.get_inputs()}
    logits = sess.run(None, feeds)[0][0]
    e      = np.exp(logits - logits.max(-1, keepdims=True))
    probs  = e / e.sum(-1, keepdims=True)

    spans, current = [], None
    for i, (start, end) in enumerate(enc.offsets):
        if start == end:                       # special token
            continue
        label = labels[int(probs[i].argmax())]
        if label == "O":
            current = None
            continue
        prefix, entity_type = label.split("-", 1)
        if prefix == "B" or current is None or current["type"] != entity_type:
            current = {"type": entity_type, "start": start, "end": end,
                       "scores": [float(probs[i].max())]}
            spans.append(current)
        else:
            current["end"] = end
            current["scores"].append(float(probs[i].max()))

    out = []
    for s in spans:
        score = sum(s["scores"]) / len(s["scores"])
        if score >= threshold:
            out.append({"type": s["type"], "start": s["start"], "end": s["end"],
                        "value": text[s["start"]:s["end"]], "score": round(score, 4)})
    return out

for entity in detect("Patient John Smith was seen on 01/15/2026 at 80331 München"):
    print(entity)
# {'type': 'PERSON_NAME', 'start': 8, 'end': 18, 'value': 'John Smith', 'score': 0.8427}
# {'type': 'DATE_TIME', 'start': 31, 'end': 41, 'value': '01/15/2026', 'score': 0.9948}
# {'type': 'POSTAL_CODE', 'start': 45, 'end': 50, 'value': '80331', 'score': 0.9925}

This snippet is the model only, single window. It does not include the rule layer (IBAN, Aadhaar, payment-card checksums), sliding windows for long documents, or the hybrid resolver. For those, use the package below.

Run it as a REST API (request / response service)

The companion PiiScan repo ships a FastAPI service that wraps this model with the rule layer, policy presets, nested spans, and redaction.

pip install fastapi "uvicorn[standard]" onnxruntime tokenizers numpy pyyaml
uvicorn service.app:app --port 5001      # auto-loads model.int8.onnx from ./models
curl -s http://127.0.0.1:5001/detect_pii \
  -H "Content-Type: application/json" \
  -d '{"texts":["Contact: jsmith@email.com or 555-123-4567"]}'
# PowerShell — send UTF-8 bytes, or non-ASCII input is mangled by Windows PowerShell 5.1
$body  = '{"texts":["Contact: jsmith@email.com or 555-123-4567"]}'
$bytes = [Text.Encoding]::UTF8.GetBytes($body)
Invoke-RestMethod -Uri http://127.0.0.1:5001/detect_pii -Method Post `
  -ContentType 'application/json; charset=utf-8' -Body $bytes

Response fields per entity: entitytype, start, end, value, score, length, subtype, source (rule / model / hybrid), policy_tags, parent_id. Endpoints: GET /, POST /detect_pii, GET /health/ready, GET /metadata, POST /redact.

Policy presets

strict (0.50) / balanced (0.75) / precision (0.90) set the score threshold; override per request with options.score_threshold. Published results use balanced.

Scores are not calibrated probabilities — 0.90 does not mean 90% correct. Rule-layer spans carry a fixed confidence by evidence strength, not a learned score. See thresholds.json.

Training record

Full provenance lives in this repo under training/ (best checkpoint, configs, dataset manifest, source licences, rule-layer benchmark). Trained 3 epochs on 1,299,487 rows, 4 h 20 m on one RTX 2000 Ada.

Limitations & licensing

  • No PHI claim. MEDICAL_RECORD_NUMBER and HEALTH_PLAN_ID are blocked for lack of data. This is not a HIPAA de-identification tool.
  • No Indic support. The corpus has 30 languages but zero Devanagari or Bengali script. Aadhaar and PAN still work inside Indic text because those formats are script-independent.
  • Recall is never 1.0. High-stakes redaction needs human review.
  • Long-form real-world prose is weaker than the headline table. Person names in legal documents are the known worst case; initials and single-letter anonymised parties are frequently missed.
  • Place names can be tagged as people, occasionally with high confidence.
  • CJK has a small span-alignment ceiling from wordpiece merging across entity boundaries.
  • Training data derived from Ai4Privacy OpenPII 1.5M (CC-BY-4.0), © Ai Suisse SA — https://huggingface.co/datasets/ai4privacy/pii-masking-openpii-1.5m Evaluation also uses the Text Anonymization Benchmark (MIT).
  • Only a redistributable subset of the corpus is published — see the dataset card.

Author

Arnab Pal — LinkedIn · GitHub

Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support