Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: CI
on:
pull_request:
branches:
- master
- main
- 'feature/**'

jobs:
Expand All @@ -13,7 +13,7 @@ jobs:
contents: read
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
python-version: ['3.11', '3.12', '3.13', '3.14']

steps:
- uses: actions/checkout@v4
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/release-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Release Documentation

# Builds the docs with Zensical (the successor to Material for MkDocs) via
# scripts/zensical_build.py and publishes the result to GitHub Pages. The script
# collects the top-level README (-> index.md) and the notebooks/ tutorials into
# the docs tree and pre-converts the notebooks with nbconvert, because Zensical
# does not run the mkdocs-jupyter plugin.
#
# The published site is served at https://presidio-research.dataprivacystack.org
# (custom domain, set via the CNAME written below). GitHub Pages then 301-
# redirects the default https://data-privacy-stack.github.io/presidio-research/
# URL to that custom domain.
#
# One-time repo setup (by a maintainer), required for the custom domain:
# 1. Settings -> Pages -> Build and deployment -> Source: "Deploy from a branch",
# Branch: gh-pages / (root).
# 2. Settings -> Pages -> Custom domain: presidio-research.dataprivacystack.org
# 3. DNS: add a CNAME record presidio-research -> data-privacy-stack.github.io

on:
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

# The custom domain served by GitHub Pages for this site. Written into the
# gh-pages branch so it survives each publish (which wipes the branch contents).
env:
DOCS_CNAME: presidio-research.dataprivacystack.org

jobs:
github-pages-release:
name: Build with Zensical and push to GitHub Pages
runs-on: ubuntu-latest

permissions:
contents: write # Required for pushing to the gh-pages branch

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for proper gh-pages deployment
persist-credentials: true # So that the token is available for pushing

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install dependencies
run: |
pip install -r docs/requirements-docs.txt

- name: Build docs
env:
# README repo-relative links are absolutised against this ref.
DOCS_REF: ${{ github.event.release.tag_name || github.ref_name }}
run: |
python scripts/zensical_build.py build

- name: Deploy to GitHub Pages
run: |
set -euo pipefail

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

publish_dir="$(mktemp -d)"
trap 'git worktree remove --force "$publish_dir"' EXIT

# Create gh-pages if it does not exist yet, otherwise reuse it.
if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then
git fetch origin gh-pages:refs/remotes/origin/gh-pages
git worktree add --detach "$publish_dir" origin/gh-pages
else
git worktree add --detach "$publish_dir"
fi

find "$publish_dir" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
cp -a site/. "$publish_dir"/
touch "$publish_dir/.nojekyll"
printf '%s\n' "$DOCS_CNAME" > "$publish_dir/CNAME"

git -C "$publish_dir" add -A
if git -C "$publish_dir" diff --cached --quiet; then
echo "No documentation changes to publish."
exit 0
fi

git -C "$publish_dir" commit -m "docs: publish Zensical documentation"
git -C "$publish_dir" push origin HEAD:gh-pages
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,11 @@ datasets/
*.svg
!synth_dataset_v2.json
test_us002.py

# Zensical docs build (scripts/zensical_build.py)
/site
/zensical.yml
.zensical-build/
.docs-venv/
# Docs brand asset is committed despite the blanket *.svg ignore above.
!docs/assets/dps-icon.svg
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## CanonicalMapper
- Single-phase (Identify-only) — no projection phase
- `analyze(results_df, min_severity='WARNING')` — COLLISION_SAME_BRANCH (INFO) hidden unless `min_severity='INFO'`
- `analyze(results_df, min_severity='WARNING')` — informational COLLISION_SAME_BRANCH issues are hidden unless `min_severity='INFO'`; mixed gold depths are ERROR
- `get_mapped_results_dataframe()` returns `MappedResults` (frozen dataclass with `.original`, `.binary`, `.branch`, `.detailed`)
- `get_mapping()` returns `{label: resolved}` dict — UNRESOLVED labels excluded
- `get_issues()` filters by `_min_severity` — call after `analyze()` or `map()`
Expand All @@ -18,7 +18,7 @@
- COLLISION_CROSS_BRANCH (WARNING, blocking) — only raised when cross-branch co-occurrences outnumber same-branch ones for the prediction label
- PREDICTION_ONLY (WARNING, blocking)
- DATASET_ONLY (WARNING, non-blocking)
- COLLISION_SAME_BRANCH (INFO, non-blocking)
- COLLISION_SAME_BRANCH (INFO when projection is unambiguous; ERROR when gold mixes depths on one branch)

## _Resolution dataclass fields
- `tier` — identification tier (EXACT, COUNTRY, COUNTRY_FALLBACK, FUZZY, UNRESOLVED)
Expand Down
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,45 @@

## Unreleased

## Version 0.3.2
### Features

- **Python 3.14 support** — `requires-python` is now `>=3.11,<3.15`, `uv.lock` has been regenerated for the wider range, and the locked `spacy` moves to 3.8.16, the first release that declares 3.14 support. CI runs the test suite on 3.11 through 3.14.

### Behavior Changes

- **Predictions are projected to the deepest annotated ancestor during canonical mapping** — the gold vocabulary decides the granularity, per prediction. A `NAME` prediction is mapped to `PERSON` when the dataset annotates `PERSON`, and `DATE` is mapped to `DATE_TIME` when the dataset annotates `DATE_TIME`. A prediction with no annotated ancestor is left unchanged, so a coarser prediction is never pushed down onto a finer gold label and siblings are never conflated. Datasets that annotate several depths on one branch (e.g. `PERSON` and `TITLE` in `data/synth_dataset_v2.json`) need no mapping decision: `TITLE` predictions stay `TITLE` while `NAME` predictions become `PERSON`, so every annotated depth keeps its own metrics. Mixed annotation depths are reported as an INFO issue. Low-IoU errors are attributed to the projected scoring label.
- **Two-sided (asymmetric) span counting in `SpanEvaluator`** — recall is now counted per annotation and precision per prediction span, replacing per-annotation counting of predictions that could count one prediction span several times (once per annotation it overlapped) or count a group of spans as a single prediction. Every annotation gets exactly one verdict (`TP + FN == num_annotated`), and every prediction span enters `num_predicted` exactly once, as either credited or FP. Precision is now `(num_predicted - false_positives) / num_predicted`; `true_positives` counts covered annotations and may exceed the number of credited predictions (one wide span covering two annotations is two recall hits but one credited prediction), so `true_positives / num_predicted` is no longer a valid precision formula for downstream consumers. Practical effects: a group of same-type spans that jointly fail the combined-IoU test now counts one FP per span (previously one per group); a too-wide span missing several annotations counts one FP (previously one per missed annotation); a span that matches one annotation and merely brushes another is no longer punished twice (FN only, no extra FP). Fixes the old inconsistency where an annotation could be counted as both FN and TP, and `num_predicted` could drift above or below the actual number of predicted spans depending on gold layout.
- **Single-span coverage uses exact pairwise IoU** — when exactly one prediction overlaps an annotation, coverage is measured with the exact pairwise `Span.iou`; the combined-IoU path (which slightly inflates values at span boundaries) is reserved for genuine multi-span coverage. Borderline single-span matches at a threshold boundary may flip compared to previous releases (e.g. IoU 0.4706 previously computed as 0.50 no longer passes τ=0.5).
- **One confusion-matrix cell per span** — a wrong-type detection at IoU >= threshold is recorded as a single `(annotation type, predicted type)` cell representing both the gold and the prediction; neither is additionally written to the `"O"` row/column. Documented in `docs/span_matching_strategies.md`.

### Bug Fixes

- **Hierarchy projection now honours a custom hierarchy** — the full-depth view used for branch and detailed projection was built from a module-level default hierarchy, so a `CanonicalMapper` constructed with a custom `EntityHierarchy` projected against the built-in taxonomy instead of its own. The full-depth view is now derived from the mapper's configured hierarchy.

## Version 0.3.2

### Features

- **Branch-level aliases** — non-leaf hierarchy nodes can now declare raw aliases via a reserved `_aliases` key (e.g. `"LOCATION": {"_aliases": ["LOC"], ...}`), mirroring the alias lists that leaf nodes already have. `add_alias()` on a branch node now records the alias instead of creating a spurious child leaf. The reserved key is skipped by every tree-walk, so it never becomes a canonical entity.

### Breaking Changes

- **`LOC`, `ORG` and `PER` are no longer canonical entities** — they were empty leaf nodes under `LOCATION`/`ORGANIZATION`/`PERSON` > `NAME` and are now branch-level aliases of `LOCATION`/`ORGANIZATION`/`PERSON`. Coarse dataset labels like TAB's `LOC`/`ORG`/`PER` therefore match a model's `LOCATION`/`ORGANIZATION`/`PERSON` at the exact (leaf) level, not only at the branch level. Concretely:
- `canonicalize("LOC")` returns `"LOCATION"` (was `"LOC"`), and likewise for `ORG` and `PER`.
- `LOC`/`ORG`/`PER` no longer appear in `all_canonical_entities` or `canonical_to_branch`.
- `get_depth("LOC")` returns `2` (was `3`), because `LOC` now denotes the depth-2 `LOCATION` branch. `get_depth("PER")` returns `2` (was `3`).
- `CanonicalMapper.map()` no longer accepts `LOC`/`ORG`/`PER` as resolution *targets*, since targets must be canonical entities. Such mappings are also no longer needed — the labels resolve on their own.
- `to_branch("LOC")` still returns `"LOCATION"`, unchanged; `to_branch("PER")` still returns `"PERSON"`.

### Behavior Changes

- **`to_branch()` and `get_depth()` now resolve raw aliases**, not just canonical names. Previously a raw alias (e.g. `COMPANYNAME`, `QQ`) was passed through unchanged by `to_branch` and raised in `get_depth`; both now resolve it first. Unknown labels are still returned as-is by `to_branch`.
- **`add_alias()` accepts an alias as its subject**, so `add_alias("LOC", ...)` works as well as `add_alias("LOCATION", ...)`.
- **`add_alias()` now raises `ValueError` instead of silently no-opping** when the alias is already claimed by a descendant of the target (e.g. adding `CITY` to the `LOCATION` branch, where `CITY` already resolves to `ADDRESS`). The hierarchy is left unmodified — an alias the target already owns is preserved. It also raises `KeyError` if the reserved `_aliases` key is passed as the entity name.
- **A branch alias shadowed by one of its own descendants logs a warning at construction time**, so collisions declared statically in `definitions.py` are no longer silent.

### Bug Fixes

- **Span merging no longer depends on the DataFrame index** — `SpanEvaluator` mixed sentence-relative token positions with DataFrame index labels when checking whether two same-type spans are adjacent. With the global index produced by `predict_dataset()`, the between-tokens lookup read the wrong rows — or none at all — for every sentence except the one starting at row 0, and an empty lookup counts as "adjacent", silently merging same-type spans separated by regular words (e.g. the two PERSON spans in "John visited Berlin with Mary" became one). Span counts (`num_annotated`, `num_predicted`, `true_positives`) were deflated symmetrically for gold and predictions, so headline precision/recall could still look plausible. The evaluator now uses sentence-relative positions throughout and produces identical results for any DataFrame index.

## Version 0.3.1
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ It also includes a fake data generator that creates synthetic sentences based on


### Using notebooks
The easiest way to get started is by reviewing the notebooks.
The easiest way to get started is by reviewing the notebooks.

- [Notebook 1](notebooks/1_Generate_data.ipynb): Shows how to use the PII data generator.
- [Notebook 2](notebooks/2_PII_EDA.ipynb): Shows a simple analysis of the PII dataset.
- [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).
Expand All @@ -36,6 +37,7 @@ python -m spacy download en_core_web_lg # for NER
#### From source

To install the package:

1. Clone the repo
2. Install all dependencies:

Expand Down Expand Up @@ -67,13 +69,13 @@ Note that some dependencies (such as Flair and Stanza) are no longer supported.

## 1. Data generation

See [Data Generator README](presidio_evaluator/data_generator/README.md) for more details.
See the [Data Generation docs](docs/data_generation.md) for more details.

The data generation process takes a file with templates, e.g. `My name is {{name}}`.
Then, it creates new synthetic sentences by sampling templates and PII values.
Furthermore, it tokenizes the data, creates tags (either IO/BIO/BILUO) and spans for the newly created samples.

- For information on data generation/augmentation, see the data generator [README](presidio_evaluator/data_generator/README.md).
- For information on data generation/augmentation, see the [Data Generation docs](docs/data_generation.md).
- For an example for running the generation process, see [this notebook](notebooks/1_Generate_data.ipynb).
- For an understanding of the underlying fake PII data used, see this [exploratory data analysis notebook](notebooks/2_PII_EDA.ipynb).

Expand All @@ -89,6 +91,7 @@ analyzing, modeling and evaluating data and models. Specifically,
see [data_objects.py](presidio_evaluator/data_objects.py).

The standardized structure, `List[InputSample]`, can be translated into different formats:

- CoNLL
- To CoNLL:
```python
Expand Down
1 change: 1 addition & 0 deletions docs/CNAME
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
presidio-research.dataprivacystack.org
4 changes: 3 additions & 1 deletion docs/adr/ADR-002-entity-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The core use case is **comparing multiple models against the same dataset**. The
the evaluation contract; models are the variable.

A flat `dict[str, str]` mapping is insufficient because:

- Many labels are aliases for the same concept (`FIRST_NAME`, `NAME_GIVEN`, `GIVENNAME` → `NAME`). Maintaining a hand-crafted dict for hundreds of model vocabularies is burdensome.
- Labels exist in a hierarchy — `NAME` is a sub-type of `PERSON`. A model predicting `PERSON` on a `NAME`-annotated token is partially correct, not wrong. A flat dict cannot express this.
- Unresolved labels need to be surfaced and triaged before evaluation; a dict silently drops or mismaps them.
Expand Down Expand Up @@ -60,7 +61,7 @@ BIO/BIOES/BILOU prefixes are stripped transparently before lookup (`B-PERSON`
| **COLLISION_CROSS_BRANCH** | WARNING | A prediction label and annotation label co-occur on the same tokens but map to different hierarchy branches. This may be a **vocabulary mismatch** (e.g., the model calls it `ORG` while the dataset calls it `COMPANY`) that can be fixed with `map()`. Even when not remapped, it is surfaced so the user is aware of the mismatch when interpreting results. | Surfaced in audit table with token counts; does not block |
| **PREDICTION_ONLY** | WARNING | Prediction entity in hierarchy but never annotated by the dataset | Surfaced in audit table; does not block |
| **DATASET_ONLY** | WARNING | Annotation entity never predicted by the model (nor any descendant) | Surfaced in audit table; does not block |
| **COLLISION_SAME_BRANCH** | INFO | A prediction label and annotation label co-occur on the same tokens and map to the **same hierarchy branch** but at different depths (e.g., model predicts `PERSON`, dataset annotates `NAME`). This is not a mapping problemit is handled correctly by the hierarchical evaluation step (see ADR-003). | Shown inline in audit table for awareness; does not block |
| **COLLISION_SAME_BRANCH** | INFO | Annotation and prediction labels use the **same hierarchy branch** at different depths. `CanonicalMapper` projects each prediction to the deepest annotated ancestor of its own label, so this is never a mapping decisionincluding when the annotations themselves mix depths on one branch. | Surfaced in audit table for awareness; does not block |

Issues are ordered by severity (ERROR > WARNING > INFO), then by affected token count (descending).

Expand All @@ -76,6 +77,7 @@ By default, issues at WARNING level and above are surfaced. The user can control

`get_mapped_results_dataframe()` raises `IncompleteMapping` only if `UNRESOLVED` issues remain.
To resolve:

- `mapper.map({"MY_LABEL": "CANONICAL"})` — map to a known hierarchy entity
- `mapper.map({"MY_LABEL": None})` — suppress from evaluation entirely

Expand Down
Loading
Loading