privacy-gate-llm

1024 weights and a bias. A logistic head on top of BAAI/bge-m3 that answers one question about a piece of text:

Must this stay on this machine?

It catches health information, credentials and personal data written as ordinary English β€” the kind that pattern rules and secret scanners cannot see.

"The woman from Tuesday's clinic, 34, has a 7mm asymmetric lesion on her
 left shoulder and a biopsy booked for the 20th."

No patient_id, no date format, no key prefix. Every regex rule in the ruleset this was built against lets that through. The gate holds it, with a score of +7.23 against a threshold of βˆ’0.45.


Why this exists

If you send prompts to a hosted model from a machine that also holds health data, you want something between the two. The usual answer is a regex ruleset: it catches sk-ant-…, AKIA…, "patient_id": 40219, a base64 image.

Measured on this project's 127-example gold set, that ruleset catches 10 of 72 sensitive examples β€” a 13.9% catch rate. The other 62 are the same information written as prose, and prose is what people actually type.

That gap is the entire reason for this model.

Results

Five-fold cross-validated. Every score comes from a head that never saw that example.

AUC catch friction
regex ruleset alone β€” 13.9% 5.5%
hashed word unigrams (no model) 0.7391 98% 85.5%
Qwen3.5-0.8B, prompted 0.4609 at chance
bge-m3 + logistic head 0.9924 100% 16.4%
  • catch β€” of sensitive texts, the share held back. A miss is an incident.
  • friction β€” of ordinary texts, the share held back. A guard that fires on ordinary work gets switched off, and then it protects nothing.

All 72 sensitive examples caught at 16.4% friction, with zero leaks. On 20 further sentences written after training, 18 of 20 correct with all 11 sensitive ones caught.

It is 8 for 8 on "buried" cases β€” one clinical sentence or one password in the middle of a long support queue, bug report or log excerpt. That is the case regex has no answer to and the one that actually happens.

The negative results are the interesting part

Two findings that cost real time and are worth having for free:

Prompting a small chat model does not work, and looks like it does. Asked for a four-way label, Qwen3.5-0.8B answered HEALTH for 106 of 127 examples and never once said SECRET or PII β€” on a set containing 24 secrets and 19 pieces of personal data. Its 95.8% "catch rate" was a stopped clock; friction was 67.3%. Asked for a simple binary it answered KEEP for all 127: a constant function.

Scored properly on logP(KEEP) βˆ’ logP(SEND) it lands at AUC 0.4609 β€” at chance, and worse than a bag of words. Argmax hides whether a model has signal with a bad default. The margin does not.

Always run a no-model baseline. Hashed unigrams reach 0.7391 here. Without that number, a neural result would have been quoted against the regex ruleset and badly overstated.

Intended use

A second gate, behind whatever deterministic checks you already have. It may only ever add a hold, never clear one:

text ──▢ your existing rules ──hold──▢ keep local
              β”‚ pass
              β–Ό
          this model ──hold──▢ keep local
              β”‚ pass
              β–Ό
        send as you would have

A false negative therefore leaves your existing protection exactly as strong as it already was. The model can only improve on it, never weaken it.

Out of scope β€” please read

A send verdict is not an assurance that text is safe. It is a statistical classifier with a measured miss rate. It is a net, not a proof. Do not use it as the only thing between personal data and a third party.

This is not production-validated. The evidence is 147 examples written by one person in one day. That is enough to choose an architecture. It is not enough to set a threshold that decides what leaves a machine holding real patient data. Before relying on it, measure it on your own traffic.

It is not a medical device and says nothing about what a lesion is. It is a privacy tool.

English only. bge-m3 is multilingual, but every training example is English and no other language was measured.

Usage

The head is a plain JSON file, so you can use it without this repo's code at all.

import json
import numpy as np
from huggingface_hub import hf_hub_download
from sentence_transformers import SentenceTransformer

head = json.load(open(hf_hub_download("YauhenBichel/privacy-gate-llm", "head-v0.json")))
encoder = SentenceTransformer("BAAI/bge-m3")

w = np.array(head["weights"]); mu = np.array(head["mean"]); sd = np.array(head["stdev"])

def decide(text: str):
    # normalize_embeddings=True is required, not cosmetic. See the note below.
    v = encoder.encode([text], normalize_embeddings=True)[0]
    score = float(((v - mu) / sd) @ w + head["bias"])
    return {"hold": score > head["threshold"], "score": round(score, 3)}

decide("her biopsy is booked for the 20th")
# {'hold': True, 'score': 7.2}
decide("reformat this YAML and sort the keys alphabetically")
# {'hold': False, 'score': -7.6}

The maths is ten lines in any language β€” standardise, dot, add bias, compare. docs/INTEGRATION.md has TypeScript and Java versions, an HTTP sidecar, and notes on choosing a threshold.

Two things that will silently give you wrong answers

The embeddings must be L2-normalised. The head was fitted on unit vectors (Ollama's /api/embed normalises; sentence-transformers does not by default). An un-normalised vector does not raise β€” it produces a confidently wrong score, which is the worst failure mode a privacy gate can have. Pass normalize_embeddings=True. The reference implementation refuses any vector whose norm is not 1.

The embeddings must be bge-m3. The head is fitted to that space; another encoder of the same width would also produce confident nonsense rather than an error.

Choosing a threshold

The trade-off is a dial, not a fixed point.

threshold catch friction
βˆ’1.385 100% 16.4%
βˆ’0.455 98.6% 12.7% (shipped default)
βˆ’0.232 97.2% 9.1%

Labels

The gate is binary, but the training labels carry a category, which is useful for an audit line:

label meaning
CLEAN nothing here identifies a person or unlocks anything
HEALTH health information about a real, identifiable person
SECRET a live credential, or enough to derive one
PII personal data about a real, identifiable person

HEALTH rather than PATIENT on purpose: a colleague who is off sick is special-category data under GDPR Article 9 just as a patient is.

The boundary is six written rules, decided before any model saw the data β€” see docs/TAXONOMY.md. The hardest one, and the one this model exists to get right, is that talking about a category is clean: a dateOfBirth column is not a date of birth, and "read the key from ANTHROPIC_API_KEY" is not a key.

Training data

127 hand-written examples: 55 clean, 72 sensitive, weighted towards hard negatives because over-firing is what gets a guard switched off. Plus 20 more written after training as a generalisation check.

Every example is invented. No real person, no real patient, no live credential. Phone numbers come from Ofcom's 07700 900xxx drama range, the AWS key is Amazon's own published documentation placeholder, and the "private key" fixture is base64 of an English sentence saying it is not a key.

The set was audited for a length artefact before any result was quoted β€” medians are 86 against 83 characters, so the classes are not separable by shape. And hashed unigrams reach only 0.7391 on it, failing almost every hard negative, which is what a well-made set looks like: the difficulty sits exactly where the taxonomy says it does.

Limitations

  • 147 examples, one author, one sitting. The single largest weakness.
  • English only.
  • Fitted to bge-m3; not portable to another encoder.
  • Latency is one embedding forward pass per call. On a loaded machine that is not free, and a gate that adds a second to every request gets turned off.
  • The categories come from one person's reading of GDPR, not from a lawyer.

Citation

@software{bichel2026privacygate,
  author = {Bichel, Yauhen},
  title  = {privacy-gate-llm: a 1024-weight head that catches personal data
            written as prose},
  year   = {2026},
  url    = {https://github.com/MoleCare/privacy-gate-llm},
  license = {Apache-2.0}
}

Licence

Apache-2.0. The base encoder, BAAI/bge-m3, is MIT and is neither included nor redistributed here β€” only the head is.

Built for MoleCare. MoleCare is not a medical device and does not diagnose.

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

Model tree for YauhenBichel/privacy-gate-llm

Base model

BAAI/bge-m3
Finetuned
(559)
this model

Space using YauhenBichel/privacy-gate-llm 1

Evaluation results

  • ROC AUC (5-fold cross-validated) on privacy-gate-llm gold set
    self-reported
    0.992