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_ADDRESS → EMAIL_ADDRESS |
Label is already a canonical name or known alias |
COUNTRY |
US_PASSPORT → PASSPORT |
Strip country prefix (US_, AU_, etc.) |
COUNTRY_FALLBACK |
GERMANY_PASSPORT_NUMBER → PASSPORT |
Strip country + _NUMBER suffix |
FUZZY |
EMAILADRES → EMAIL_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. Blocking issues1 requiring action
Labels that could not be mapped automatically (ERROR / WARNING). Resolve these before calling mapper.get_mapped_results_dataframe().
COMPOSERmapper.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
NRPmapper.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
ORGANIZATION2. Annotation labels15 label(s) from your dataset
The entity labels in your ground-truth annotations. Non-blocking issues (INFO) are shown inline.
| Label | Resolved as | Tokens |
|---|---|---|
| STREET_ADDRESS | ADDRESS | 161 |
| PERSON | PERSON | 111 |
| GPE | GPE | 33 |
| PHONE_NUMBER | PHONE_NUMBER | 30 |
| ORGANIZATION | ORGANIZATION | 20 |
| DATE_TIME | DATE_TIME | 17 |
| TITLE | TITLE | 11 |
| US_SSN | SSN | 10 |
| CREDIT_CARD | FINANCIAL | 8 |
| US_DRIVER_LICENSE | DRIVER_LICENSE | 5 |
| EMAIL_ADDRESS | EMAIL_ADDRESS | 5 |
| AGE | AGE | 4 |
| DOMAIN_NAME | DOMAIN | 4 |
| ZIP_CODE | ADDRESS | 3 |
| IBAN_CODE | FINANCIAL | 1 |
3. Prediction labels11 label(s) from the model
The entity labels your model outputs. Non-blocking issues (INFO) are shown inline.
| Label | Resolved as | Tokens |
|---|---|---|
| COMPOSER | unresolved | 27 |
| PERSON | PERSON | 118 |
| DATE_TIME | DATE_TIME | 41 |
| LOCATION | LOCATION | 23 |
| PHONE_NUMBER | PHONE_NUMBER | 22 |
| US_SSN | SSN | 10 |
| CREDIT_CARD | FINANCIAL | 6 |
| EMAIL_ADDRESS | EMAIL_ADDRESS | 5 |
| NRP | NATIONALITY | 4 |
| URL | URL | 4 |
| IBAN_CODE | FINANCIAL | 1 |
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
1. Blocking issuesnone
Labels that could not be mapped automatically (ERROR / WARNING). Resolve these before calling mapper.get_mapped_results_dataframe().
NRPmapper.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
ORGANIZATION2. Annotation labels15 label(s) from your dataset
The entity labels in your ground-truth annotations. Non-blocking issues (INFO) are shown inline.
| Label | Resolved as | Tokens |
|---|---|---|
| STREET_ADDRESS | ADDRESS | 161 |
| PERSON | PERSON | 111 |
| GPE | GPE | 33 |
| PHONE_NUMBER | PHONE_NUMBER | 30 |
| ORGANIZATION | ORGANIZATION | 20 |
| DATE_TIME | DATE_TIME | 17 |
| US_SSN | SSN | 10 |
| CREDIT_CARD | FINANCIAL | 8 |
| US_DRIVER_LICENSE | DRIVER_LICENSE | 5 |
| EMAIL_ADDRESS | EMAIL_ADDRESS | 5 |
| AGE | AGE | 4 |
| DOMAIN_NAME | DOMAIN | 4 |
| ZIP_CODE | ADDRESS | 3 |
| IBAN_CODE | FINANCIAL | 1 |
| TITLE | unresolved | 11 |
3. Prediction labels11 label(s) from the model
The entity labels your model outputs. Non-blocking issues (INFO) are shown inline.
| Label | Resolved as | Tokens |
|---|---|---|
| PERSON | PERSON | 118 |
| DATE_TIME | DATE_TIME | 41 |
| LOCATION | LOCATION | 23 |
| PHONE_NUMBER | PHONE_NUMBER | 22 |
| US_SSN | SSN | 10 |
| CREDIT_CARD | FINANCIAL | 6 |
| EMAIL_ADDRESS | EMAIL_ADDRESS | 5 |
| NRP | NATIONALITY | 4 |
| URL | URL | 4 |
| IBAN_CODE | FINANCIAL | 1 |
| COMPOSER | unresolved | 27 |
# 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. Blocking issues1 requiring action
Labels that could not be mapped automatically (ERROR / WARNING). Resolve these before calling mapper.get_mapped_results_dataframe().
COMPOSERmapper.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
NRPmapper.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
ORGANIZATION2. Annotation labels15 label(s) from your dataset
The entity labels in your ground-truth annotations. Non-blocking issues (INFO) are shown inline.
| Label | Resolved as | Tokens |
|---|---|---|
| STREET_ADDRESS | ADDRESS | 161 |
| PERSON | PERSON | 111 |
| GPE | GPE | 33 |
| PHONE_NUMBER | PHONE_NUMBER | 30 |
| ORGANIZATION | ORGANIZATION | 20 |
| DATE_TIME | DATE_TIME | 17 |
| TITLE | TITLE | 11 |
| US_SSN | SSN | 10 |
| CREDIT_CARD | FINANCIAL | 8 |
| US_DRIVER_LICENSE | DRIVER_LICENSE | 5 |
| EMAIL_ADDRESS | EMAIL_ADDRESS | 5 |
| AGE | AGE | 4 |
| DOMAIN_NAME | DOMAIN | 4 |
| ZIP_CODE | ADDRESS | 3 |
| IBAN_CODE | FINANCIAL | 1 |
3. Prediction labels11 label(s) from the model
The entity labels your model outputs. Non-blocking issues (INFO) are shown inline.
| Label | Resolved as | Tokens |
|---|---|---|
| COMPOSER | unresolved | 27 |
| PERSON | PERSON | 118 |
| DATE_TIME | DATE_TIME | 41 |
| LOCATION | LOCATION | 23 |
| PHONE_NUMBER | PHONE_NUMBER | 22 |
| US_SSN | SSN | 10 |
| CREDIT_CARD | FINANCIAL | 6 |
| EMAIL_ADDRESS | EMAIL_ADDRESS | 5 |
| NRP | NATIONALITY | 4 |
| URL | URL | 4 |
| IBAN_CODE | FINANCIAL | 1 |
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
-
Single-phase workflow:
analyze()runs Identify only (5 tiers) — no projection phase -
Five issue types:
- ERROR (blocking):
UNRESOLVED - WARNING (blocking):
COLLISION_CROSS_BRANCH,PREDICTION_ONLY,DATASET_ONLY -
INFO (non-blocking):
COLLISION_SAME_BRANCH(usemin_severity='INFO'to show) -
Resolution options:
map({label: canonical})— remapmap({label: None})— suppress-
resolve_interactively()— guided interactive session -
get_mapped_results_dataframe()returns aMappedResultsobject with four DataFrames:.original,.binary,.branch,.detailed -
calculate_hierarchical_scores(mapped_results)evaluates all three levels at once: binary, branch, and detailed
Best Practices
- Call
render_html()afteranalyze()to visually audit all mappings - Pre-map known overrides before
analyze()to keep the issue list focused - Use
min_severity='INFO'to surface COLLISION_SAME_BRANCH for awareness - 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