Skip to content

fix(pii): break weighted-score ties by confidence in the NER scanner - #31961

Merged
Khairajani merged 3 commits into
mainfrom
fix/ner-scanner-score-tiebreak
Aug 24, 2026
Merged

fix(pii): break weighted-score ties by confidence in the NER scanner#31961
Khairajani merged 3 commits into
mainfrom
fix/ner-scanner-score-tiebreak

Conversation

@Khairajani

@Khairajani Khairajani commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What

NERScanner.get_highest_score_label now breaks ties on the raw confidence, plus a regression
test for the case below.

     top_entity = max(
         entities_score,
-        key=lambda type_: entities_score[type_].score * entities_score[type_].appearances * 0.8,
+        key=lambda type_: (
+            entities_score[type_].score * entities_score[type_].appearances * 0.8,
+            entities_score[type_].score,
+        ),
     )

Why

Candidates are ranked by score * appearances * 0.8 and the winner is taken with max(), which
on an exact tie returns whichever key was recorded first. The classification therefore depended
on the order the sample rows happened to be scanned in.

A column of dashed US SSNs lands exactly on that tie. The customised registry overrides
UsLicenseRecognizer with patterns.us_driving_license, which contains ^\d{3}-\d{2}-\d{4}$ at
confidence 0.3 — the shape of a dashed SSN — so it matches every row, while US_SSN matches only
a subset (123-45-6789 is rejected as sequential digits, 987-65-4321 reads as US_ITIN):

123-45-6789: US_DRIVER_LICENSE=0.30
987-65-4321: US_ITIN=0.50, US_DRIVER_LICENSE=0.30
543-21-0987: US_SSN=0.50,  US_DRIVER_LICENSE=0.30
678-90-1234: US_SSN=0.50,  US_DRIVER_LICENSE=0.30
876-54-3210: US_SSN=0.50,  US_DRIVER_LICENSE=0.30

US_DRIVER_LICENSE  0.3 * 5 * 0.8 = 1.200
US_SSN             0.5 * 3 * 0.8 = 1.200   <- exact tie
US_ITIN            0.5 * 1 * 0.8 = 0.400

The first row yields only US_DRIVER_LICENSE, so it is recorded first and wins the tie: a weak 0.3
guess outranks a 0.5 match and national IDs get tagged as driving licences.

Reproduced against the real registry with presidio 2.2.358 on both macOS/arm64 and a Linux
container — same result in both, so this is not environmental. With the secondary key,
US_SSN wins.

Scope

This surfaced when NERScanner.__init__ moved from a stock AnalyzerEngine to
build_analyzer_engine() (#31890), which is what brings the customised driving-license patterns
into this scanner. That move looks intentional and is not reverted here — the defect is the
order-dependent tie-break, not the recognizer set. The same commit already made the sibling
ranking in algorithms/utils.py deterministic; this applies the equivalent treatment to
get_highest_score_label.

Where both the weighted total and the confidence are equal, the winner is unchanged, so the two
existing assertions in test_get_highest_score_label still hold.

Note on coverage

test_ner_scanner.py::test_scan_entities asserts only tag_fqn == "PII.Sensitive" for these
samples. US_SSN and US_DRIVER_LICENSE both map to PII.Sensitive, so the flip is invisible to
it — the added assertion checks the entity itself.

Greptile Summary

The PR makes NER entity selection deterministic when weighted totals tie and reduces the confidence of the SSN-shaped driving-license pattern to avoid displacing validated SSNs.

  • Uses raw recognizer confidence as the secondary ranking key.
  • Retains the overlapping driving-license pattern at a weaker confidence.
  • Adds regression coverage for weighted-score ties and masked SSN columns.

Confidence Score: 5/5

The PR appears safe to merge based on the available follow-up-review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
ingestion/src/metadata/pii/algorithms/patterns.py Retains the SSN-shaped US driving-license pattern at confidence 0.05 so it does not displace stronger SSN recognition.
ingestion/src/metadata/pii/scanners/ner_scanner.py Adds raw confidence as a deterministic secondary key when entity weighted totals are equal.
ingestion/tests/unit/pii/test_ner_scanner.py Adds regression tests for confidence-based tie resolution and masked SSN classification.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Analyze sampled column values] --> B[Aggregate confidence and appearances by entity]
  B --> C[Rank by confidence times appearances times 0.8]
  C --> D{Weighted totals tied?}
  D -- No --> E[Select highest weighted total]
  D -- Yes --> F[Select higher raw confidence]
  E --> G[Map entity to PII tag]
  F --> G
Loading

Reviews (3): Last reviewed commit: "test(pii): keep the tie-break comment ac..." | Re-trigger Greptile

get_highest_score_label ranks candidates by "score * appearances * 0.8" and picks
the winner with max(). On an exact tie max() returns whichever key was recorded
first, so the classification depended on the order the sample rows happened to be
scanned in rather than on the evidence.

A column of dashed US SSNs hits that tie. The customised registry's
us_driving_license patterns include ^\d{3}-\d{2}-\d{4}$ at 0.3, which matches every
row, while US_SSN only matches a subset (a sequential-digit sample is rejected and
another reads as US_ITIN):

    US_DRIVER_LICENSE  0.3 * 5 * 0.8 = 1.200
    US_SSN             0.5 * 3 * 0.8 = 1.200

The first row produces only US_DRIVER_LICENSE, so it is recorded first and a weak
0.3 guess outranks a 0.5 match, tagging national IDs as driving licences.

Adding the raw confidence as the secondary key makes the stronger match win and
removes the dependency on insertion order. Where both the weighted total and the
confidence are equal the outcome is unchanged, so the existing expectations hold.
@Khairajani
Khairajani requested a review from a team as a code owner August 24, 2026 11:47
@github-actions

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • No GitHub issue is linked. Link an issue in the Development section of the PR (or add Fixes #12345 to the description). For a same-org cross-repo issue, add Fixes open-metadata/<repo>#123 to the description.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions

Copy link
Copy Markdown
Contributor

Hi there 👋 Thanks for your contribution!

The OpenMetadata team will review the PR shortly! Once it has been labeled as safe to test, the CI workflows
will start executing and we'll be able to make sure everything is working as expected.

Let us know if you need any help!

@Khairajani Khairajani added safe to test Add this label to run secure Github workflows on PRs skip-pr-checks Bypass PR metadata validation check labels Aug 24, 2026
The tie-break only engages when the weighted totals land on the same number. The
SSN-shaped licence pattern matches every dashed 3-2-4 value unconditionally while
US_SSN matches only the subset passing Presidio's validator, so the licence
appearance count is always >= the SSN one. Solving 0.3n > 0.5k puts the crossover at
60%: any column where fewer than 60% of the sampled values validate was labelled a
driving licence, which is ordinary for masked, placeholder or synthetic SSNs.

    ["000-12-3456", "666-45-6789", "111-00-2222", "333-44-0000", "543-21-0987"]
    US_DRIVER_LICENSE  0.3 * 5 * 0.8 = 1.200   <- wins outright, no tie
    US_SSN             0.5 * 1 * 0.8 = 0.400

Deleting the pattern is not the answer: Mississippi prints its nine-digit licence
numbers in Social Security positions, so the shape is a real licence format and
dropping it loses that detection entirely (a column of such values then matches
nothing). Scoring it very weak instead keeps the licence reading available when
nothing else claims the column, while a validated SSN match outranks it.

Measured against build_analyzer_engine() with presidio 2.2.358, before -> after:

    dashed SSN, 3 of 5 validate      US_SSN            -> US_SSN
    masked SSN, 1 of 5 validate      US_DRIVER_LICENSE -> US_SSN
    licence shape, none validate     US_DRIVER_LICENSE -> US_DRIVER_LICENSE
    ordinary licences (A1234567)     US_DRIVER_LICENSE -> US_DRIVER_LICENSE
@Khairajani

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in a8ce9ab — the tie-break alone was not enough.

Reproduced case C against build_analyzer_engine() with presidio 2.2.358:

["000-12-3456", "666-45-6789", "111-00-2222", "333-44-0000", "543-21-0987"]
US_DRIVER_LICENSE  0.3 * 5 * 0.8 = 1.200   <- wins outright, tie-break never runs
US_SSN             0.5 * 1 * 0.8 = 0.400

The 60% crossover is right: the licence pattern matches the shape unconditionally while US_SSN matches only what passes Presidio's validator, so its appearance count is always >= the SSN one.

Deleting the pattern is not the fix. Mississippi prints its nine-digit licence numbers in Social Security positions (###-##-####), unrelated to the SSN itself — so the shape is a genuine licence format, and removing it means a column of such values matches nothing at all. I scored it very weak (0.05) instead of the shared 0.3, which keeps the licence reading available when nothing else claims the column while letting a validated SSN outrank it.

Measured before -> after:

column before after
dashed SSN, 3 of 5 validate US_SSN US_SSN
masked SSN, 1 of 5 validate US_DRIVER_LICENSE US_SSN
licence shape, none validate US_DRIVER_LICENSE US_DRIVER_LICENSE
ordinary licences (A1234567) US_DRIVER_LICENSE US_DRIVER_LICENSE

Both commits stay: the demotion fixes this pair, the tie-break fixes the general case where any two entities land on the same weighted total and the winner fell out of dict insertion order. Added a regression test covering the masked-SSN column.

On co-firing: agreed that Presidio's dedup is same-entity-type only, so both results survive the span. I looked at de-duplicating across entity types by span, but it does not fix case C on its own — on the rows where the SSN validator rejects the value there is no competing result to lose to, so the licence match still accrues an appearance. The score is what has to change.

The 0.3 x 5 illustration is now synthetic rather than something the SSN-shaped
licence pattern still produces, so describe the general shape of the tie instead of
attributing it to that pair.
@Khairajani

Khairajani commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Ran the suites for real now (make install_dev + make generate in a worktree venv), rather than reasoning from the analyzer alone.

OSS unit testsingestion/tests/unit/pii/: 138 passed, including test_classifiers.py, so the score demotion does not disturb the newer classifier path or the labelled sample expectations.

Control — same tests with only ner_scanner.py and patterns.py reverted to main:

FAILED test_get_highest_score_label
  AssertionError: assert ('US_DRIVER_LICENSE', 0.3) == ('US_SSN', 0.5)
FAILED test_masked_ssn_column_is_not_a_driving_licence
  AssertionError: assert 'US_DRIVER_LICENSE' == 'US_SSN'
2 failed, 11 passed

Downstream check — Collate's test_collate_ner_scanner.py from its main, which is where this first surfaced (it is the only test asserting the General.* entity rather than just PII.Sensitive), run against this branch's build:

OSS main (unfixed):  assert {'General.DriverLicense', 'PII.Sensitive'}
                          == {'General.NationalID',   'PII.Sensitive'}   -> 1 failed
this branch:                                                            -> 3 passed, 1 xfailed

@gitar-bot

gitar-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown
Code Review ✅ Approved

Updates the NER scanner to break weighted-score ties using raw confidence, preventing order-dependent classification mismatches between overlapping PII recognizers. No issues found.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

@sonarqubecloud

Copy link
Copy Markdown

@Khairajani
Khairajani enabled auto-merge August 24, 2026 13:32
@Khairajani
Khairajani disabled auto-merge August 24, 2026 13:34
@Khairajani
Khairajani added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 36e00d5 Aug 24, 2026
103 checks passed
@Khairajani
Khairajani deleted the fix/ner-scanner-score-tiebreak branch August 24, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to test Add this label to run secure Github workflows on PRs skip-pr-checks Bypass PR metadata validation check

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants