Skip to content

Commit e4f6b60

Browse files
committed
Align TokenEvaluator and SpanEvaluator EvaluationResult outputs; add display_mode=none to Plotter; extract test fixtures; fix ruff linting
1 parent 3282b60 commit e4f6b60

16 files changed

Lines changed: 808 additions & 988 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
4747
- name: Install dependencies
4848
run: |
49+
uv venv --seed
4950
uv sync --extra dev
5051
uv run python -m spacy download en_core_web_sm
5152
uv run python -m spacy download en_core_web_lg

README.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Presidio-research
22

3-
This package provides evaluation and data-science capabilities for
3+
This package provides evaluation and data-science capabilities for
44
[Presidio](https://github.com/microsoft/presidio) and PII detection models in general.
55

66
It also includes a fake data generator that creates synthetic sentences based on templates and fake PII.
@@ -20,16 +20,13 @@ The easiest way to get started is by reviewing the notebooks.
2020
- [Notebook 3](notebooks/3_Split_by_pattern_number.ipynb): Provides tools to split the dataset into train/test/validation sets while avoiding leakage due to the same pattern appearing in multiple folds (only applicable for synthetically generated data).
2121
- [Notebook 4](notebooks/4_Evaluate_Presidio_Analyzer.ipynb): Shows how to use the evaluation tools to evaluate how well Presidio detects PII. Note that this is using the vanilla Presidio, and the results aren't very accurate.
2222
- [Notebook 5](notebooks/5_Evaluate_Custom_Presidio_Analyzer.ipynb): Shows how one can configure Presidio to detect PII much more accurately, and boost the f score in ~30%.
23+
- [Notebook 6](notebooks/6_Interactive_Entity_Mapping.ipynb): Explains the entity mapping process, which is crucial when evaluating multiple models each returning a different set of entities.
2324

2425
### Installation
2526

26-
>Note: Presidio evaluator requires Python version 3.11 or higher.
27-
2827
#### From PyPI
2928

3029
``` sh
31-
conda create --name presidio python=3.12
32-
conda activate presidio
3330
pip install presidio-evaluator
3431
python -m spacy download en_core_web_sm # for tokenization
3532
python -m spacy download en_core_web_lg # for NER

docs/entity_hierarchy.md

Lines changed: 157 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ can be normalized to.
1313
`presidio_evaluator.entity_mapping` provides:
1414

1515
- **`HIERARCHY`** — a single nested Python dict that is the authoritative taxonomy.
16-
- **`EntityHierarchy`** — a class that wraps the taxonomy and exposes canonicalization, branch lookup, and a mutation API.
17-
- **Module-level shortcuts**`canonicalize()`, `get_branch()`, and `print_hierarchy()` delegate to a shared default
18-
instance so most callers never need to instantiate the class directly.
16+
- **`EntityHierarchy`** — a class that wraps the taxonomy and exposes canonicalization, branch lookup, BIO prefix stripping, and alias extension.
17+
- **`CanonicalMapper`** — a workflow class that resolves a full set of raw model/dataset labels through auto-resolution, fuzzy matching, and manual override.
1918

2019
---
2120

@@ -32,8 +31,8 @@ The default canonical depth is **3** (passed as `canonical_depth=3` to `EntityHi
3231

3332
| Depth | Role | Behaviour |
3433
|-------|------|-----------|
35-
| 1 | Root (`PII`) | Self-maps — `canonicalize("PII")``"PII"` |
36-
| 2 | Domain branch (e.g. `GOVERNMENT_ID`, `CONTACT`) | Self-maps — `canonicalize("CONTACT")``"CONTACT"` |
34+
| 1 | Root (`PII`) | Self-maps — `h.canonicalize("PII")``"PII"` |
35+
| 2 | Domain branch (e.g. `GOVERNMENT_ID`, `CONTACT`) | Self-maps — `h.canonicalize("CONTACT")``"CONTACT"` |
3736
| 3 | **Canonical entity** (e.g. `EMAIL_ADDRESS`, `PASSPORT`) | The resolution target |
3837
| 4+ | Fine-grained sub-type (e.g. `CARD_NUMBER` under `CREDIT_CARD` under `FINANCIAL`) | Rolls up to its depth-3 ancestor |
3938

@@ -79,26 +78,28 @@ FINANCIAL_PII (depth 2, self-maps)
7978
```
8079

8180
```python
82-
canonicalize("CREDIT_CARD") # → "FINANCIAL"
83-
canonicalize("IBAN") # → "FINANCIAL"
84-
canonicalize("FINANCIAL_PII") # → "FINANCIAL_PII" (depth-2 self-map)
81+
h = EntityHierarchy()
82+
h.canonicalize("CREDIT_CARD") # → "FINANCIAL"
83+
h.canonicalize("IBAN") # → "FINANCIAL"
84+
h.canonicalize("FINANCIAL_PII") # → "FINANCIAL_PII" (depth-2 self-map)
8585
```
8686

8787
### Country-prefix auto-mapping
8888

8989
Rather than listing every `URUGUAY_TAX_ID`, `AUSTRALIA_DRIVERS_LICENSE`, etc. explicitly, the module keeps two tables:
9090

91-
- **`COUNTRIES`** — all 249 ISO 3166-1 alpha-2 codes plus full English country name tokens (e.g. `AUSTRALIA`, `GERMANY`).
92-
- **`country_prefixed_doc_types`** — an instance attribute on `EntityHierarchy`; a suffix keyword → canonical entity mapping (e.g. `"DRIVER"``"DRIVER_LICENSE"`). Add entries via `h.add_country_doc_type()`.
91+
- **`COUNTRIES`** — all 249 ISO 3166-1 alpha-2 codes plus full English country name tokens, demonyms, and adjectival forms (e.g. `AUSTRALIA`, `GERMANY`, `BRITISH`, `FRENCH`).
92+
- **`country_prefixed_doc_types`** — an instance attribute on `EntityHierarchy`; a suffix keyword → canonical entity mapping (e.g. `"DRIVER"``"DRIVER_LICENSE"`). Mutate directly: `h.country_prefixed_doc_types["MY_SUFFIX"] = "MY_CANONICAL"`.
9393

9494
Any `<COUNTRY>_<SUFFIX>` label is resolved automatically. An unrecognized suffix with a known country prefix defaults
9595
to `"NATIONAL_ID"`.
9696

9797
```python
98-
canonicalize("URUGUAY_TAX_ID") # → "TAX_ID"
99-
canonicalize("AUSTRALIA_DRIVERS_LICENSE") # → "DRIVER_LICENSE"
100-
canonicalize("GERMANY_PASSPORT_NUMBER") # → "PASSPORT"
101-
canonicalize("BRAZIL_UNKNOWN_DOC") # → "NATIONAL_ID" (fallback)
98+
h = EntityHierarchy()
99+
h.canonicalize("URUGUAY_TAX_ID") # → "TAX_ID"
100+
h.canonicalize("AUSTRALIA_DRIVERS_LICENSE") # → "DRIVER_LICENSE"
101+
h.canonicalize("GERMANY_PASSPORT_NUMBER") # → "PASSPORT"
102+
h.canonicalize("BRAZIL_UNKNOWN_DOC") # → "NATIONAL_ID" (fallback)
102103
```
103104

104105
---
@@ -175,99 +176,106 @@ clinical data governed by health-data regulations (HIPAA, GDPR Article 9) rather
175176

176177
## Usage
177178

178-
### Quick lookup — module-level shortcuts
179-
180-
For most use cases, import the three module-level functions directly:
179+
### Basic lookup — `EntityHierarchy`
181180

182181
```python
183-
from presidio_evaluator.entity_mapping import (
184-
canonicalize,
185-
get_branch,
186-
EntityNotMappedError,
187-
)
182+
from presidio_evaluator.entity_mapping import EntityHierarchy, EntityNotMappedError
188183

189-
canonicalize("EMAIL") # → "EMAIL_ADDRESS"
190-
canonicalize("date_of_birth") # → "BIRTH_DATE"
191-
canonicalize("CREDITCARD") # → "FINANCIAL"
184+
h = EntityHierarchy() # uses the built-in HIERARCHY at canonical_depth=3
192185

193-
get_branch("PASSPORT")
186+
h.canonicalize("EMAIL") # → "EMAIL_ADDRESS"
187+
h.canonicalize("date_of_birth") # → "BIRTH_DATE"
188+
h.canonicalize("CREDITCARD") # → "FINANCIAL"
189+
190+
h.get_branch("PASSPORT")
194191
# → ["PII", "GOVERNMENT_ID", "PASSPORT"]
195192

196-
get_branch("GERMANY_PASSPORT_NUMBER")
193+
h.get_branch("GERMANY_PASSPORT_NUMBER")
197194
# → ["PII", "GOVERNMENT_ID", "PASSPORT"]
198195
```
199196

200197
`EntityNotMappedError` (a subclass of `ValueError`) is raised for labels that cannot be resolved:
201198

202199
```python
203200
try:
204-
canonicalize("TOTALLY_UNKNOWN")
201+
h.canonicalize("TOTALLY_UNKNOWN")
205202
except EntityNotMappedError as e:
206203
print(e) # Unknown entity label: 'TOTALLY_UNKNOWN'
207204
```
208205

209-
### Accessing the pre-built lookup tables
206+
#### Fuzzy resolution
210207

211-
The module exposes read-only snapshots of the default instance's lookup tables — useful for bulk operations:
208+
`canonicalize()` accepts an optional `threshold` (default `0.80`). Set `threshold=1.0` to force exact matching only:
212209

213210
```python
214-
from presidio_evaluator.entity_mapping import (
215-
RAW_TO_CANONICAL, # dict[str, str] — normalized raw → canonical
216-
ALL_CANONICAL_ENTITIES, # list[str] — every depth-3 (or shallower-leaf) node
217-
CANONICAL_TO_BRANCH, # dict[str, list] — canonical → ancestor path
218-
)
211+
h.canonicalize("EMAIL_ADRES") # → "EMAIL_ADDRESS" (fuzzy match)
212+
h.canonicalize("EMAIL_ADRES", threshold=1.0) # → EntityNotMappedError
213+
```
214+
215+
#### BIO prefix stripping
216+
217+
Labels with BIO/BIOES/BILOU/BILUO prefixes or suffixes are automatically stripped before lookup:
219218

220-
# Map a list of model outputs in one comprehension
221-
raw_labels = ["CREDITCARD", "EMAIL", "DATE_OF_BIRTH"]
222-
canonical = [RAW_TO_CANONICAL.get(lbl.upper().replace("_",""), lbl) for lbl in raw_labels]
219+
```python
220+
h.canonicalize("B-PERSON") # → "NAME"
221+
h.canonicalize("PERSON-I") # → "NAME"
223222
```
224223

225-
### Custom hierarchy — mutation API
224+
### Accessing instance lookup tables
226225

227-
When you need to extend or restrict the default taxonomy for a specific project, create an independent copy and mutate
228-
it. The original default instance is never modified.
226+
The lookup tables built from the hierarchy are exposed as instance attributes:
229227

230228
```python
231-
from presidio_evaluator.entity_mapping import EntityHierarchy
229+
h = EntityHierarchy()
232230

233-
h = EntityHierarchy.default().copy()
231+
# dict[str, str] — normalized raw → canonical
232+
h.raw_to_canonical["CREDITCARD"] # → "FINANCIAL"
234233

235-
# Add a new alias for an existing entity
236-
h.add_alias("EMAIL_ADDRESS", "ELECTRONIC_MAIL")
237-
h.canonicalize("ELECTRONIC_MAIL") # → "EMAIL_ADDRESS"
234+
# list[str] — every canonical-depth node name
235+
h.all_canonical_entities # ["NAME", "EMAIL_ADDRESS", ...]
238236

239-
# Add a new canonical entity under an existing branch
240-
h.add_entity(["PII", "CONTACT"], "MESSAGING_APP", aliases=["WHATSAPP", "SIGNAL"])
241-
h.canonicalize("WHATSAPP") # → "MESSAGING_APP"
237+
# dict[str, list[str]] — canonical → full ancestor path
238+
h.canonical_to_branch["PASSPORT"] # → ["PII", "GOVERNMENT_ID", "PASSPORT"]
239+
```
242240

243-
# Rename a node
244-
h.rename_entity("MESSAGING_APP", "INSTANT_MESSAGE")
245-
h.canonicalize("WHATSAPP") # → "INSTANT_MESSAGE"
241+
### Normalizing a label
246242

247-
# Remove an entity (and all its children/aliases)
248-
h.remove_entity("INSTANT_MESSAGE")
243+
`EntityHierarchy.normalize()` is a static method that strips BIO prefixes/suffixes, uppercases, and removes `_` and `-`. It is the first step of every canonicalization:
249244

250-
# Remove a single alias
251-
h.remove_alias("EMAIL_ADDRESS", "ELECTRONIC_MAIL")
245+
```python
246+
EntityHierarchy.normalize("b-email_address") # → "EMAILADDRESS"
247+
EntityHierarchy.normalize("PERSON-I") # → "PERSON"
252248
```
253249

254-
### Country-prefix Customization
250+
### Adding aliases
255251

256252
```python
257-
h = EntityHierarchy.default().copy()
253+
h = EntityHierarchy()
254+
h.add_alias("EMAIL_ADDRESS", "ELECTRONIC_MAIL")
255+
h.canonicalize("ELECTRONIC_MAIL") # → "EMAIL_ADDRESS"
256+
```
257+
258+
`add_alias()` raises `KeyError` if `entity_name` is not found in the hierarchy. Each `add_alias()` call triggers a full rebuild of the internal lookup tables.
259+
260+
### Country-prefix customization
258261

259-
# Teach the engine that a new suffix maps to an existing canonical
260-
h.add_country_doc_type("HEALTH_CARD", "HEALTH_INSURANCE_ID")
262+
Add entries to `country_prefixed_doc_types` to teach the engine new suffix → canonical mappings:
263+
264+
```python
265+
h = EntityHierarchy()
266+
h.country_prefixed_doc_types["HEALTH_CARD"] = "HEALTH_INSURANCE_ID"
261267
h.canonicalize("CANADA_HEALTH_CARD") # → "HEALTH_INSURANCE_ID"
262268

263-
h.remove_country_doc_type("HEALTH_CARD")
269+
del h.country_prefixed_doc_types["HEALTH_CARD"]
264270
```
265271

266272
### Custom taxonomy from scratch
267273

268274
Pass your own hierarchy dict to the constructor:
269275

270276
```python
277+
from presidio_evaluator.entity_mapping import EntityHierarchy
278+
271279
custom = EntityHierarchy(
272280
hierarchy={"MY_ROOT": {"MY_BRANCH": {"MY_ENTITY": ["alias1", "alias2"]}}},
273281
canonical_depth=3,
@@ -278,28 +286,101 @@ custom.canonicalize("alias1") # → "MY_ENTITY"
278286
### Inspecting the tree
279287

280288
```python
281-
h = EntityHierarchy.default()
282-
h.print_hierarchy()
289+
h = EntityHierarchy()
290+
291+
print(h.all_canonical_entities) # list of all canonical names
292+
print(h.canonical_to_branch["PASSPORT"]) # ["PII", "GOVERNMENT_ID", "PASSPORT"]
293+
```
294+
295+
### Mapping model output with `CanonicalMapper`
296+
297+
`CanonicalMapper` resolves a full set of raw entity labels — as used by models or evaluation datasets — to canonical entities. Auto-resolution runs at construction time (exact alias → country-prefix → fuzzy); unresolvable labels land in `pending` and must be handled manually before `get_mapping()` will succeed.
298+
299+
```python
300+
from presidio_evaluator.entity_mapping import CanonicalMapper, IncompleteMapping
301+
302+
mapper = CanonicalMapper(["EMAIL_ADDRESS", "EMAILADRES", "MY_CUSTOM_LABEL"])
303+
# repr: "CanonicalMapper(2 resolved, 1 pending)"
304+
305+
mapper.render_html() # inspect in Jupyter; plain-text fallback in terminals
306+
```
307+
308+
#### Handling pending labels
309+
310+
```python
311+
# Option A: manually assign a canonical (or None to suppress from evaluation)
312+
mapper.map({"MY_CUSTOM_LABEL": "EMAIL_ADDRESS"})
313+
314+
# Option B: resolve interactively in the terminal (shows ranked fuzzy suggestions)
315+
mapper.resolve_interactively()
316+
317+
# Retrieve the final mapping dict
318+
mapping = mapper.get_mapping()
319+
# → {"EMAIL_ADDRESS": "EMAIL_ADDRESS", "EMAILADRES": "EMAIL_ADDRESS", "MY_CUSTOM_LABEL": "EMAIL_ADDRESS"}
320+
```
321+
322+
`get_mapping()` raises `IncompleteMapping` (a `RuntimeError` subclass) if any labels remain pending.
323+
`map()` is atomic — if any entry is invalid, no changes are applied.
324+
325+
#### Rendering the mapping for review
326+
327+
```python
328+
# Return an HTML string for saving or embedding; pending labels shown, no exception raised
329+
html = mapper.get_mapping(mode="html")
283330

284-
print(h.all_canonical_entities) # list of all canonical names
285-
print(h.canonical_to_branch["PASSPORT"]) # ["PII", "GOVERNMENT_ID", "PASSPORT"]
331+
# Return a plain-text table string
332+
text = mapper.get_mapping(mode="text")
286333
```
287334

335+
#### Applying the mapping to evaluation results
336+
337+
```python
338+
# results_df is the 5-column DataFrame returned by model.predict_dataset()
339+
mapped_df = mapper.get_mapped_results_dataframe(results_df)
340+
341+
# Switch to a coarser hierarchy level
342+
mapped_df = mapper.get_mapped_results_dataframe(results_df, hierarchy=2)
343+
```
344+
345+
When annotation and prediction labels are related but resolve to different canonical entities at the current depth, a `UserWarning` is emitted. Passing `hierarchy=2` (or another depth) rebuilds the underlying `EntityHierarchy` and re-resolves all previously seen labels.
346+
288347
---
289348

290349
## Quick reference
291350

292-
| Import | Type | Description |
351+
### `EntityHierarchy`
352+
353+
| Symbol | Type | Description |
354+
|--------|------|-------------|
355+
| `EntityHierarchy()` | `EntityHierarchy` | Default instance at `canonical_depth=3` |
356+
| `h.canonicalize(raw, threshold=0.80)` | `str` | Resolve raw label → canonical; fuzzy-enabled by default |
357+
| `h.get_branch(raw)` | `list[str]` | Full ancestor path for a raw label |
358+
| `EntityHierarchy.normalize(label)` | `str` (static) | Strip BIO prefix/suffix, uppercase, remove `_`/`-` |
359+
| `h.add_alias(entity, alias)` | `None` | Add a raw alias to an existing entity |
360+
| `h.raw_to_canonical` | `dict[str, str]` | Normalized raw → canonical |
361+
| `h.all_canonical_entities` | `list[str]` | Every canonical-depth entity name |
362+
| `h.canonical_to_branch` | `dict[str, list[str]]` | Canonical → full ancestor path |
363+
| `h.country_prefixed_doc_types` | `dict[str, str]` | Suffix → canonical overrides for the country-prefix engine |
364+
365+
### `CanonicalMapper`
366+
367+
| Symbol | Type | Description |
368+
|--------|------|-------------|
369+
| `CanonicalMapper(labels)` | `CanonicalMapper` | Resolve a set of raw labels with auto + manual fallback |
370+
| `mapper.pending` | `list[str]` | Unresolved labels, alphabetically sorted |
371+
| `mapper.map(dict)` | `CanonicalMapper` | Manually assign canonicals (atomic, returns `self`) |
372+
| `mapper.resolve_interactively()` | `CanonicalMapper` | Terminal prompt loop for pending labels |
373+
| `mapper.get_mapping()` | `dict[str, str\|None]` | Final mapping (raises `IncompleteMapping` if pending) |
374+
| `mapper.get_mapping(mode="html")` | `str` | HTML audit table (no exception on pending) |
375+
| `mapper.get_mapping(mode="text")` | `str` | Plain-text audit table (no exception on pending) |
376+
| `mapper.render_html()` | `None` | Display in Jupyter / print plain-text fallback |
377+
| `mapper.get_mapped_results_dataframe(df)` | `pd.DataFrame` | Remap annotation/prediction columns |
378+
379+
### Module-level constants
380+
381+
| Symbol | Type | Description |
293382
|--------|------|-------------|
294-
| `canonicalize(raw)` | `str` | Resolve a raw label to its canonical form |
295-
| `get_branch(raw)` | `list[str]` | Full ancestor path for a raw label |
296-
| `RAW_TO_CANONICAL` | `dict[str, str]` | Pre-built normalized-raw → canonical map |
297-
| `ALL_CANONICAL_ENTITIES` | `list[str]` | Every canonical entity name |
298-
| `CANONICAL_TO_BRANCH` | `dict[str, list[str]]` | Canonical → ancestor path map |
299-
| `EntityHierarchy.default()` | `EntityHierarchy` | Shared read-only default instance |
300-
| `EntityHierarchy.default().copy()` | `EntityHierarchy` | Mutable independent copy |
301-
| `EntityNotMappedError` | `ValueError` subclass | Raised for unresolvable labels |
302-
| `IncompleteMapping` | `RuntimeError` subclass | Raised by `get_mapping()` when labels are still pending |
303383
| `HIERARCHY` | `dict` | The raw taxonomy dict |
304384
| `COUNTRIES` | `set[str]` | All recognized country tokens |
305-
| `h.country_prefixed_doc_types` | `dict[str, str]` | Instance attr: suffix → canonical mapping (mutate via `add_country_doc_type()`) |
385+
| `EntityNotMappedError` | `ValueError` subclass | Raised for unresolvable labels |
386+
| `IncompleteMapping` | `RuntimeError` subclass | Raised by `get_mapping()` when labels are still pending |

0 commit comments

Comments
 (0)