Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fcfec9d
Initial plan
Copilot Mar 21, 2026
95f230c
Add ADR-001: Simplified Evaluation Pipeline Using DataFrame as Clean …
Copilot Mar 21, 2026
b59cded
Simplify evaluation pipeline and update model prediction
omri374 Mar 21, 2026
1df517c
docs: add ADR-002 for entity mapping via CanonicalMapper
Copilot Mar 21, 2026
3e900aa
docs: revise ADR-002 per review feedback
Copilot Mar 21, 2026
6ca1cac
Add hierarchical entity mapping dictionary
omri374 Mar 21, 2026
e1b5884
Update entity mapping details in ADR-002
omri374 Mar 21, 2026
ec19dc7
Refactor CanonicalMapper usage examples
omri374 Mar 21, 2026
87a9d8e
Update docs/adr/ADR-001-simplified-evaluation-pipeline.md
omri374 Mar 22, 2026
cddbdda
docs: move DOMAIN, WEB, URL, WEBSITE to NETWORK_IDENTIFIER
Copilot Mar 22, 2026
e0daa3c
Update docs/adr/ADR-001-simplified-evaluation-pipeline.md
omri374 Mar 22, 2026
d7d95e9
docs: update CanonicalMapper API and map_entities interface per revie…
Copilot Mar 22, 2026
6d6866f
docs: remove HOSPITAL alias from ORGANIZATION.COMPANY
Copilot Mar 22, 2026
151acad
Apply suggestions from code review
omri374 Mar 22, 2026
44adbc0
docs: remove MRN alias from PHI.PATIENT_ID
Copilot Mar 22, 2026
a56c6ac
Add example mappings for entity labels
omri374 Mar 22, 2026
c74e418
Enhance ADR-001 with additional evaluation details
omri374 Mar 24, 2026
5fdf500
Enhance evaluation pipeline with hierarchy scoring
omri374 Mar 24, 2026
b400004
Update ADR-001-simplified-evaluation-pipeline.md
omri374 Mar 24, 2026
7c600b6
Modify prediction method to return DataFrame
omri374 Mar 24, 2026
191f330
Enhance ADR-002 with input format and usage updates
omri374 Mar 24, 2026
6725db7
Enhance ADR-001 with multi-hierarchical evaluation example
omri374 Mar 24, 2026
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
134 changes: 134 additions & 0 deletions docs/adr/ADR-001-simplified-evaluation-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# ADR-001: Simplified Evaluation Pipeline Using DataFrame as Clean Interface

## Status

Proposed

## Date

2026-03-21

## Context

The current evaluation pipeline in `presidio_evaluator` is tightly coupled and requires several intermediate steps to go from a dataset and a model to evaluation metrics. The full flow today is:

```
Dataset (List[InputSample])
→ Evaluator(model=model) # model is embedded in evaluator
→ evaluator.evaluate_all(dataset) # runs inference AND builds per-sample EvaluationResult objects
→ List[EvaluationResult] # intermediate per-sample carriers
→ evaluator.calculate_score(...) # calls get_results_dataframe() → DataFrame → scoring
→ EvaluationResult (aggregated)
```

This design has four concrete pain points:

1. **Model is coupled to the Evaluator** — `BaseEvaluator.__init__` takes a `model` argument, and `evaluate_all()` calls `model.batch_predict`, `model.filter_tags_in_supported_entities`, and `model.to_scheme` internally. While it is technically possible to evaluate a pre-computed result set via `SpanEvaluator(model=None)` and `calculate_score_on_df()` on a results DataFrame, this coupling makes that path non-obvious and discourages treating the DataFrame-based interface as a first-class entry point.

2. **`evaluate_all()` does two things** — it runs model inference AND builds per-sample `EvaluationResult` objects. These objects are simple data carriers holding `(tokens, actual_tags, predicted_tags, start_indices)`, yet they require callers to go through the evaluator just to get predictions into a usable shape.

3. **The real interface already exists but is buried** — for span-level evaluation, `SpanEvaluator.calculate_score()` internally calls `get_results_dataframe()` to convert `List[EvaluationResult]` to a DataFrame, and then calls `SpanEvaluator.calculate_score_on_df()`. The `EvaluationResult` list is a wasteful intermediate step; the DataFrame is the actual computational surface.

4. **Entity mapping logic is scattered** — entity type remapping lives in `BaseModel.align_entity_types`, `BaseEvaluator.align_entity_types` (static method), and is applied independently in both models and evaluators. There is no single, composable entry point for this transformation.

A new contributor trying to run an evaluation must understand all of these layers before being able to get a single metric out of the system. The goal of this ADR is to establish a clean, linear pipeline with a single well-defined interface point between the model and the evaluator.

## Decision

Adopt the **results DataFrame** as the canonical interface boundary between the model stage and the evaluation stage. The DataFrame has the following five-column schema:

| Column | Type | Description |
|---|---|---|
| `sentence_id` | `int` | Index of the source sentence in the dataset |
| `token` | `str` | Token string |
| `annotation` | `str` | Ground-truth entity tag (from `InputSample.tags`) |
| `prediction` | `str` | Model-predicted entity tag |
| `start_indices` | `int` | Character start offset of the token within the sentence |

This schema is already produced by `get_results_dataframe()` and consumed by `calculate_score_on_df()`. The changes below make it the first-class output of the model stage instead of an internal implementation detail of the evaluator.

### Proposed simplified pipeline (5 steps)

```python
# 1. Load dataset
dataset = InputSample.read_dataset_json("data/dataset.json")

# 2. Choose model and run predictions → get DataFrame directly
Comment thread
omri374 marked this conversation as resolved.
model = PresidioAnalyzerWrapper(analyzer_engine=AnalyzerEngine())
results_df = model.predict_dataset(dataset) # NEW: returns the DataFrame directly

# 3. Map entities (transforms both predictions and annotations into canonical entities)
# ENTITY_MAPPING can be defined in config or as a shared constant.
results_df = map_entities(results_df, mapping=ENTITY_MAPPING)

# 4. Score
evaluator = SpanEvaluator() # no model argument needed!
results = evaluator.calculate_score_on_df(per_type=True, results_df=results_df)

# 5. Analyze/plot
plotter = Plotter(results=results)
plotter.plot_scores()
```

### Concrete changes

| Component | Current | Proposed |
|---|---|---|
| `BaseModel` | `predict()` → `List[str]` tags per sample | Add `predict_dataset()` → `pd.DataFrame` with the 5-column schema |
| `BaseEvaluator` | `__init__(model=...)`, `evaluate_all(dataset)` | `model` becomes optional; primary entry point becomes `calculate_score_on_df()` |
| Entity mapping | Scattered across `BaseModel`, `BaseEvaluator`, and `InputSample` | Single `map_entities(df, mapping)` utility operating on the DataFrame |
| `evaluate_all()` | Required step in the pipeline | Becomes a convenience wrapper (calls `predict_dataset` then `calculate_score_on_df`) |
| `EvaluationResult` (per-sample) | Carrier object for tokens/tags | Eliminated — the DataFrame IS the carrier |
Comment thread
omri374 marked this conversation as resolved.


## Consequences

### Positive

- **Reduced conceptual overhead** — new contributors only need to understand `predict_dataset` + `calculate_score_on_df`; the full evaluator internals are optional knowledge.
- **Decoupled model and evaluator** — models and evaluators can be developed, tested, and swapped independently. `SpanEvaluator` and `TokenEvaluator` no longer require a `model` instance.
- **Composable entity mapping** — `map_entities` is a pure function on DataFrames; it can be chained, applied selectively, or omitted without touching model or evaluator configuration.
- **Easier offline/batch evaluation** — a results DataFrame can be saved to disk and re-evaluated later without re-running inference. This is currently impossible without serializing `List[EvaluationResult]` objects.
- **`calculate_score_on_df` already exists and is tested for spans** — `SpanEvaluator.calculate_score_on_df` is already implemented and covered by tests in `test_span_evaluator.py`. A similar API can be added for `TokenEvaluator` as part of this ADR.
- **Eliminates the `EvaluationResult` per-sample carrier** — removes a class whose only purpose is to ferry data between `evaluate_all()` and `get_results_dataframe()`.

### Negative / Trade-offs

- **`predict_dataset` materializes all predictions in memory** — for very large datasets, the full DataFrame is held in RAM. The current `evaluate_all()` loop can in principle be streamed (though it isn't today).
- **`evaluate_all()` becomes a thin wrapper** — code that currently calls `evaluate_all()` and inspects `List[EvaluationResult]` directly will need to be updated if it relies on the per-sample `EvaluationResult` structure. Backward compatibility is preserved at the call site, but the internal representation changes.
- **Schema enforcement is implicit** — the 5-column schema is a convention, not enforced by a type. Callers that hand-construct DataFrames must respect column names and types.
- **`EvaluationResult` (per-sample) removal is a breaking change** — any downstream code (e.g., notebooks, scripts) that imports or type-annotates `EvaluationResult` will need updating. A deprecation period should be provided.

## Implementation Plan

1. **Add `BaseModel.predict_dataset()`** — implement the method as sketched above in `presidio_evaluator/models/base_model.py`. Add a unit test in `tests/` that verifies the 5-column schema and correct row count for a small synthetic dataset.

2. **Add `map_entities()` utility** — add the function (and `Dict` import) to `presidio_evaluator/evaluation/` (e.g., in a new `utils.py` or alongside `get_results_dataframe`). Add a unit test verifying that both `annotation` and `prediction` columns are remapped.
Comment thread
omri374 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add the mapping to results dataframe. If we allow users to define their own mapping, I think that we need to make sure that both predicted and annotated entities are mapped to the same canonical mapping (if they really are a match).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's what I was thinking too. Essentially the only place where we change entities to their canonical form is this dataframe. This would simplify the flow today which does mapping in different parts of the flow.

in any case, the entity mapper generates one mapping for both the annotation and prediction, so they would have to be mapped the same way

@omri374 omri374 Mar 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this look?

# 1. Load dataset
dataset = InputSample.read_dataset_json("data/dataset.json")

# 2. Choose model and run predictions → get DataFrame directly
model = PresidioAnalyzerWrapper(analyzer_engine=AnalyzerEngine())
results_df = model.predict_dataset(dataset)  # NEW: returns the DataFrame directly

# 3. Map entities (transforms both predictions and annotations into canonical entities)
mapper = CanonicalMapper()

# 4. Map to hierarchy (PII, High level, canonical, specific) and evaluate
evaluator = SpanEvaluator()
results_per_hierarchy = []
for hierarchy in [1,2,3]):
    results_df_hierarchy = mapper.map_entities(results_df, hierarchy=hierarchy)
    results_per_hierarchy = evaluator.calculate_score_on_df(results_df=results_df_hierarchy)

# 5. Analyze/plot
plotter = Plotter(results=results_per_hierarchy[0])
plotter.plot_scores()

@omri374 omri374 Mar 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, one can just use the default (hierarchy=3) and run the experiment, which is simpler:

# 1. Load dataset
dataset = InputSample.read_dataset_json("data/dataset.json")

# 2. Choose model and run predictions
model = PresidioAnalyzerWrapper(analyzer_engine=AnalyzerEngine())
results_df = model.predict_dataset(dataset)

# 3. Map entities (transforms both predictions and annotations into canonical entities)
mapper = CanonicalMapper()

# 4. Map to hierarchy (PII, High level, canonical, specific) and evaluate
evaluator = SpanEvaluator()

results_df_mapped = mapper.map_entities(results_df)
results = evaluator.calculate_score_on_df(results_df=results_df_mapped)

# 5. Analyze/plot
plotter = Plotter(results=results)
plotter.plot_scores()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me :) BTW, what if a label is too general for the chosen canonical depth? Should we handle this case?

@omri374 omri374 Mar 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question! So if the user has a model with ["PERSON", "LOCATION"] and the dataset has ["STREET_ADDRESS", "NAME"], how should we map the two? PERSON and LOCATION are level 2, but we want to map to level 3. In this case, maybe if one of the entities is level 2, we should map everything to level 2? Or is this too naive?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we can choose one level 3 entity that a level 2 entity would be mapped to (like "NAME" for "PERSON" or "ADDRESS" to "LOCATION")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, where the model's and dataset's levels of depth are different, should both mappers be coordinated when deciding on the depth? Assuming we have a mapper for the model and another one for the dataset and there is some auto-downgrade done. Also, what if the model includes both level 2 and level 3 entities? Does it make sense to downgrade per category only where there's a depth mismatch? It might complicate things

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the proposed approach here is to have one mapper for everything. If there level 2 entities + level 3 entities, we would go with level 2. Another option is to just change the branch that has level 2 entities and keep everything else at level 3. So if the dataset has PERSON (level 2), ADDRESS (level 3) and some other level 3, and the model has FIRST_NAME, PREFIX, LAST_NAME, (level 3 under PERSON), then these would be mapped to PERSON while the other level 3 entities under other branches (like the LOCATION branch) would be mapped to level 3.
Sorry... this is becoming too complicated :)


3. **Make `model` optional in `BaseEvaluator`** — change `BaseEvaluator.__init__(self, model=None, ...)` so that `model` defaults to `None`, relying on the existing runtime check in `evaluate_all()` that raises a clear error when `model is None`.

4. **Update `evaluate_all()` to delegate to `predict_dataset` + `calculate_score_on_df`** — refactor `SpanEvaluator.evaluate_all()` and `TokenEvaluator.evaluate_all()` to call `self.model.predict_dataset(dataset)` and then pass the result to `calculate_score_on_df()`. This ensures a single code path for both old and new usage.
Comment thread
omri374 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't evaluate_all() only part of BaseEvaluator?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it is. Good catch. I'll update this comment, but the principle is the same- we try to keep the existing interface for backward compatibility but it would essentially call the new flow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So technically evaluate_all() returns the dataframe?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, maybe we can just deprecate it. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we call predict_dataset and then calculate_score_on_df, wouldn't evaluate_all be redundant? If we keep it for backward compatibility, I think we should leave it as is (return EvaluationResult)


5. **Deprecate the per-sample *usage* of `EvaluationResult`** — keep `EvaluationResult` as the aggregated-metrics return type, but stop using it as an intermediate per-sample carrier. Mark the per-sample–only fields/usages (e.g., the path that produces `List[EvaluationResult]` for individual samples) with a `DeprecationWarning`, and update `get_results_dataframe()` docs to note that this per-sample usage will be removed in a future release and replaced by the DataFrame-based pipeline (or, if needed, a dedicated `SampleEvaluationResult` type introduced later).

6. **Update documentation and notebooks** — revise `docs/evaluation.md` and any Jupyter notebooks under `notebooks/` to demonstrate the new 5-step pipeline. Keep the old `evaluate_all()` example with a note that it is the backward-compatible convenience path.

7. **Add integration test** — add an end-to-end test that runs `predict_dataset` → `map_entities` → `calculate_score_on_df` on a small synthetic dataset and asserts that the returned metrics match expected values.

## Alternatives Considered

### Keep the current architecture and add a thin adapter function

A helper function like `run_evaluation(model, dataset, evaluator)` could hide the complexity without changing any classes. This was rejected because it does not address the root coupling: `evaluate_all()` would still conflate inference with result formatting, and entity mapping would remain scattered.

### Use a custom result object instead of a DataFrame

A typed dataclass or `NamedTuple` (e.g., `TokenPrediction`) could replace the per-sample `EvaluationResult`. This was rejected because `calculate_score_on_df` already exists and is the correct computational surface. Introducing another intermediate type would add a conversion step rather than remove one.

### Make `BaseModel` return a list of tagged spans instead of a DataFrame

Returning `List[List[Tuple[str, str, str, int]]]` (one list per sentence) would preserve laziness but lose the benefits of columnar operations and Pandas compatibility. Rejected because the DataFrame is already the internal format used for all scoring, and forcing a conversion at the last moment adds no value.

### Move entity mapping into `BaseModel.predict_dataset` as a parameter

Passing `mapping` directly to `predict_dataset(dataset, entity_mapping=...)` would keep mapping close to prediction. This was rejected in favor of a standalone `map_entities` utility because it makes the transformation composable and testable in isolation, and because mapping is a property of the evaluation goal (what entities to score), not of the model.
Loading
Loading