Evaluation & Benchmarking

Measuring PII Detection Accuracy

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 Method

Why "99% Accurate" Means Nothing

Every 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.

Precision, Recall & F1, Defined for Spans

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.

Span Matching Schemes

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.

Exact Span + Type

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.

Overlap (Partial) + Type

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.

Boundary-Only (Type-Agnostic)

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.

Type-Relaxed (Grouped Labels)

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.

Entity-Level vs Token-Level Scoring

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.

Vendor-claim hygiene: when a datasheet says "F1 0.97," ask four questions — which matching rule, which counting level, which dataset, which threshold? If the benchmark set is public, assume models may have seen it in training (benchmark leakage) and re-measure on held-out samples of your own traffic before believing any number. The live demo lets you paste your own hard cases and inspect spans and confidences directly.

Per-Type Breakdown, Micro & Macro Averaging

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.

Building a Labeled Test Set

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.

Sample Your Real Traffic

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.

  • Include messy negatives: order IDs, SKUs, ticket numbers that look like PII
  • Cover every language your traffic contains, in proportion or better
  • Handle the test set itself under the same access controls as production PII

Annotate with Written Rules

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.

  • Adjudicate disagreements and fold the rulings back into the guide
  • Record spans as character offsets against the frozen source text
  • Seed synthetic PII (generated names, valid-checksum test card numbers) to enrich rare types — but score it separately, since synthetic insertions are usually easier than organic mentions

Quarantine Evaluation from Tuning

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.

  • Version test sets and record which set produced every reported score
  • Never average scores across different test-set versions
  • Keep a small "canary" subset stable for long-horizon trend lines

Computing Metrics Against API Output

The API returns everything a scorer needs in one response. A single call, thresholded at 0.7, looks like this:

cURL
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:

Python
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.

Threshold Tuning Tradeoffs

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:

JavaScript (Node)
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)}`
  );
}
Per-type operating points: a common production configuration is threshold 0.4 for SSN, CREDIT_CARD_NUMBER, and other direct identifiers (recall first), 0.7 for PERSON_NAME and ADDRESS in analyst-facing tools (precision first). Two scoped API calls with different entities lists implement this cleanly — request volume, not complexity, is the cost, and volume pricing keeps the second pass cheap.

Continuous Evaluation & Regression Testing

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.

Score Every Change in CI

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.

Sample Production for Drift

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.

Keep an Adversarial Suite

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.

Frequently Asked Questions

What is a "good" F1 score for PII detection?
There is no universal bar — it depends on data difficulty, entity mix, matching rule, and counting level. As rough entity-level orientation on realistic business text: formatted identifiers (emails, card numbers, IBANs) should reach 0.95+, while names and addresses in noisy free text landing between 0.85 and 0.95 is strong performance. More important than the absolute number: it must be measured on your data with a stated methodology, and your high-risk types must clear the recall floor your compliance posture requires.
Why don't published benchmark scores transfer to my data?
Three reasons. Domain shift: benchmarks are mostly clean news or synthetic text, while your traffic is abbreviated, multilingual, and typo-ridden. Label mismatch: the benchmark's entity taxonomy and boundary conventions rarely match yours, so some measured "errors" are definitional. Leakage: widely published test sets tend to end up in training data, inflating scores for every model trained afterwards. The only transferable claim comes from re-running the evaluation on a held-out sample of your own traffic — which the harness on this page does in under a hundred lines.
Entity-level or token-level — which should I report?
Report entity-level exact-match as the primary number: it is the strictest, the hardest to inflate, and maps directly to "pieces of PII missed." Add token-level or overlap-based figures when the downstream action is redaction, because partially masked entities still reduce real exposure and entity-exact scoring gives them zero credit. Never mix levels in one trend line, and always label which scheme a number uses — an unlabeled F1 is unfalsifiable.
How large does my labeled test set need to be?
Size it per entity type, not per corpus: you need enough gold instances of each type for its recall estimate to be stable — 50 instances gives roughly ±10 points of uncertainty at 90% recall, 200+ gets you to a few points. In practice that means 200–500 documents of typical traffic plus deliberate over-sampling of rare types (MRNs, passport numbers) and hard negatives. Grow it opportunistically: every production incident and every audited disagreement is a labeled example you have already paid for.
Should I optimize for precision or recall in a compliance context?
Recall, in almost every redaction or leak-prevention scenario: a false negative is exposed personal data with regulatory consequences, while a false positive is an over-masked token. Set a recall target per high-risk type first (e.g., ≥0.98 for CREDIT_CARD_NUMBER), then maximize precision subject to that constraint by tuning thresholds and exclusions. The exception is human-review tooling, where excessive false positives cause alert fatigue and reviewers start ignoring hits — there, precision protects the process that protects recall.
How often should I re-evaluate a detector that "already works"?
On every configuration change (thresholds, entity selections, custom instructions, pre-processing), on every announced model update from your provider, and on a calendar cadence — monthly production-sample audits and a quarterly full run against the held-out set is a sustainable rhythm. Traffic drift is the silent killer: nothing in your stack changed, but a new market, product surface, or user habit shifted the input distribution. The monthly audit sample exists precisely to catch what the frozen test set cannot.

Measure It on Your Own Data

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