Precision, recall, and F1 mean nothing until you define how spans are matched and counted. Learn to build a labeled test set, score entity-level and token-level results correctly, and tune thresholds for the risk profile you actually have.
Learn the MethodEvery PII detection vendor publishes an accuracy number, and almost none of those numbers can be compared to each other. The reason is not dishonesty — it is that "accuracy" for span detection is undefined until you answer four questions. What counts as a correct detection: an exact span match, any overlap, or a match ignoring entity type? What unit are you counting: entities, tokens, or characters? What data were you measured on: news text, medical notes, chat logs? And at what confidence threshold was the system run? Change any one answer and the same system's score can swing by ten points or more.
This matters commercially and legally. If you promise a regulator that redaction is applied before data leaves the EU, the operative question is not the vendor's benchmark — it is your recall, on your data, for the entity types your obligations cover. A system with a stellar F1 on English news headlines can miss half the person names in Portuguese customer chats. Evaluation methodology is how you convert a marketing claim into an engineering fact.
The classical "accuracy" metric — correct predictions divided by all predictions — is actively misleading for PII. Sensitive entities are rare relative to the volume of surrounding text: in a corpus where 2% of tokens are PII, a detector that flags nothing at all is "98% accurate" while being completely useless. That is why the field measures precision, recall, and their harmonic mean F1 instead, and why this guide spends most of its time on how those three numbers are actually computed for spans.
Everything below applies to any detector — regex, rules, or transformer NER — and every code example evaluates real output from the PII Detection API, whose responses return exactly what span scoring needs: the entity type, matched text, start/end character offsets, and a confidence score you can threshold.
Span evaluation compares two sets over the same text: the gold spans your annotators marked (each a triple of entity type, start offset, end offset) and the predicted spans the detector returned. A true positive (TP) is a predicted span that matches a gold span under whatever matching rule you have chosen. A false positive (FP) is a predicted span that matches no gold span — the detector cried wolf. A false negative (FN) is a gold span no prediction matched — a leak. Each gold span may be matched by at most one prediction and vice versa, so duplicated predictions over the same gold span count as extra FPs.
Precision = TP / (TP + FP): of everything the detector flagged, how much was really PII. Low precision means over-redaction — masked order numbers, unusable analytics, reviewers drowning in noise. Recall = TP / (TP + FN): of the PII that exists, how much was found. Low recall means leaked data, which for compliance purposes is usually the expensive direction. F1 is the harmonic mean of the two, 2PR/(P+R); the harmonic mean punishes imbalance, so a detector cannot buy a good F1 by maximizing one side and abandoning the other.
Notice what is missing: true negatives. For classification over fixed items ("is this email spam?") every item is either positive or negative, so TN is well-defined. For spans, the "negatives" are all substrings the detector correctly did not flag — a combinatorially huge, meaningless set. This is why span detection reports precision/recall/F1 rather than accuracy or specificity, and why ROC curves (which need TN) are replaced by precision-recall curves in this domain.
One subtlety worth fixing in your harness from day one: offsets must be computed over the exact string you sent. Normalizing whitespace, stripping HTML, or re-encoding Unicode between annotation and scoring silently shifts offsets and manufactures phantom boundary errors. Store gold annotations against a frozen copy of each test string and never transform it again.
Before a prediction can be a TP, you must define "matches." Four rules cover the practical spectrum, from strictest to most permissive — pick one deliberately and state it next to every number you report.
TP only when start, end, and entity type all agree with a gold span. The strictest and least ambiguous rule, standard in academic NER (CoNLL-style). It treats a boundary slip of one character as a double error — one FP plus one FN — which is harsh but makes scores maximally comparable across systems and over time. Use it as your headline regression metric.
TP when the predicted span overlaps any gold span of the same type by at least one character (or ≥50% of characters, if you prefer a stricter variant). This is the right rule when the downstream action is redaction: masking "John Smith" out of "Dr. John Smith" still removes the name, so counting it as a total miss misstates real-world risk.
Match on offsets while ignoring the predicted label. Isolates localization quality from classification quality: a system that finds every sensitive span but labels some SSNs as national IDs scores perfectly here. Useful for diagnosis, and defensible for redaction pipelines where every detected span is masked the same way regardless of label.
Collapse related labels into families before matching — PERSON_NAME variants together, all address components (ADDRESS, CITY, ZIP_CODE) together, all payment identifiers together. Sensible when your policy treats a family identically (mask all location data), and it prevents taxonomy mismatches between your annotation guide and the detector's label set from masquerading as detection errors.
Independently of the matching rule, you must choose what to count. The choice changes the numbers — sometimes dramatically — because it changes how partial credit and long entities are weighted.
Entity-level scoring counts whole spans: each gold entity is found or missed, each prediction is right or wrong. It answers the operational question "how many pieces of PII does this system miss?", weights a two-word name the same as a ten-word address, and is the level at which compliance risk is naturally expressed. Its weakness is brittleness at boundaries: under exact matching, predicting John Smith where gold says Dr. John Smith scores as one FP and one FN, even though 71% of the sensitive characters were caught.
Token-level scoring (or its finer cousin, character-level scoring) counts each token/character of each span as its own decision. The same boundary error now scores two token TPs (John, Smith) and one token FN (Dr.) — partial credit that better reflects how much sensitive material was actually neutralized. The cost: long entities dominate the average (one missed postal address outweighs three missed surnames), and token scores look flattering next to entity scores computed on identical output, which is exactly how misleading vendor comparisons get made.
The worked example below scores one sentence both ways. Gold annotations for the text "Contact Dr. John Smith, DOB 04/12/1985, at extension 88231 in Springfield." contain three entities; the detector returned three predictions, one with clipped boundaries and one spurious.
| Gold Span | Predicted Span (confidence) | Entity-Level, Exact + Type | Token-Level, + Type |
|---|---|---|---|
PERSON_NAME "Dr. John Smith" (8–22) |
PERSON_NAME "John Smith" (12–22) — 0.94 |
1 FP + 1 FN (boundary mismatch) | 2 token TP ("John", "Smith"), 1 token FN ("Dr.") |
DATE_OF_BIRTH "04/12/1985" (28–38) |
DATE_OF_BIRTH "04/12/1985" (28–38) — 0.97 |
1 TP | 1 token TP |
| — (no gold span) | PHONE_NUMBER "88231" (53–58) — 0.58 |
1 FP (extension misread as phone) | 1 token FP |
CITY "Springfield" (62–73) |
— (no prediction) | 1 FN | 1 token FN |
| Resulting scores | P = 1/3 ≈ 0.33, R = 1/3 ≈ 0.33, F1 ≈ 0.33 | P = 3/4 = 0.75, R = 3/5 = 0.60, F1 ≈ 0.67 | |
Same output, F1 of 0.33 versus 0.67 — a factor of two, produced purely by the counting convention. Neither number is wrong; they answer different questions. A defensible reporting practice is to publish entity-level exact + type as the primary metric (it is the hardest to game and easiest to reproduce), with token-level or overlap-based figures alongside when the downstream action is redaction and partial masking has real value. Whatever you choose, keep it fixed across evaluation runs, or your trend lines measure your methodology instead of your detector.
A single overall F1 hides exactly the failures you most need to see. Detectors are never uniformly good: formatted identifiers like email addresses and IBANs typically score in the high 0.9s, while person names in noisy chat text, addresses split across lines, and rare types like MEDICAL_RECORD_NUMBER lag far behind. Always compute precision, recall, and F1 per entity type first, and treat the aggregate as a derived summary.
There are two standard ways to aggregate. Micro-averaging pools all TP/FP/FN counts across types before computing the metrics. It weights every entity occurrence equally, so frequent types dominate: a corpus that is 70% email addresses will report a micro-F1 that mostly measures email detection. Macro-averaging computes F1 per type and then takes the unweighted mean, giving your ten SSN test cases the same voice as your ten thousand emails. Micro answers "what fraction of PII instances do we handle correctly?"; macro answers "how good are we across the range of things we claim to detect?" Report both — they diverge precisely when something interesting is wrong.
Per-type breakdowns also drive action in a way aggregates cannot. If recall for CREDIT_CARD_NUMBER is 0.99 but PERSON_NAME sits at 0.81, the fix is not "improve the model" — it is inspecting the 19% of missed names, which usually cluster into diagnosable groups: nicknames, transliterated names, names adjacent to job titles, or a language your test set under-represents. Error clusters, not scores, are what you can actually engineer against.
Finally, keep confusion pairs in view: a span found with the wrong label (NATIONAL_ID predicted where gold says SSN) is invisible in per-type F1 except as symmetrical FP/FN noise. Tracking a small type-confusion matrix alongside per-type scores tells you whether errors are detection failures or taxonomy disagreements — and the latter can often be fixed with the API's entities selection or label mapping on your side, at zero model cost.
Your metrics are only as good as your gold labels. A useful PII test set is representative, consistently annotated, and quarantined from tuning. Three disciplines get you there.
Draw test texts from the actual channels you will scan — support tickets, chat transcripts, free-text form fields — stratified by source, language, and length. Public NER corpora (news, Wikipedia) are stylistically nothing like "cust called abt refund, cb 555-0193 after 6"; a model's rank order on them rarely transfers. Two to three hundred documents per channel is a workable start, provided rare-but-critical types are deliberately over-sampled.
Most "model errors" on a first evaluation turn out to be annotation inconsistencies. Write a guideline that settles the boundary questions before labeling starts: are honorifics ("Dr.", "Mrs.") inside the name span? Is "the patient's daughter" a person reference? Does a partial address ("Springfield office") count? Have two annotators label an overlapping subset and measure inter-annotator agreement (F1 of one against the other); if humans agree at 0.90, that is the practical ceiling for any detector score on that data.
Split labeled data into a tuning set you may look at freely — for choosing thresholds, writing custom_instruction exclusions, debugging misses — and a held-out evaluation set you score against but never inspect case-by-case. Every time you adjust a knob to fix an example you saw, that example stops measuring generalization. When the held-out set has been reused across many decisions, retire it into tuning and label a fresh one.
The API returns everything a scorer needs in one response. A single call, thresholded at 0.7, looks like this:
curl -X POST https://piidetectionapi.com/api/moderate.php \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_KEY",
"api_type": "pii_detection",
"text": "Contact Dr. John Smith, DOB 04/12/1985, at extension 88231 in Springfield.",
"threshold": 0.7
}'
The Python harness below runs a gold-labeled dataset through the API, matches predictions to gold spans under the exact-span-plus-type rule, and prints per-type and micro-averaged precision, recall, and F1. It is deliberately dependency-free beyond requests so it can drop into any CI job:
import requests, collections
API_URL = "https://piidetectionapi.com/api/moderate.php"
API_KEY = "YOUR_API_KEY"
# Gold data: frozen text + annotated spans (type, start, end)
GOLD = [
{
"text": "Contact Dr. John Smith, DOB 04/12/1985, at extension 88231 in Springfield.",
"spans": [
{"type": "PERSON_NAME", "start": 8, "end": 22},
{"type": "DATE_OF_BIRTH", "start": 28, "end": 38},
{"type": "CITY", "start": 62, "end": 73},
],
},
{
"text": "Refund to card 4111 1111 1111 1111, holder [email protected].",
"spans": [
{"type": "CREDIT_CARD_NUMBER", "start": 15, "end": 34},
{"type": "EMAIL_ADDRESS", "start": 43, "end": 66},
],
},
]
def detect(text, threshold=0.5):
resp = requests.post(API_URL, json={
"api_key": API_KEY,
"api_type": "pii_detection",
"text": text,
"threshold": threshold,
}, timeout=30)
resp.raise_for_status()
return resp.json()["detected_entities"]
tp = collections.Counter(); fp = collections.Counter(); fn = collections.Counter()
for doc in GOLD:
preds = detect(doc["text"])
gold = {(s["type"], s["start"], s["end"]) for s in doc["spans"]}
matched = set()
for p in preds:
key = (p["type"], p["start"], p["end"])
if key in gold and key not in matched:
tp[p["type"]] += 1
matched.add(key)
else:
fp[p["type"]] += 1
for g in gold - matched:
fn[g[0]] += 1
def prf(t, f_p, f_n):
p = t / (t + f_p) if t + f_p else 0.0
r = t / (t + f_n) if t + f_n else 0.0
f1 = 2 * p * r / (p + r) if p + r else 0.0
return p, r, f1
print(f"{'TYPE':<22}{'P':>7}{'R':>7}{'F1':>7}")
for t in sorted(set(tp) | set(fp) | set(fn)):
p, r, f1 = prf(tp[t], fp[t], fn[t])
print(f"{t:<22}{p:>7.2f}{r:>7.2f}{f1:>7.2f}")
p, r, f1 = prf(sum(tp.values()), sum(fp.values()), sum(fn.values()))
print(f"{'MICRO AVG':<22}{p:>7.2f}{r:>7.2f}{f1:>7.2f}")
Swapping in overlap matching is a five-line change (compare ranges instead of set membership); adding token-level scoring means expanding each span into token offsets before counting. Keep all three in the same harness so any number you quote carries its methodology with it.
Every detection arrives with a confidence score, and the threshold parameter decides which detections survive. Raising it trades recall for precision; lowering it trades precision for recall. The full picture is the precision-recall curve you get by sweeping the threshold across your tuning set — and the right operating point depends entirely on the cost asymmetry of your use case, not on maximizing F1.
For compliance-driven redaction, a false negative is leaked personal data — potentially a notifiable incident — while a false positive is an over-masked token someone can un-redact on request. That asymmetry argues for low thresholds (0.3–0.5) and accepting precision in the 0.8s. For analytics enrichment or alerting pipelines where humans review every hit, false positives burn reviewer time and erode trust, so thresholds of 0.7–0.8 are more sensible. If both matter, run two passes: auto-redact above a high threshold, queue the band between low and high for human review.
Thresholds should also differ per entity type, because confidence distributions differ per type: checksummed identifiers (card numbers, IBANs) are confidently right or absent, while names and addresses occupy the murky middle. A practical pattern is one low-threshold request for high-risk types via the entities parameter, and a second, stricter pass for the rest. The Node script below produces the raw material for these decisions — precision and recall at each threshold step:
const GOLD = [
{
text: "Contact Dr. John Smith, DOB 04/12/1985, at extension 88231 in Springfield.",
spans: [
{ type: "PERSON_NAME", start: 8, end: 22 },
{ type: "DATE_OF_BIRTH", start: 28, end: 38 },
{ type: "CITY", start: 62, end: 73 },
],
},
{
text: "Refund to card 4111 1111 1111 1111, holder [email protected].",
spans: [
{ type: "CREDIT_CARD_NUMBER", start: 15, end: 34 },
{ type: "EMAIL_ADDRESS", start: 43, end: 66 },
],
},
];
async function detect(text, threshold) {
const resp = await fetch("https://piidetectionapi.com/api/moderate.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: process.env.PII_API_KEY,
api_type: "pii_detection",
text,
threshold,
}),
});
return (await resp.json()).detected_entities;
}
function score(preds, gold) {
const goldKeys = new Set(gold.map(s => `${s.type}:${s.start}:${s.end}`));
const matched = new Set();
let tp = 0, fpCount = 0;
for (const p of preds) {
const key = `${p.type}:${p.start}:${p.end}`;
if (goldKeys.has(key) && !matched.has(key)) { tp++; matched.add(key); }
else fpCount++;
}
return { tp, fp: fpCount, fn: goldKeys.size - matched.size };
}
for (let t = 0.3; t <= 0.9; t += 0.1) {
let tp = 0, fp = 0, fn = 0;
for (const doc of GOLD) {
const s = score(await detect(doc.text, t), doc.spans);
tp += s.tp; fp += s.fp; fn += s.fn;
}
const p = tp / (tp + fp) || 0;
const r = tp / (tp + fn) || 0;
console.log(
`threshold=${t.toFixed(1)} precision=${p.toFixed(2)} recall=${r.toFixed(2)}`
);
}
entities lists implement this cleanly — request volume, not complexity, is the cost, and volume pricing keeps the second pass cheap.Accuracy is not a one-time certification. Your traffic drifts, your annotation standards mature, and detector models improve underneath you. Treat evaluation like any other test suite.
Run the evaluation harness whenever anything in the detection path changes — threshold values, entities selections, custom_instruction text, pre-processing, or an announced model update. Gate deployment on per-type recall floors for your high-risk types, not on aggregate F1, so a two-point gain on emails can never mask a five-point recall loss on SSNs. Persist every run's per-type numbers so regressions are attributable to a specific change.
Each month, pull a small random sample of live traffic, have a reviewer audit the detector's output on it, and fold corrected labels into the next test-set version. This catches distribution drift — a new market's phone formats, a new product surface's slang — long before an incident does. Track the audit's disagreement rate over time; a rising curve is your earliest signal that the frozen test set no longer represents reality.
Maintain a curated set of known hard cases: obfuscated emails ("john dot doe at example dot com"), digit-spelled phone numbers, names inside email addresses, valid-checksum card numbers embedded in URLs, look-alike negatives such as order and tracking numbers. Every production miss and every false-positive complaint becomes a new case. This suite measures robustness at the margins, where compliance incidents actually happen — aggregate metrics on typical traffic never will.
Paste your hardest examples into the demo, inspect spans and confidence scores, then wire the evaluation harness from this guide into your CI. Numbers you computed yourself beat any datasheet.
Try the Live Demo View Pricing