PhoneRecognizer: analysis_explanation can report a region that never matched that number
Describe the bug
PhoneRecognizer can explain a detected phone number with the region of a different number in the same
text. analyze() writes the parsed number's region back into the outer region loop variable, so every
later match in that iteration is attributed to it.
To Reproduce
On current main (f251c51), Python 3.11, python-phonenumbers 9.0.34 (the locked version), offline with
the default spaCy engine:
from presidio_analyzer import AnalyzerEngine
text = ("My international number is +44 (20) 7123 4567 "
"and my US one is (415) 555-0132")
for r in AnalyzerEngine().analyze(text, "en", return_decision_process=True):
if r.entity_type == "PHONE_NUMBER":
print(r.start, r.end, r.score, r.analysis_explanation.textual_explanation)prints:
27 45 0.75 Recognized as GB region phone number, using PhoneRecognizer
63 77 0.75 Recognized as GB region phone number, using PhoneRecognizer <-- (415) 555-0132The same two numbers in the opposite order do produce US for the US one, so the explanation depends on
text order rather than on the number.
At recognizer level the claim is not merely coarse, it can be provably false: a recognizer restricted to one region reports a region it never used.
from presidio_analyzer.predefined_recognizers import PhoneRecognizer
PhoneRecognizer(supported_regions=["US"]).analyze(
"+44 20 7946 0958 or (415) 555-0132 now", ["PHONE_NUMBER"])Both results come back as GB, although the recognizer was constructed with supported_regions=["US"], so
no GB matcher ever ran. The mirror case is the more troubling direction for this library:
PhoneRecognizer(supported_regions=["GB"]) on "+1 212 555 0187 or 020 7946 0958" explains the genuine GB
national number as US.
Cause
presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/phone_recognizer.py, in analyze:
for region in self.supported_regions: # outer loop variable
for match in phonenumbers.PhoneNumberMatcher(text, region, leniency=self.leniency):
try:
parsed_number = phonenumbers.parse(text[match.start : match.end])
region = phonenumbers.region_code_for_number(parsed_number) # overwrites it
...
except NumberParseException:
... self._get_recognizer_result(match, text, region, ...) # reads the stale valuephonenumbers.parse(x) without a default region raises NumberParseException for every national-format
number (parse("(415) 555-0132") → (0) Missing or invalid default region.), so the except branch is the
common path, and it reads whatever the last internationally-formatted number left behind.
Expected behavior
Each result's analysis_explanation names a region belonging to that match: the number's own region when it
can be parsed without a default region, otherwise the region whose matcher produced it. Detection itself is
unaffected, so the expected output of the first snippet is:
27 45 0.75 Recognized as GB region phone number, using PhoneRecognizer
63 77 0.75 Recognized as US region phone number, using PhoneRecognizerScope of the change
Only analysis_explanation.textual_explanation differs. Measured over 5 recognizer configurations (default
regions, ["US"], ["GB"], default + JP/CN, and leniency=0) × 20 texts = 165 results: every span,
entity type and score is identical between main and a fixed tree, and 20 of the 165 explanations change.
Checked against an independent oracle — a region is legitimate for a result only if it is either
region_code_for_number(parse(fragment)) or one of the supported regions whose PhoneNumberMatcher actually
produced that span — main fails on 20/165 results and the fixed tree on 0/165.
That string is the documented decision-process output (return_decision_process=True /
log_decision_process=True, see docs/analyzer/decision_process.md), so it is what callers that log or
display the decision process see.
Suggested fix
Derive the region per match instead of writing to the loop variable, falling back to the region that produced
the match when the number cannot be parsed without a default region. That preserves the behaviour #2174 was
about (an internationally-formatted number is explained with its own region) and removes only the cross-match
leak. I have this with a regression test and will open a PR referencing this issue — docs/development.md
asks for an issue first, so this is that step. Reviewers who would rather see a different fallback (e.g.
always the configured region) should say so on the PR.
Additional context
- Related: #2173 / #2174 —
"UK"inDEFAULT_SUPPORTED_REGIONS; merged; same function, different root cause. - #2185 / #2186 — GB regression coverage for the region list (#2186 edits only
presidio-analyzer/tests/test_phone_recognizer.pyandCHANGELOG.md), so it does not reach this attribution path. - #1330 / #1332 —
PhoneRecognizerreporting the wrong recognizer name inanalysis_explanation; treated as a bug and fixed. Same class: an explanation string that states something the code did not do. - Environment: macOS 27 arm64, Python 3.11,
uv sync --locked --all-extras --group devatf251c51,python-phonenumbers9.0.34,en_core_web_lg.
Source: data-privacy-stack/presidio