cascabel-ner 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cascabel/__init__.py +86 -0
- cascabel/__main__.py +7 -0
- cascabel/alignment.py +363 -0
- cascabel/bootstrap.py +131 -0
- cascabel/card.py +182 -0
- cascabel/cascabel.py +1154 -0
- cascabel/charlm.py +325 -0
- cascabel/cli.py +150 -0
- cascabel/constants.py +15 -0
- cascabel/dimensions.py +186 -0
- cascabel/doc.py +282 -0
- cascabel/extensions.py +333 -0
- cascabel/metrics.py +66 -0
- cascabel/perturb.py +145 -0
- cascabel/report.py +629 -0
- cascabel/shape.py +41 -0
- cascabel_ner-0.8.0.dist-info/METADATA +247 -0
- cascabel_ner-0.8.0.dist-info/RECORD +22 -0
- cascabel_ner-0.8.0.dist-info/WHEEL +5 -0
- cascabel_ner-0.8.0.dist-info/entry_points.txt +2 -0
- cascabel_ner-0.8.0.dist-info/licenses/LICENSE +21 -0
- cascabel_ner-0.8.0.dist-info/top_level.txt +1 -0
cascabel/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""cascabel — fine-grained error analysis for NER and span-labeling models.
|
|
2
|
+
|
|
3
|
+
Instead of a single F1 score, cascabel aligns predictions against gold
|
|
4
|
+
annotations, classifies every disagreement into a fine-grained error taxonomy
|
|
5
|
+
(missed, spurious, type swap, fused, split, missegmented, partial overlaps),
|
|
6
|
+
profiles datasets along linguistic dimensions (length, casing, position,
|
|
7
|
+
quotation, seen/unseen, surprisal, prototypicality) and supports controlled perturbations for stress
|
|
8
|
+
testing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from cascabel.alignment import (
|
|
12
|
+
Alignment,
|
|
13
|
+
ErrorType,
|
|
14
|
+
SEGMENTATION_ERRORS,
|
|
15
|
+
align_doc,
|
|
16
|
+
align_sentence,
|
|
17
|
+
)
|
|
18
|
+
from cascabel.bootstrap import bootstrap_scores
|
|
19
|
+
from cascabel.cascabel import Cascabel
|
|
20
|
+
from cascabel.dimensions import (
|
|
21
|
+
CATEGORICAL_ATTRIBUTES,
|
|
22
|
+
ENTITY_DIMENSIONS,
|
|
23
|
+
LENGTH_BINS,
|
|
24
|
+
NUMERIC_ATTRIBUTES,
|
|
25
|
+
DimensionStats,
|
|
26
|
+
dimension_report,
|
|
27
|
+
)
|
|
28
|
+
from cascabel.doc import (
|
|
29
|
+
bio_tag,
|
|
30
|
+
doc_from_conll,
|
|
31
|
+
doc_from_sentences,
|
|
32
|
+
doc_from_spacy_binary,
|
|
33
|
+
doc_from_tokens,
|
|
34
|
+
fold_text,
|
|
35
|
+
load_wordlist,
|
|
36
|
+
occurrence_label,
|
|
37
|
+
read_conll,
|
|
38
|
+
repair_bio,
|
|
39
|
+
repair_bio_sentences,
|
|
40
|
+
to_bio,
|
|
41
|
+
)
|
|
42
|
+
from cascabel.extensions import register_extensions
|
|
43
|
+
from cascabel.metrics import Score
|
|
44
|
+
from cascabel.perturb import add_quotes, recase, strip_quotes
|
|
45
|
+
from cascabel.charlm import CharLM, CharLMConfig, ShapeModels
|
|
46
|
+
from cascabel.shape import word_shape
|
|
47
|
+
|
|
48
|
+
__version__ = "0.8.0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Alignment",
|
|
52
|
+
"CATEGORICAL_ATTRIBUTES",
|
|
53
|
+
"Cascabel",
|
|
54
|
+
"CharLM",
|
|
55
|
+
"CharLMConfig",
|
|
56
|
+
"DimensionStats",
|
|
57
|
+
"ENTITY_DIMENSIONS",
|
|
58
|
+
"ErrorType",
|
|
59
|
+
"LENGTH_BINS",
|
|
60
|
+
"NUMERIC_ATTRIBUTES",
|
|
61
|
+
"SEGMENTATION_ERRORS",
|
|
62
|
+
"Score",
|
|
63
|
+
"ShapeModels",
|
|
64
|
+
"__version__",
|
|
65
|
+
"add_quotes",
|
|
66
|
+
"align_doc",
|
|
67
|
+
"align_sentence",
|
|
68
|
+
"bootstrap_scores",
|
|
69
|
+
"bio_tag",
|
|
70
|
+
"dimension_report",
|
|
71
|
+
"doc_from_conll",
|
|
72
|
+
"doc_from_sentences",
|
|
73
|
+
"doc_from_spacy_binary",
|
|
74
|
+
"doc_from_tokens",
|
|
75
|
+
"fold_text",
|
|
76
|
+
"load_wordlist",
|
|
77
|
+
"occurrence_label",
|
|
78
|
+
"read_conll",
|
|
79
|
+
"recase",
|
|
80
|
+
"register_extensions",
|
|
81
|
+
"repair_bio",
|
|
82
|
+
"repair_bio_sentences",
|
|
83
|
+
"to_bio",
|
|
84
|
+
"strip_quotes",
|
|
85
|
+
"word_shape",
|
|
86
|
+
]
|
cascabel/__main__.py
ADDED
cascabel/alignment.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Alignment of predicted entities against gold entities.
|
|
2
|
+
|
|
3
|
+
Instead of only counting exact matches, cascabel classifies every
|
|
4
|
+
gold/prediction disagreement into a fine-grained error taxonomy:
|
|
5
|
+
|
|
6
|
+
============================ ====================================================
|
|
7
|
+
``missed`` a gold entity with no overlapping prediction
|
|
8
|
+
``spurious`` a prediction with no overlapping gold entity
|
|
9
|
+
``type`` correct boundaries, wrong label
|
|
10
|
+
``fused`` one prediction covering two gold entities
|
|
11
|
+
``split`` two predictions inside one gold entity
|
|
12
|
+
``missegmented`` prediction boundaries straddle gold entity boundaries
|
|
13
|
+
``overlap_missed`` prediction strictly inside a gold entity
|
|
14
|
+
``overlap_spurious`` prediction strictly containing a gold entity
|
|
15
|
+
``overlap_spurious_missed`` staggered overlap (extra tokens on one side,
|
|
16
|
+
missing tokens on the other)
|
|
17
|
+
============================ ====================================================
|
|
18
|
+
|
|
19
|
+
Each alignment carries two scores:
|
|
20
|
+
|
|
21
|
+
- ``span_score``: strict span-level counting (a correct alignment is one TP;
|
|
22
|
+
every gold span involved in an error is one FN, every predicted span one FP).
|
|
23
|
+
- ``boundary_score``: the same counting with labels ignored (unlabelled
|
|
24
|
+
exact match): a predicted span is a TP when its start and end equal a gold
|
|
25
|
+
span's, whatever the label.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import warnings
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from enum import Enum
|
|
33
|
+
from typing import List, Optional, Sequence, Tuple
|
|
34
|
+
|
|
35
|
+
from spacy.tokens import Span, Token
|
|
36
|
+
|
|
37
|
+
from cascabel.doc import ents_by_sentence
|
|
38
|
+
from cascabel.metrics import Score
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ErrorType(str, Enum):
|
|
42
|
+
MISSED = "missed"
|
|
43
|
+
SPURIOUS = "spurious"
|
|
44
|
+
TYPE = "type"
|
|
45
|
+
FUSED = "fused"
|
|
46
|
+
SPLIT = "split"
|
|
47
|
+
MISSEGMENTED = "missegmented"
|
|
48
|
+
OVERLAP_MISSED = "overlap_missed"
|
|
49
|
+
OVERLAP_SPURIOUS = "overlap_spurious"
|
|
50
|
+
OVERLAP_SPURIOUS_MISSED = "overlap_spurious_missed"
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str: # pragma: no cover - convenience only
|
|
53
|
+
return self.value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: Error types that reflect a segmentation problem (boundaries, not labels).
|
|
57
|
+
SEGMENTATION_ERRORS = frozenset(
|
|
58
|
+
{ErrorType.MISSEGMENTED, ErrorType.SPLIT, ErrorType.FUSED}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Span-pair predicates (token offsets; spans within the same Doc)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def is_exact_match(gold: Span, predicted: Span) -> bool:
|
|
67
|
+
return (
|
|
68
|
+
gold.start == predicted.start
|
|
69
|
+
and gold.end == predicted.end
|
|
70
|
+
and gold.label_ == predicted.label_
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_type_swap(gold: Span, predicted: Span) -> bool:
|
|
75
|
+
return (
|
|
76
|
+
gold.start == predicted.start
|
|
77
|
+
and gold.end == predicted.end
|
|
78
|
+
and gold.label_ != predicted.label_
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_fused(gold_first: Span, gold_second: Span, predicted: Span) -> bool:
|
|
83
|
+
"""One prediction spanning from the first gold entity through the second."""
|
|
84
|
+
return predicted.start == gold_first.start and predicted.end >= gold_second.end
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_split(gold: Span, predicted_first: Span, predicted_second: Span) -> bool:
|
|
88
|
+
"""Two predictions falling inside a single gold entity."""
|
|
89
|
+
return gold.start <= predicted_first.start and gold.end >= predicted_second.end
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_contained(gold: Span, predicted: Span) -> bool:
|
|
93
|
+
"""Prediction inside the gold entity (some gold tokens missing)."""
|
|
94
|
+
return predicted.start >= gold.start and predicted.end <= gold.end
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_containing(gold: Span, predicted: Span) -> bool:
|
|
98
|
+
"""Prediction containing the gold entity (some spurious tokens added)."""
|
|
99
|
+
return predicted.start <= gold.start and predicted.end >= gold.end
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_staggered(gold: Span, predicted: Span) -> bool:
|
|
103
|
+
"""Partial overlap with extra tokens on one side and missing on the other."""
|
|
104
|
+
return (
|
|
105
|
+
predicted.start < gold.start < predicted.end < gold.end
|
|
106
|
+
or gold.start < predicted.start < gold.end < predicted.end
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_straddling(gold_first: Span, gold_second: Span, predicted: Span) -> bool:
|
|
111
|
+
"""Prediction starting inside the first gold entity and reaching into the
|
|
112
|
+
second (``end`` is exclusive: a prediction ending right before the second
|
|
113
|
+
entity does not reach it)."""
|
|
114
|
+
return (
|
|
115
|
+
gold_first.start <= predicted.start < gold_first.end
|
|
116
|
+
and predicted.end > gold_second.start
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Alignment record
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Alignment:
|
|
126
|
+
"""One unit of gold/prediction correspondence within a sentence.
|
|
127
|
+
|
|
128
|
+
``gold`` and ``predicted`` hold the spans involved (either may be empty
|
|
129
|
+
for missed/spurious cases). ``error_type`` is ``None`` for a correct
|
|
130
|
+
alignment.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
gold: Tuple[Span, ...]
|
|
134
|
+
predicted: Tuple[Span, ...]
|
|
135
|
+
sentence: Span
|
|
136
|
+
error_type: Optional[ErrorType] = None
|
|
137
|
+
correct_tokens: List[Token] = field(default_factory=list, repr=False)
|
|
138
|
+
wrong_tokens: List[Token] = field(default_factory=list, repr=False)
|
|
139
|
+
span_score: Score = field(init=False, repr=False)
|
|
140
|
+
boundary_score: Score = field(init=False, repr=False)
|
|
141
|
+
|
|
142
|
+
def __post_init__(self) -> None:
|
|
143
|
+
self.gold = tuple(self.gold)
|
|
144
|
+
self.predicted = tuple(self.predicted)
|
|
145
|
+
self._classify_tokens()
|
|
146
|
+
self.span_score = self._compute_span_score()
|
|
147
|
+
self.boundary_score = self._compute_boundary_score()
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_error(self) -> bool:
|
|
151
|
+
return self.error_type is not None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_segmentation_error(self) -> bool:
|
|
155
|
+
return self.error_type in SEGMENTATION_ERRORS
|
|
156
|
+
|
|
157
|
+
# -- token classification -------------------------------------------------
|
|
158
|
+
#
|
|
159
|
+
# Note: predicted spans may belong to the prediction Doc, while gold tags
|
|
160
|
+
# and the ``iob_pred`` overlay live on the test Doc. The two documents are
|
|
161
|
+
# token-aligned, so predicted span offsets are always resolved against
|
|
162
|
+
# ``self.sentence.doc`` (the test Doc) before reading token attributes.
|
|
163
|
+
|
|
164
|
+
def _tokens_at(self, span: Span) -> List[Token]:
|
|
165
|
+
doc = self.sentence.doc
|
|
166
|
+
return [doc[i] for i in range(span.start, span.end)]
|
|
167
|
+
|
|
168
|
+
def _classify_tokens(self) -> None:
|
|
169
|
+
error = self.error_type
|
|
170
|
+
if error is None:
|
|
171
|
+
self.correct_tokens = [tok for span in self.gold for tok in span]
|
|
172
|
+
elif error is ErrorType.MISSED:
|
|
173
|
+
self.wrong_tokens = list(self.gold[0])
|
|
174
|
+
elif error is ErrorType.SPURIOUS:
|
|
175
|
+
self.wrong_tokens = self._tokens_at(self.predicted[0])
|
|
176
|
+
elif error is ErrorType.FUSED:
|
|
177
|
+
# The wrong tokens are those that should have started an entity
|
|
178
|
+
# (gold B) but did not: they caused the fusing.
|
|
179
|
+
for span in self.gold:
|
|
180
|
+
for tok in span:
|
|
181
|
+
if tok.ent_iob_ == "B" and not str(tok._.iob_pred or "").startswith("B"):
|
|
182
|
+
self.wrong_tokens.append(tok)
|
|
183
|
+
else:
|
|
184
|
+
self.correct_tokens.append(tok)
|
|
185
|
+
elif error is ErrorType.SPLIT:
|
|
186
|
+
# The wrong tokens are those that started an entity (predicted B)
|
|
187
|
+
# but should not have: they caused the splitting.
|
|
188
|
+
doc = self.sentence.doc
|
|
189
|
+
start = min(self.gold[0].start, self.predicted[0].start)
|
|
190
|
+
end = max(self.gold[-1].end, self.predicted[-1].end)
|
|
191
|
+
for i in range(start, end):
|
|
192
|
+
tok = doc[i]
|
|
193
|
+
if tok.ent_iob_ != "B" and str(tok._.iob_pred or "").startswith("B"):
|
|
194
|
+
self.wrong_tokens.append(tok)
|
|
195
|
+
else:
|
|
196
|
+
self.correct_tokens.append(tok)
|
|
197
|
+
elif error is ErrorType.MISSEGMENTED:
|
|
198
|
+
gold_texts = {tok.text for span in self.gold for tok in span}
|
|
199
|
+
for span in self.predicted:
|
|
200
|
+
for tok in self._tokens_at(span):
|
|
201
|
+
if tok.text in gold_texts:
|
|
202
|
+
self.correct_tokens.append(tok)
|
|
203
|
+
else:
|
|
204
|
+
self.wrong_tokens.append(tok)
|
|
205
|
+
else:
|
|
206
|
+
# type swaps and the overlap_* family: a token is correct as long
|
|
207
|
+
# as both gold and prediction agree it belongs to an entity.
|
|
208
|
+
doc = self.sentence.doc
|
|
209
|
+
start = min(self.gold[0].start, self.predicted[0].start)
|
|
210
|
+
end = max(self.gold[-1].end, self.predicted[-1].end)
|
|
211
|
+
for i in range(start, end):
|
|
212
|
+
tok = doc[i]
|
|
213
|
+
if tok._.iob_pred not in (None, "O") and tok.ent_iob_ != "O":
|
|
214
|
+
self.correct_tokens.append(tok)
|
|
215
|
+
else:
|
|
216
|
+
self.wrong_tokens.append(tok)
|
|
217
|
+
|
|
218
|
+
# -- scores ---------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _compute_span_score(self) -> Score:
|
|
221
|
+
if self.error_type is None:
|
|
222
|
+
return Score(tp=1)
|
|
223
|
+
if self.error_type is ErrorType.MISSED:
|
|
224
|
+
return Score(fn=1)
|
|
225
|
+
if self.error_type is ErrorType.SPURIOUS:
|
|
226
|
+
return Score(fp=1)
|
|
227
|
+
return Score(fn=len(self.gold), fp=len(self.predicted))
|
|
228
|
+
|
|
229
|
+
def _compute_boundary_score(self) -> Score:
|
|
230
|
+
"""Unlabelled exact match: a gold and a predicted span of this
|
|
231
|
+
alignment with the same start and end are one TP, whatever their
|
|
232
|
+
labels; every other gold span is an FN and every other predicted
|
|
233
|
+
span an FP."""
|
|
234
|
+
predicted = {(p.start, p.end) for p in self.predicted}
|
|
235
|
+
tp = sum((g.start, g.end) in predicted for g in self.gold)
|
|
236
|
+
return Score(tp=tp, fn=len(self.gold) - tp, fp=len(self.predicted) - tp)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# The alignment walk
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def align_sentence(
|
|
244
|
+
sentence: Span,
|
|
245
|
+
gold_entities: Sequence[Span],
|
|
246
|
+
predicted_entities: Sequence[Span],
|
|
247
|
+
) -> List[Alignment]:
|
|
248
|
+
"""Walk gold and predicted entities of one sentence in parallel and
|
|
249
|
+
classify every correspondence. Both sequences must be sorted by start
|
|
250
|
+
offset (spaCy's ``.ents`` already are)."""
|
|
251
|
+
alignments: List[Alignment] = []
|
|
252
|
+
gold = list(gold_entities)
|
|
253
|
+
pred = list(predicted_entities)
|
|
254
|
+
gi = pi = 0
|
|
255
|
+
|
|
256
|
+
def add(golds, preds, error_type):
|
|
257
|
+
alignments.append(Alignment(tuple(golds), tuple(preds), sentence, error_type))
|
|
258
|
+
|
|
259
|
+
while gi < len(gold) and pi < len(pred):
|
|
260
|
+
g, p = gold[gi], pred[pi]
|
|
261
|
+
next_gold = gold[gi + 1] if gi + 1 < len(gold) else None
|
|
262
|
+
next_pred = pred[pi + 1] if pi + 1 < len(pred) else None
|
|
263
|
+
|
|
264
|
+
if is_exact_match(g, p):
|
|
265
|
+
add([g], [p], None)
|
|
266
|
+
gi += 1
|
|
267
|
+
pi += 1
|
|
268
|
+
elif is_type_swap(g, p):
|
|
269
|
+
add([g], [p], ErrorType.TYPE)
|
|
270
|
+
gi += 1
|
|
271
|
+
pi += 1
|
|
272
|
+
elif next_gold is not None and is_fused(g, next_gold, p):
|
|
273
|
+
add([g, next_gold], [p], ErrorType.FUSED)
|
|
274
|
+
gi += 2
|
|
275
|
+
pi += 1
|
|
276
|
+
elif next_pred is not None and is_split(g, p, next_pred):
|
|
277
|
+
add([g], [p, next_pred], ErrorType.SPLIT)
|
|
278
|
+
gi += 1
|
|
279
|
+
pi += 2
|
|
280
|
+
elif is_contained(g, p):
|
|
281
|
+
# e.g. gold "carrot cake" + "vegan friendly", predicted "carrot"
|
|
282
|
+
# + "cake vegan friendly": the boundary drifted right.
|
|
283
|
+
if (
|
|
284
|
+
p.start == g.start
|
|
285
|
+
and p.end < g.end
|
|
286
|
+
and next_pred is not None
|
|
287
|
+
and next_gold is not None
|
|
288
|
+
and next_pred.start < g.end
|
|
289
|
+
):
|
|
290
|
+
add([g, next_gold], [p, next_pred], ErrorType.MISSEGMENTED)
|
|
291
|
+
gi += 2
|
|
292
|
+
pi += 2
|
|
293
|
+
else:
|
|
294
|
+
add([g], [p], ErrorType.OVERLAP_MISSED)
|
|
295
|
+
gi += 1
|
|
296
|
+
pi += 1
|
|
297
|
+
elif is_staggered(g, p):
|
|
298
|
+
# e.g. gold "carrot cake" + "vegan friendly", predicted a single
|
|
299
|
+
# "cake vegan friendly": the first entity was missed and the
|
|
300
|
+
# second missegmented.
|
|
301
|
+
if (
|
|
302
|
+
next_gold is not None
|
|
303
|
+
and g.start < p.start < g.end
|
|
304
|
+
and p.end == next_gold.end
|
|
305
|
+
):
|
|
306
|
+
add([g], [], ErrorType.MISSED)
|
|
307
|
+
add([next_gold], [p], ErrorType.MISSEGMENTED)
|
|
308
|
+
gi += 2
|
|
309
|
+
pi += 1
|
|
310
|
+
else:
|
|
311
|
+
add([g], [p], ErrorType.OVERLAP_SPURIOUS_MISSED)
|
|
312
|
+
gi += 1
|
|
313
|
+
pi += 1
|
|
314
|
+
elif next_gold is not None and is_straddling(g, next_gold, p):
|
|
315
|
+
# A "perfect" missegmentation ends exactly where the second gold
|
|
316
|
+
# entity ends and is recorded as a single segmentation error.
|
|
317
|
+
if next_pred is not None and next_pred.end == next_gold.end:
|
|
318
|
+
add([g, next_gold], [p, next_pred], ErrorType.MISSEGMENTED)
|
|
319
|
+
gi += 2
|
|
320
|
+
pi += 2
|
|
321
|
+
else:
|
|
322
|
+
add([g], [p], ErrorType.MISSEGMENTED)
|
|
323
|
+
gi += 1
|
|
324
|
+
pi += 1
|
|
325
|
+
elif is_containing(g, p):
|
|
326
|
+
add([g], [p], ErrorType.OVERLAP_SPURIOUS)
|
|
327
|
+
gi += 1
|
|
328
|
+
pi += 1
|
|
329
|
+
elif g.start_char < p.start_char:
|
|
330
|
+
add([g], [], ErrorType.MISSED)
|
|
331
|
+
gi += 1
|
|
332
|
+
elif g.start_char > p.start_char:
|
|
333
|
+
add([], [p], ErrorType.SPURIOUS)
|
|
334
|
+
pi += 1
|
|
335
|
+
else: # pragma: no cover - defensive: the cases above are exhaustive
|
|
336
|
+
warnings.warn(
|
|
337
|
+
f"Unclassifiable alignment between gold {g!r} and prediction "
|
|
338
|
+
f"{p!r}; recording as overlap_spurious_missed",
|
|
339
|
+
stacklevel=2,
|
|
340
|
+
)
|
|
341
|
+
add([g], [p], ErrorType.OVERLAP_SPURIOUS_MISSED)
|
|
342
|
+
gi += 1
|
|
343
|
+
pi += 1
|
|
344
|
+
|
|
345
|
+
for i in range(pi, len(pred)): # remaining predictions are spurious
|
|
346
|
+
add([], [pred[i]], ErrorType.SPURIOUS)
|
|
347
|
+
for i in range(gi, len(gold)): # remaining gold entities are missed
|
|
348
|
+
add([gold[i]], [], ErrorType.MISSED)
|
|
349
|
+
return alignments
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def align_doc(test_doc) -> List[Alignment]:
|
|
353
|
+
"""Align every sentence of a test ``Doc`` that has the prediction layer
|
|
354
|
+
applied (``sent._.ents_pred`` set by :class:`cascabel.Cascabel`)."""
|
|
355
|
+
# Bucket gold entities per sentence once (O(n)); per-sentence ``Span.ents``
|
|
356
|
+
# would rescan the whole doc each time (O(sentences x entities)).
|
|
357
|
+
gold_by_sent = ents_by_sentence(test_doc)
|
|
358
|
+
alignments: List[Alignment] = []
|
|
359
|
+
for sentence in test_doc.sents:
|
|
360
|
+
predicted = sentence._.ents_pred or []
|
|
361
|
+
gold = gold_by_sent.get(sentence.start, [])
|
|
362
|
+
alignments.extend(align_sentence(sentence, gold, predicted))
|
|
363
|
+
return alignments
|
cascabel/bootstrap.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Sentence-level bootstrap confidence intervals for cascabel scores.
|
|
2
|
+
|
|
3
|
+
The evaluation unit is the sentence: to estimate the uncertainty of a score,
|
|
4
|
+
we resample sentences with replacement and recompute. All metrics in one
|
|
5
|
+
draw use the *same* resample, so the intervals are jointly valid. Reported
|
|
6
|
+
intervals are percentile bootstrap intervals; the point estimate is the
|
|
7
|
+
statistic on the full data, not the bootstrap mean.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Dict, Mapping, Optional
|
|
13
|
+
|
|
14
|
+
from cascabel.dimensions import ENTITY_DIMENSIONS, SpanPredicate
|
|
15
|
+
from cascabel.doc import bio_tag
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _f1(tp: float, fp: float, fn: float) -> float:
|
|
19
|
+
precision = tp / (tp + fp) if tp + fp else 0.0
|
|
20
|
+
recall = tp / (tp + fn) if tp + fn else 0.0
|
|
21
|
+
return 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _recall(tp: float, fp: float, fn: float) -> float:
|
|
25
|
+
return tp / (tp + fn) if tp + fn else 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bootstrap_scores(
|
|
29
|
+
casc,
|
|
30
|
+
n: int = 1000,
|
|
31
|
+
alpha: float = 0.05,
|
|
32
|
+
seed: int = 0,
|
|
33
|
+
dimensions: Optional[Mapping[str, SpanPredicate]] = None,
|
|
34
|
+
) -> Dict[str, Dict[str, float]]:
|
|
35
|
+
"""Percentile bootstrap CIs over resampled sentences.
|
|
36
|
+
|
|
37
|
+
Returns a dict keyed by ``"span"``, ``"token"``, ``"boundary"`` (F1) and
|
|
38
|
+
by dimension name (``"<dim>.f1"`` and ``"<dim>.recall"``); each value is
|
|
39
|
+
``{"point": .., "lo": .., "hi": ..}`` at the ``1 - alpha`` level. Only
|
|
40
|
+
dimensions that are available (predicate not ``None`` for every gold
|
|
41
|
+
span) are included, matching :meth:`Cascabel.scores_by_dimension`.
|
|
42
|
+
"""
|
|
43
|
+
import numpy as np
|
|
44
|
+
|
|
45
|
+
casc._require_prediction()
|
|
46
|
+
sents = list(casc.test.sents)
|
|
47
|
+
n_sents = len(sents)
|
|
48
|
+
if n_sents == 0:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
align_by_sent: Dict[int, list] = {s.start: [] for s in sents}
|
|
52
|
+
for alignment in casc.alignments:
|
|
53
|
+
align_by_sent[alignment.sentence.start].append(alignment)
|
|
54
|
+
|
|
55
|
+
dims = dict(dimensions) if dimensions is not None else dict(ENTITY_DIMENSIONS)
|
|
56
|
+
|
|
57
|
+
span = np.zeros((n_sents, 3))
|
|
58
|
+
boundary = np.zeros((n_sents, 3))
|
|
59
|
+
token = np.zeros((n_sents, 3))
|
|
60
|
+
dim_arrays = {name: np.zeros((n_sents, 3)) for name in dims}
|
|
61
|
+
dim_available = {name: False for name in dims}
|
|
62
|
+
|
|
63
|
+
for i, sent in enumerate(sents):
|
|
64
|
+
for alignment in align_by_sent[sent.start]:
|
|
65
|
+
sc = alignment.span_score
|
|
66
|
+
span[i] += (sc.tp, sc.fp, sc.fn)
|
|
67
|
+
bc = alignment.boundary_score
|
|
68
|
+
boundary[i] += (bc.tp, bc.fp, bc.fn)
|
|
69
|
+
if alignment.gold:
|
|
70
|
+
for name, predicate in dims.items():
|
|
71
|
+
flags = [predicate(g) for g in alignment.gold]
|
|
72
|
+
if all(flag is None for flag in flags):
|
|
73
|
+
continue
|
|
74
|
+
dim_available[name] = True
|
|
75
|
+
if any(flags):
|
|
76
|
+
dim_arrays[name][i] += (sc.tp, sc.fp, sc.fn)
|
|
77
|
+
for tok in sent:
|
|
78
|
+
gold, pred = bio_tag(tok), tok._.iob_pred
|
|
79
|
+
if gold == "O" and pred == "O":
|
|
80
|
+
continue
|
|
81
|
+
if pred == gold:
|
|
82
|
+
if gold != "O":
|
|
83
|
+
token[i, 0] += 1
|
|
84
|
+
else:
|
|
85
|
+
if gold != "O":
|
|
86
|
+
token[i, 2] += 1
|
|
87
|
+
if pred != "O":
|
|
88
|
+
token[i, 1] += 1
|
|
89
|
+
|
|
90
|
+
available = {name: arr for name, arr in dim_arrays.items()
|
|
91
|
+
if dim_available[name]}
|
|
92
|
+
|
|
93
|
+
rng = np.random.default_rng(seed)
|
|
94
|
+
# Draw all resamples up front; loop per draw to bound memory on large sets.
|
|
95
|
+
draws = rng.integers(0, n_sents, size=(n, n_sents))
|
|
96
|
+
view_f1_b = {"span": [], "token": [], "boundary": []}
|
|
97
|
+
view_recall_b = {"span": [], "token": [], "boundary": []}
|
|
98
|
+
view_arr = {"span": span, "token": token, "boundary": boundary}
|
|
99
|
+
dim_f1_b = {name: [] for name in available}
|
|
100
|
+
dim_recall_b = {name: [] for name in available}
|
|
101
|
+
for draw in draws:
|
|
102
|
+
for view, arr in view_arr.items():
|
|
103
|
+
v = arr[draw].sum(0)
|
|
104
|
+
view_f1_b[view].append(_f1(*v))
|
|
105
|
+
view_recall_b[view].append(_recall(*v))
|
|
106
|
+
for name, arr in available.items():
|
|
107
|
+
d = arr[draw].sum(0)
|
|
108
|
+
dim_f1_b[name].append(_f1(*d))
|
|
109
|
+
dim_recall_b[name].append(_recall(*d))
|
|
110
|
+
|
|
111
|
+
lo_q, hi_q = 100 * alpha / 2, 100 * (1 - alpha / 2)
|
|
112
|
+
|
|
113
|
+
def interval(samples, point):
|
|
114
|
+
return {"point": point,
|
|
115
|
+
"lo": float(np.percentile(samples, lo_q)),
|
|
116
|
+
"hi": float(np.percentile(samples, hi_q))}
|
|
117
|
+
|
|
118
|
+
points = {"span": casc.span_scores(), "token": casc.token_scores(),
|
|
119
|
+
"boundary": casc.boundary_scores()}
|
|
120
|
+
result: Dict[str, Dict[str, float]] = {}
|
|
121
|
+
for view, score in points.items():
|
|
122
|
+
result[view] = interval(view_f1_b[view], score.f1)
|
|
123
|
+
result[f"{view}.recall"] = interval(view_recall_b[view], score.recall)
|
|
124
|
+
by_dim = casc.scores_by_dimension({name: dims[name] for name in available})
|
|
125
|
+
for name in available:
|
|
126
|
+
score = by_dim.get(name)
|
|
127
|
+
if score is None:
|
|
128
|
+
continue
|
|
129
|
+
result[f"{name}.f1"] = interval(dim_f1_b[name], score.f1)
|
|
130
|
+
result[f"{name}.recall"] = interval(dim_recall_b[name], score.recall)
|
|
131
|
+
return result
|