Skip to content

Commit 8efffd1

Browse files
authored
Fix index-sensitive span merging in SpanEvaluator (#184)
1 parent 141a94e commit 8efffd1

5 files changed

Lines changed: 56 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
## Version 0.3.2
6+
7+
### Bug Fixes
8+
9+
- **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.
10+
511
## Version 0.3.1
612

713
### Bug Fixes

presidio_evaluator/evaluation/span_evaluator.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,14 @@ def _are_spans_adjacent(self, span1: Span, span2: Span, df: pd.DataFrame) -> boo
154154
:param df: DataFrame containing the tokens
155155
:return: True if spans are adjacent, False otherwise
156156
"""
157-
# Slice tokens between span1 and span2 using the row indices
158-
between_tokens = df.loc[
159-
span1.token_end : (span2.token_start or 0) - 1,
160-
"token",
161-
].tolist()
157+
# token_start/token_end are positions within the sentence, so slice
158+
# positionally — the DataFrame's index labels are caller-defined
159+
# (e.g. global across sentences) and must not be used as positions.
160+
if span1.token_end is None or span2.token_start is None:
161+
raise ValueError(
162+
"Spans must have token_start/token_end set to check adjacency",
163+
)
164+
between_tokens = df["token"].iloc[span1.token_end : span2.token_start].tolist()
162165
non_skip_tokens = [
163166
tok for tok in between_tokens if tok.lower().strip() not in self.skip_words
164167
]
@@ -612,19 +615,11 @@ def _create_spans(self, df: pd.DataFrame, column: str) -> list[Span]:
612615
current_tokens = []
613616
current_start_indices = []
614617
current_token_start: int = 0
615-
curr_char_position = 0
616-
token_position = 0 # Add token position counter
617618

618619
for idx, (_, row) in enumerate(df.iterrows()):
619620
entity_type = row[column]
620621
token = row["token"]
621622
token_start = row["start_indices"]
622-
token_length = len(token)
623-
# If this isn't the first token, add space before it
624-
if idx > df.index[0]:
625-
curr_char_position += 1 # Account for space between tokens
626-
627-
token_end = curr_char_position + token_length
628623

629624
if entity_type == "O":
630625
if current_entity_type and current_tokens:
@@ -648,8 +643,6 @@ def _create_spans(self, df: pd.DataFrame, column: str) -> list[Span]:
648643
current_start_indices = []
649644
current_token_start = 0
650645

651-
curr_char_position = token_end
652-
token_position += 1 # Increment token position
653646
continue
654647

655648
if entity_type != current_entity_type:
@@ -677,8 +670,6 @@ def _create_spans(self, df: pd.DataFrame, column: str) -> list[Span]:
677670
else:
678671
current_tokens.append(token)
679672
current_start_indices.append(token_start)
680-
curr_char_position = token_end
681-
token_position += 1 # Increment token position
682673

683674
# Handle final span
684675
if current_entity_type and current_tokens:
@@ -693,7 +684,7 @@ def _create_spans(self, df: pd.DataFrame, column: str) -> list[Span]:
693684
start_indices=current_start_indices,
694685
token_start=current_token_start,
695686
current_tokens=current_tokens,
696-
idx=df.index[-1] + 1,
687+
idx=len(df),
697688
normalized_start_indices=normalized_start_indices,
698689
normalized_tokens=normalized_tokens,
699690
),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "presidio_evaluator"
3-
version = "0.3.1"
3+
version = "0.3.2"
44
description = "A framework for evaluating Presidio's Named Entity Recognition performance"
55
readme = "README.md"
66
license = {text = "MIT"}

tests/evaluation/test_span_evaluator.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,3 +1740,42 @@ def test_positional_tokens_length_mismatch_raises():
17401740
)
17411741
with pytest.raises(ValueError, match="normalized"):
17421742
SpanEvaluator._positional_tokens(span)
1743+
1744+
1745+
def test_multi_sentence_df_does_not_merge_separated_same_type_spans(span_evaluator):
1746+
"""Same-type spans separated by regular words must never be merged, regardless
1747+
of where their sentence sits in the dataset.
1748+
"""
1749+
sentence_tokens = ["John", "visited", "Berlin", "with", "Mary", "yesterday"]
1750+
sentence_tags = ["PERSON", "O", "O", "O", "PERSON", "O"]
1751+
1752+
rows = []
1753+
for sentence_id in (0, 1):
1754+
char_position = 0
1755+
for token, tag in zip(sentence_tokens, sentence_tags):
1756+
rows.append(
1757+
{
1758+
"sentence_id": sentence_id,
1759+
"token": token,
1760+
"annotation": tag,
1761+
"prediction": tag,
1762+
"start_indices": char_position,
1763+
}
1764+
)
1765+
char_position += len(token) + 1
1766+
1767+
# Default global RangeIndex (0..11), exactly as predict_dataset returns it.
1768+
df = pd.DataFrame(rows)
1769+
1770+
result = span_evaluator.calculate_score_on_df(results_df=df, level="entity")
1771+
person = result.per_type["PERSON"]
1772+
1773+
# Two PERSON spans per sentence, two sentences: "visited Berlin with" is not
1774+
# skip-word filler, so nothing may be merged.
1775+
assert person.num_annotated == 4, (
1776+
f"Expected 4 annotated PERSON spans (2 per sentence), got "
1777+
f"{person.num_annotated} — same-type spans were merged across "
1778+
f"intervening non-skip tokens"
1779+
)
1780+
assert person.num_predicted == 4
1781+
assert person.true_positives == 4

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)