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 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
@@ -0,0 +1,7 @@
1
+ """Entry point for ``python -m cascabel``."""
2
+
3
+ import sys
4
+
5
+ from cascabel.cli import main
6
+
7
+ sys.exit(main())
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