Skip to content

6 Interactive Entity Mapping

Interactive Entity Mapping for PII Evaluation

This notebook is a comprehensive tutorial on CanonicalMapper — the single-phase (Identify-only) entity alignment tool for PII evaluation.

Topics covered: 1. Why entity mapping is needed 2. Single-phase workflow: Identify (5 tiers) 3. All five issue types with concrete examples 4. min_severity: surfacing INFO-level same-branch mismatches 5. Programmatic resolution with map() 6. Interactive resolution with resolve_interactively() 7. Multi-model comparison 8. EntityHierarchy customisation 9. Getting the MappedResults object and using hierarchical scoring

from collections import Counter
from pathlib import Path
from pprint import pprint

from presidio_analyzer import AnalyzerEngine, PatternRecognizer

from presidio_evaluator import InputSample
from presidio_evaluator.entity_mapping import (
    CanonicalMapper,
    EntityHierarchy,
    IncompleteMapping,
    IssueType,
    IssueSeverity,
)
from presidio_evaluator.evaluation import SpanEvaluator
from presidio_evaluator.models import PresidioAnalyzerWrapper

1. Why Entity Mapping is Needed

When evaluating a PII detection model, you are comparing: - Dataset entities: ground truth labels (e.g., STREET_ADDRESS, FIRST_NAME, GPE) - Model entities: what the model can detect (e.g., LOCATION, PERSON, NRP)

These often use different naming conventions and hierarchy depths:

Dataset Entity Model Entity Notes
STREET_ADDRESS LOCATION STREET_ADDRESS is depth-3 (PII→LOCATION→ADDRESS); Presidio predicts depth-2 LOCATION
FIRST_NAME PERSON FIRST_NAME is depth-4; Presidio predicts depth-2 PERSON
GPE LOCATION Both refer to geopolitical entities, different names

CanonicalMapper resolves these mismatches automatically using a single-phase (Identify-only) workflow: match each raw label to a canonical hierarchy entity via EXACT → COUNTRY → COUNTRY_FALLBACK → FUZZY → UNRESOLVED.

# Load dataset
dataset_name = "synth_dataset_v2.json"
dataset = InputSample.read_dataset_json(Path(Path.cwd().parent, "data", dataset_name))
print(f"Loaded {len(dataset)} samples")

# Show dataset entities
dataset_entities = Counter()
for sample in dataset:
    for span in sample.spans:
        dataset_entities[span.entity_type] += 1

print(f"\nDataset has {len(dataset_entities)} entity types:")
pprint(dict(dataset_entities.most_common(10)), compact=True)
tokenizing input:   0%|          | 1/1500 [00:00<04:17,  5.81it/s]

loading model en_core_web_sm


tokenizing input: 100%|██████████| 1500/1500 [00:04<00:00, 307.68it/s]

Loaded 1500 samples

Dataset has 17 entity types:
{'AGE': 74,
 'CREDIT_CARD': 136,
 'DATE_TIME': 119,
 'GPE': 411,
 'NRP': 55,
 'ORGANIZATION': 250,
 'PERSON': 857,
 'PHONE_NUMBER': 92,
 'STREET_ADDRESS': 598,
 'TITLE': 92}
# Load Presidio Analyzer
analyzer = AnalyzerEngine(default_score_threshold=0.4)

# Let's add a custom entity recognizer 
# with an unknown entity type to show how the mapping works
composers_recognizer = PatternRecognizer(supported_entity="COMPOSER", 
                                         deny_list=["A", "Mo", "Bee", "Ba"])

analyzer.registry.add_recognizer(composers_recognizer)

print(f"Presidio supports {len(analyzer.get_supported_entities('en'))} entity types:")
pprint(sorted(analyzer.get_supported_entities("en")), compact=True)
Presidio supports 20 entity types:
['COMPOSER', 'CREDIT_CARD', 'CRYPTO', 'DATE_TIME', 'EMAIL_ADDRESS', 'IBAN_CODE',
 'IP_ADDRESS', 'LOCATION', 'MAC_ADDRESS', 'MEDICAL_LICENSE', 'NRP', 'PERSON',
 'PHONE_NUMBER', 'UK_NHS', 'URL', 'US_BANK_NUMBER', 'US_DRIVER_LICENSE',
 'US_ITIN', 'US_PASSPORT', 'US_SSN']

2. Single-Phase Workflow: Identify

Identification (5 tiers, in order)

Tier Example Notes
EXACT EMAIL_ADDRESSEMAIL_ADDRESS Label is already a canonical name or known alias
COUNTRY US_PASSPORTPASSPORT Strip country prefix (US_, AU_, etc.)
COUNTRY_FALLBACK GERMANY_PASSPORT_NUMBERPASSPORT Strip country + _NUMBER suffix
FUZZY EMAILADRESEMAIL_ADDRESS Fuzzy string match above threshold (default 0.80)
UNRESOLVED XYZZY_UNKNOWN No match found — must be manually mapped or suppressed

BIO tags (B-PERSON, I-PERSON) are automatically stripped before matching.

After identification, get_mapped_results_dataframe() returns a MappedResults object with four pre-projected DataFrames: .original, .binary, .branch, .detailed.

# Load and predict on a small subset
subset = dataset[:100]
wrapped_analyzer = PresidioAnalyzerWrapper(analyzer_engine=analyzer, language='en')
results_df = wrapped_analyzer.predict_dataset(subset)

# Create mapper and analyze
mapper = CanonicalMapper()

# analyze() identifies all labels in the hierarchy (single phase)
mapper.analyze(results_df)

# render_html() shows a color-coded audit table
# (falls back to plain text outside Jupyter)
mapper.render_html()
[UNRESOLVED] COMPOSER  -- no automatic match found

Entity Mapping Audit 19 labels identified

1 issue(s) need your attention
Unresolved: 1Cross-branch: 1Pred-only: 0Dataset-only: 1Same-branch: 1

1. Blocking issues1 requiring action

Labels that could not be mapped automatically (ERROR / WARNING). Resolve these before calling mapper.get_mapped_results_dataframe().

ERROR  COMPOSER
This label doesn't appear in the entity hierarchy, so the mapper has no basis for matching it to a canonical representation. It may be a typo, a model-specific tag, or an entirely new entity type.
mapper.map({'COMPOSER': 'CUSTOMER_ID'}) — Map 'COMPOSER' to 'CUSTOMER_ID'
3 more option(s)
  • mapper.map({'COMPOSER': 'COHORT_ID'}) — Map 'COMPOSER' to 'COHORT_ID'
  • mapper.map({'COMPOSER': 'CUSTOMER_ID'}) — Map 'COMPOSER' to 'CUSTOMER_ID'
  • mapper.map({'COMPOSER': None}) — Suppress 'COMPOSER' from evaluation
WARNING  NRP
This prediction label co-occurs on the same tokens with annotation labels from a different hierarchy branch that the model never covers. The model predicts this label where a cross-branch entity is annotated, and it never predicts anything on that annotation's branch — so the conflict can't be resolved by hierarchical evaluation.
mapper.map({'NRP': 'ADDRESS'}) — Remap 'NRP' to 'ADDRESS' (4 token co-occurrences with 'STREET_ADDRESS')
1 more option(s)
  • mapper.map({'NRP': None}) — Suppress 'NRP' from evaluation
WARNING  ORGANIZATION
The dataset annotates this entity type, but the model never predicts it. There are no predictions to evaluate, so recall will be zero for this label. Consider whether the model is expected to detect this entity type.

2. Annotation labels15 label(s) from your dataset

The entity labels in your ground-truth annotations. Non-blocking issues (INFO) are shown inline.

LabelResolved asTokens
STREET_ADDRESSADDRESS161
PERSONPERSON111
GPEGPE33
PHONE_NUMBERPHONE_NUMBER30
ORGANIZATIONORGANIZATION20
DATE_TIMEDATE_TIME17
TITLETITLE11
US_SSNSSN10
CREDIT_CARDFINANCIAL8
US_DRIVER_LICENSEDRIVER_LICENSE5
EMAIL_ADDRESSEMAIL_ADDRESS5
AGEAGE4
DOMAIN_NAMEDOMAIN4
ZIP_CODEADDRESS3
IBAN_CODEFINANCIAL1

3. Prediction labels11 label(s) from the model

The entity labels your model outputs. Non-blocking issues (INFO) are shown inline.

LabelResolved asTokens
COMPOSERunresolved27
PERSONPERSON118
DATE_TIMEDATE_TIME41
LOCATIONLOCATION23
PHONE_NUMBERPHONE_NUMBER22
US_SSNSSN10
CREDIT_CARDFINANCIAL6
EMAIL_ADDRESSEMAIL_ADDRESS5
NRPNATIONALITY4
URLURL4
IBAN_CODEFINANCIAL1

3. All Five Issue Types

After analyze(), get_issues() returns MappingIssue objects sorted by severity (ERROR first, then WARNING, then INFO), then by token count (most frequent first).

Issue Type Severity Blocking? When it occurs
UNRESOLVED ERROR Yes Label matched nothing in the hierarchy
COLLISION_CROSS_BRANCH WARNING Yes Label maps to a branch absent from the annotation set
PREDICTION_ONLY WARNING Yes Label appears only in predictions, never in annotations
DATASET_ONLY WARNING No Label appears only in annotations, never in predictions
COLLISION_SAME_BRANCH INFO No Same-branch depth mismatch — handled by hierarchical evaluation

Blocking means get_mapped_results_dataframe() raises IncompleteMapping until resolved.

INFO issues (COLLISION_SAME_BRANCH) are only shown when min_severity='INFO'. INFO issues are non-blocking — they are informational only.

# View all detected issues with full detail
print(f'Total issues: {len(mapper.get_issues())}')
for issue in mapper.get_issues():
    blocking = 'BLOCKING' if issue.severity != IssueSeverity.INFO else 'non-blocking'
    print(f'  [{issue.severity.value.upper():7}] {issue.type.value:30} | {blocking} | {issue.labels}')
    if issue.annotation_count or issue.prediction_count:
        print(f'             annotations={issue.annotation_count}, predictions={issue.prediction_count}')
Total issues: 3
  [ERROR  ] unresolved                     | BLOCKING | ['COMPOSER']
             annotations=0, predictions=27
  [WARNING] collision_cross_branch         | BLOCKING | ['NRP']
             annotations=0, predictions=4
  [WARNING] dataset_only                   | BLOCKING | ['ORGANIZATION']
             annotations=20, predictions=0

Resolving Issues

Use map({label: canonical}) to remap a label to a canonical entity, or map({label: None}) to suppress it (both annotation and prediction sides become 'O').

When to suppress (None): - The model predicts entity types your dataset does not cover (PREDICTION_ONLY) - A label is noise or irrelevant to your evaluation

When to remap: - COLLISION_CROSS_BRANCH: remap to a canonical entity in the correct branch - UNRESOLVED: manually specify what the label should map to

map() validates all targets atomically — if any target is invalid, none are applied. map() returns self so you can chain calls.

# Programmatic batch resolution — iterate over issues by type

# PREDICTION_ONLY: suppress labels the model predicts but dataset never annotates
prediction_only = [i for i in mapper.get_issues() if i.type == IssueType.PREDICTION_ONLY]
for issue in prediction_only:
    print(f'Suppressing PREDICTION_ONLY: {issue.labels}')
    mapper.map({lbl: None for lbl in issue.labels})

# COLLISION_CROSS_BRANCH: remap to the correct branch
cross_branch = [i for i in mapper.get_issues() if i.type == IssueType.COLLISION_CROSS_BRANCH]
for issue in cross_branch:
    for lbl in issue.labels:
        # Map cross-branch labels to an appropriate canonical entity
        print(f'Needs remapping (COLLISION_CROSS_BRANCH): {lbl}')
        # mapper.map({lbl: 'TARGET_ENTITY'})  # uncomment and set target as appropriate

# Suppress other known noise
mapper.map({'TITLE': None})
mapper.map({'COMPOSER': None})

# Re-analyze to refresh issue detection
mapper.analyze(results_df)
mapper.render_html()
Needs remapping (COLLISION_CROSS_BRANCH): NRP

Entity Mapping Audit 19 labels identified

✓ Ready for evaluation
Unresolved: 0Cross-branch: 1Pred-only: 0Dataset-only: 1Same-branch: 1

1. Blocking issuesnone

Labels that could not be mapped automatically (ERROR / WARNING). Resolve these before calling mapper.get_mapped_results_dataframe().

WARNING  NRP
This prediction label co-occurs on the same tokens with annotation labels from a different hierarchy branch that the model never covers. The model predicts this label where a cross-branch entity is annotated, and it never predicts anything on that annotation's branch — so the conflict can't be resolved by hierarchical evaluation.
mapper.map({'NRP': 'ADDRESS'}) — Remap 'NRP' to 'ADDRESS' (4 token co-occurrences with 'STREET_ADDRESS')
1 more option(s)
  • mapper.map({'NRP': None}) — Suppress 'NRP' from evaluation
WARNING  ORGANIZATION
The dataset annotates this entity type, but the model never predicts it. There are no predictions to evaluate, so recall will be zero for this label. Consider whether the model is expected to detect this entity type.

2. Annotation labels15 label(s) from your dataset

The entity labels in your ground-truth annotations. Non-blocking issues (INFO) are shown inline.

LabelResolved asTokens
STREET_ADDRESSADDRESS161
PERSONPERSON111
GPEGPE33
PHONE_NUMBERPHONE_NUMBER30
ORGANIZATIONORGANIZATION20
DATE_TIMEDATE_TIME17
US_SSNSSN10
CREDIT_CARDFINANCIAL8
US_DRIVER_LICENSEDRIVER_LICENSE5
EMAIL_ADDRESSEMAIL_ADDRESS5
AGEAGE4
DOMAIN_NAMEDOMAIN4
ZIP_CODEADDRESS3
IBAN_CODEFINANCIAL1
TITLEunresolved11

3. Prediction labels11 label(s) from the model

The entity labels your model outputs. Non-blocking issues (INFO) are shown inline.

LabelResolved asTokens
PERSONPERSON118
DATE_TIMEDATE_TIME41
LOCATIONLOCATION23
PHONE_NUMBERPHONE_NUMBER22
US_SSNSSN10
CREDIT_CARDFINANCIAL6
EMAIL_ADDRESSEMAIL_ADDRESS5
NRPNATIONALITY4
URLURL4
IBAN_CODEFINANCIAL1
COMPOSERunresolved27
# Check remaining blocking issues
blocking = [i for i in mapper.get_issues() if i.severity != IssueSeverity.INFO]
if blocking:
    print(f'{len(blocking)} blocking issue(s) remain:')
    for issue in blocking:
        print(f'  [{issue.severity.value}] {issue.type.value}: {issue.labels}')
else:
    print('No blocking issues - ready to call get_mapped_results_dataframe()')

# DATASET_ONLY issues are INFO (non-blocking) - they tell you what the model never predicts
dataset_only = [i for i in mapper.get_issues() if i.type == IssueType.DATASET_ONLY]
if dataset_only:
    all_ds_only = [lbl for i in dataset_only for lbl in i.labels]
    print(f'\nDataset-only entities (INFO, non-blocking): {all_ds_only}')
    print('  These entities never appear in model predictions — recall for them will be 0.')
2 blocking issue(s) remain:
  [warning] collision_cross_branch: ['NRP']
  [warning] dataset_only: ['ORGANIZATION']

Dataset-only entities (INFO, non-blocking): ['ORGANIZATION']
  These entities never appear in model predictions — recall for them will be 0.

4. Single Entity Lookup

Use get_mapping(entity='...') to look up the resolved hierarchy entity for any label, including labels not yet analyzed. Returns None for suppressed labels.

# Look up individual entities
print('EMAIL_ADDRESS ->', mapper.get_mapping(entity='EMAIL_ADDRESS'))
print('B-PERSON ->', mapper.get_mapping(entity='B-PERSON'))  # BIO prefix stripped
print('GERMANY_PASSPORT_NUMBER ->', mapper.get_mapping(entity='GERMANY_PASSPORT_NUMBER'))  # COUNTRY tier
print('US_SSN ->', mapper.get_mapping(entity='US_SSN'))

# Full mapping dict for all labels seen so far
full_mapping = mapper.get_mapping()
print('\nFull mapping:')
pprint(full_mapping, compact=True)
EMAIL_ADDRESS -> EMAIL_ADDRESS
B-PERSON -> PERSON
GERMANY_PASSPORT_NUMBER -> PASSPORT
US_SSN -> SSN

Full mapping:
{'AGE': 'AGE',
 'COMPOSER': None,
 'CREDIT_CARD': 'FINANCIAL',
 'DATE_TIME': 'DATE_TIME',
 'DOMAIN_NAME': 'DOMAIN',
 'EMAIL_ADDRESS': 'EMAIL_ADDRESS',
 'GPE': 'GPE',
 'IBAN_CODE': 'FINANCIAL',
 'LOCATION': 'LOCATION',
 'NRP': 'NATIONALITY',
 'ORGANIZATION': 'ORGANIZATION',
 'PERSON': 'PERSON',
 'PHONE_NUMBER': 'PHONE_NUMBER',
 'STREET_ADDRESS': 'ADDRESS',
 'TITLE': None,
 'URL': 'URL',
 'US_DRIVER_LICENSE': 'DRIVER_LICENSE',
 'US_SSN': 'SSN',
 'ZIP_CODE': 'ADDRESS'}

5. Majority-Vote Depth Auto-Discovery

The canonical surface is computed automatically from the annotation labels in the results DataFrame. Each annotation label is resolved to a canonical entity, its depth in the hierarchy is measured (capped at 3), and a weighted majority vote determines the canonical depth.

Scenario Majority depth Canonical surface
Most annotations are NAME, EMAIL_ADDRESS, SSN (depth 3) 3 depth-3 entities
Most annotations are PERSON, LOCATION (depth 2) 2 depth-2 entities

The canonical surface is locked after the first analyze() call and does not change on subsequent calls. This ensures consistent evaluation across multiple models on the same dataset.

# Demonstrate hierarchy depth
# EntityHierarchy.get_depth() shows where each entity sits in the hierarchy
h = EntityHierarchy(canonical_depth=10)
for entity in ['PERSON', 'NAME', 'FIRST_NAME', 'EMAIL_ADDRESS', 'SSN']:
    try:
        depth = h.get_depth(entity)
        branch = h.canonical_to_branch.get(entity, [])
        print(f'  {entity:25} depth={depth}  branch={" → ".join(branch)}')
    except Exception:
        print(f'  {entity:25} not in hierarchy')
  PERSON                    depth=2  branch=PII → PERSON
  NAME                      depth=3  branch=PII → PERSON → NAME
  FIRST_NAME                depth=4  branch=PII → PERSON → NAME → FIRST_NAME
  EMAIL_ADDRESS             depth=3  branch=PII → CONTACT → EMAIL_ADDRESS
  SSN                       depth=3  branch=PII → GOVERNMENT_ID → SSN
# Inspect resolved labels per record
# After analyze(), inspect _records to see tier and resolved entity per label
print(f'{"Label":25} {"Tier":15} {"Resolved":25} {"Score":10}')
print('-' * 80)
for lbl, rec in sorted(mapper._records.items()):
    score_str = f'{rec.score:.0%}' if rec.score is not None else '-'
    print(f'{lbl:25} {rec.tier:15} {str(rec.resolved):25} {score_str:10}')
Label                     Tier            Resolved                  Score     
--------------------------------------------------------------------------------
AGE                       EXACT           AGE                       -         
COMPOSER                  NONE            None                      -         
CREDIT_CARD               EXACT           FINANCIAL                 -         
DATE_TIME                 EXACT           DATE_TIME                 -         
DOMAIN_NAME               EXACT           DOMAIN                    -         
EMAIL_ADDRESS             EXACT           EMAIL_ADDRESS             -         
GPE                       EXACT           GPE                       -         
IBAN_CODE                 EXACT           FINANCIAL                 -         
LOCATION                  EXACT           LOCATION                  -         
NRP                       EXACT           NATIONALITY               -         
ORGANIZATION              EXACT           ORGANIZATION              -         
PERSON                    EXACT           PERSON                    -         
PHONE_NUMBER              EXACT           PHONE_NUMBER              -         
STREET_ADDRESS            EXACT           ADDRESS                   -         
TITLE                     NONE            None                      -         
URL                       EXACT           URL                       -         
US_DRIVER_LICENSE         EXACT           DRIVER_LICENSE            -         
US_SSN                    EXACT           SSN                       -         
ZIP_CODE                  EXACT           ADDRESS                   -
# Adjusting the fuzzy threshold: lower = more aggressive auto-resolution
loose_mapper = CanonicalMapper(fuzzy_threshold=0.65)
loose_mapper.analyze(results_df)
print(f'Pending with threshold=0.65: {loose_mapper.pending}')
loose_mapper.render_html()
[UNRESOLVED] COMPOSER  -- no automatic match found


Pending with threshold=0.65: ['COMPOSER']

Entity Mapping Audit 19 labels identified

1 issue(s) need your attention
Unresolved: 1Cross-branch: 1Pred-only: 0Dataset-only: 1Same-branch: 1

1. Blocking issues1 requiring action

Labels that could not be mapped automatically (ERROR / WARNING). Resolve these before calling mapper.get_mapped_results_dataframe().

ERROR  COMPOSER
This label doesn't appear in the entity hierarchy, so the mapper has no basis for matching it to a canonical representation. It may be a typo, a model-specific tag, or an entirely new entity type.
mapper.map({'COMPOSER': 'CUSTOMER_ID'}) — Map 'COMPOSER' to 'CUSTOMER_ID'
3 more option(s)
  • mapper.map({'COMPOSER': 'COHORT_ID'}) — Map 'COMPOSER' to 'COHORT_ID'
  • mapper.map({'COMPOSER': 'CUSTOMER_ID'}) — Map 'COMPOSER' to 'CUSTOMER_ID'
  • mapper.map({'COMPOSER': None}) — Suppress 'COMPOSER' from evaluation
WARNING  NRP
This prediction label co-occurs on the same tokens with annotation labels from a different hierarchy branch that the model never covers. The model predicts this label where a cross-branch entity is annotated, and it never predicts anything on that annotation's branch — so the conflict can't be resolved by hierarchical evaluation.
mapper.map({'NRP': 'ADDRESS'}) — Remap 'NRP' to 'ADDRESS' (4 token co-occurrences with 'STREET_ADDRESS')
1 more option(s)
  • mapper.map({'NRP': None}) — Suppress 'NRP' from evaluation
WARNING  ORGANIZATION
The dataset annotates this entity type, but the model never predicts it. There are no predictions to evaluate, so recall will be zero for this label. Consider whether the model is expected to detect this entity type.

2. Annotation labels15 label(s) from your dataset

The entity labels in your ground-truth annotations. Non-blocking issues (INFO) are shown inline.

LabelResolved asTokens
STREET_ADDRESSADDRESS161
PERSONPERSON111
GPEGPE33
PHONE_NUMBERPHONE_NUMBER30
ORGANIZATIONORGANIZATION20
DATE_TIMEDATE_TIME17
TITLETITLE11
US_SSNSSN10
CREDIT_CARDFINANCIAL8
US_DRIVER_LICENSEDRIVER_LICENSE5
EMAIL_ADDRESSEMAIL_ADDRESS5
AGEAGE4
DOMAIN_NAMEDOMAIN4
ZIP_CODEADDRESS3
IBAN_CODEFINANCIAL1

3. Prediction labels11 label(s) from the model

The entity labels your model outputs. Non-blocking issues (INFO) are shown inline.

LabelResolved asTokens
COMPOSERunresolved27
PERSONPERSON118
DATE_TIMEDATE_TIME41
LOCATIONLOCATION23
PHONE_NUMBERPHONE_NUMBER22
US_SSNSSN10
CREDIT_CARDFINANCIAL6
EMAIL_ADDRESSEMAIL_ADDRESS5
NRPNATIONALITY4
URLURL4
IBAN_CODEFINANCIAL1

6. Interactive Resolution with resolve_interactively()

resolve_interactively() prompts you for each WARNING+ issue one at a time. It accepts a prompt_fn argument for testing (default: input). The interactive session is most useful in a terminal or a Jupyter cell — it walks you through every WARNING/ERROR issue and lets you type a canonical name or None to suppress.

# Interactive resolution in a terminal or Jupyter cell
# (commented out here to avoid blocking automated notebook runs)
# mapper.resolve_interactively()

For automated / batch resolution, use map() directly as shown in section 3.

# Get the full mapping dict once no blocking issues remain
if mapper.pending:
    print(f'Still {len(mapper.pending)} pending: {mapper.pending}')
else:
    entity_mapping = mapper.get_mapping()
    print('Entity mapping ready:')
    pprint(entity_mapping, compact=True)

# Get the MappedResults object — four DataFrames at different granularity levels
try:
    mapped_results = mapper.get_mapped_results_dataframe()
    print(f'\nMappedResults fields: original, binary, branch, detailed')
    print(f'Each DataFrame has {mapped_results.original.shape[0]} rows')
except IncompleteMapping as e:
    print(f'Blocked: {e}')
Entity mapping ready:
{'AGE': 'AGE',
 'COMPOSER': None,
 'CREDIT_CARD': 'FINANCIAL',
 'DATE_TIME': 'DATE_TIME',
 'DOMAIN_NAME': 'DOMAIN',
 'EMAIL_ADDRESS': 'EMAIL_ADDRESS',
 'GPE': 'GPE',
 'IBAN_CODE': 'FINANCIAL',
 'LOCATION': 'LOCATION',
 'NRP': 'NATIONALITY',
 'ORGANIZATION': 'ORGANIZATION',
 'PERSON': 'PERSON',
 'PHONE_NUMBER': 'PHONE_NUMBER',
 'STREET_ADDRESS': 'ADDRESS',
 'TITLE': None,
 'URL': 'URL',
 'US_DRIVER_LICENSE': 'DRIVER_LICENSE',
 'US_SSN': 'SSN',
 'ZIP_CODE': 'ADDRESS'}

MappedResults fields: original, binary, branch, detailed
Each DataFrame has 1832 rows
## 7. Multi-Model Comparison

When evaluating multiple models on the same dataset, use the same mapper instance and call
`analyze()` for each model's results. The resolved mappings are consistent across models
because the hierarchy is fixed.
  Cell In[13], line 4
    `analyze()` for each model's results. The resolved mappings are consistent across models
                              ^
SyntaxError: unterminated string literal (detected at line 4)
# Demonstrate consistent mapping across two models (same mapper, different analyze() calls)
comparison_mapper = CanonicalMapper()

# Model 1
comparison_mapper.analyze(results_df)
records_after_model1 = set(comparison_mapper._records.keys())
print(f'Labels after model 1: {len(records_after_model1)}')

# Model 2 (using same results_df for illustration; in practice this would be a different model)
comparison_mapper.analyze(results_df)
records_after_model2 = set(comparison_mapper._records.keys())
print(f'Labels after model 2: {len(records_after_model2)}')
print('Same label set across models:', records_after_model1 == records_after_model2)
## 8. EntityHierarchy Customisation

Pass a custom hierarchy dict or `EntityHierarchy` instance to `CanonicalMapper(hierarchy=...)` to:
- Add aliases for labels not in the default taxonomy
- Extend the hierarchy with new entity types
- Override the default mappings

9. Hierarchical Evaluation with MappedResults

get_mapped_results_dataframe() returns a MappedResults object — a frozen dataclass with four pre-projected DataFrames at different granularity levels:

Field Description Example labels
.original Raw (unmapped) labels FIRST_NAME, US_PASSPORT
.binary PII vs O PII, O
.branch Top-level branch PERSON, LOCATION, O
.detailed Full canonical entity NAME, GPE, O

Pass mapped_results directly to calculate_hierarchical_scores() to get F1 at all three levels.

from presidio_evaluator.evaluation import SpanEvaluator

# Get mapped results (requires no pending blocking issues)
try:
    mapped_results = mapper.get_mapped_results_dataframe()
    print(f'MappedResults ready: {mapped_results.detailed.shape[0]} rows')
    print(f'Fields: original={mapped_results.original.shape}, binary={mapped_results.binary.shape},')
    print(f'        branch={mapped_results.branch.shape}, detailed={mapped_results.detailed.shape}')
except IncompleteMapping as e:
    print(f'Cannot get MappedResults yet: {e}')
    mapped_results = None
if mapped_results is not None:
    evaluator = SpanEvaluator()

    # calculate_hierarchical_scores returns scores at binary, branch and detailed levels
    hierarchical_results = evaluator.calculate_hierarchical_scores(mapped_results)

    for level, scores in hierarchical_results.items():
        pprint_score = {
            'precision': round(scores.pii_precision, 3),
            'recall': round(scores.pii_recall, 3),
            'f1': round(scores.pii_f, 3),
        }
        print(f'{level:10}: {pprint_score}')

Summary

Key Takeaways

  1. Single-phase workflow: analyze() runs Identify only (5 tiers) — no projection phase

  2. Five issue types:

  3. ERROR (blocking): UNRESOLVED
  4. WARNING (blocking): COLLISION_CROSS_BRANCH, PREDICTION_ONLY, DATASET_ONLY
  5. INFO (non-blocking): COLLISION_SAME_BRANCH (use min_severity='INFO' to show)

  6. Resolution options:

  7. map({label: canonical}) — remap
  8. map({label: None}) — suppress
  9. resolve_interactively() — guided interactive session

  10. get_mapped_results_dataframe() returns a MappedResults object with four DataFrames: .original, .binary, .branch, .detailed

  11. calculate_hierarchical_scores(mapped_results) evaluates all three levels at once: binary, branch, and detailed

Best Practices

  1. Call render_html() after analyze() to visually audit all mappings
  2. Pre-map known overrides before analyze() to keep the issue list focused
  3. Use min_severity='INFO' to surface COLLISION_SAME_BRANCH for awareness
  4. INFO issues are non-blocking — review but no action required
print('''
=== CanonicalMapper Quick Reference ===

# 1. Create (no constructor args needed)
mapper = CanonicalMapper()

# 2. Pre-map known labels before analyze() (optional)
mapper.map({'MY_LABEL': 'PERSON', 'NOISE': None})

# 3. Analyze — Identify-only (single phase)
mapper.analyze(results_df)                    # default min_severity='WARNING'
mapper.analyze(results_df, min_severity='INFO')  # show COLLISION_SAME_BRANCH too

# 4. Review
mapper.render_html()          # HTML audit table (Jupyter)
mapper.get_issues()           # structured MappingIssue list (filtered by min_severity)
mapper.pending                # UNRESOLVED-tier labels

# 5. Resolve blocking issues
mapper.map({'CROSS_BRANCH_LBL': 'CANONICAL'})  # remap COLLISION_CROSS_BRANCH
mapper.map({'PREDICTION_ONLY_LBL': None})       # suppress PREDICTION_ONLY
# mapper.resolve_interactively()               # guided interactive session

# 6. Single entity lookup
mapper.get_mapping(entity='B-PERSON')   # strips BIO, returns resolved value

# 7. Get results
entity_mapping = mapper.get_mapping()    # full {label: resolved} dict (UNRESOLVED excluded)
mapped_results = mapper.get_mapped_results_dataframe()  # raises IncompleteMapping if blocking
# mapped_results.original  — raw labels
# mapped_results.binary    — PII / O
# mapped_results.branch    — PERSON, LOCATION, ...
# mapped_results.detailed  — NAME, GPE, ...


# 8. Evaluate

evaluator = SpanEvaluator(iou_threshold=0.75)''')

scores = evaluator.calculate_score_on_df(results_df=mapped_results.detailed)  # single levelhier = evaluator.calculate_hierarchical_scores(mapped_results)                 # all levels