Skip to content

Commit 61f0c8f

Browse files
Copilotbact
andcommitted
Add TypedDicts: RougeScore, TokenizationStats family, CorefResult; export from public APIs
- metrics.py: add RougeScore TypedDict (precision/recall/fmeasure); rouge_score() now returns dict[str, RougeScore] instead of dict[str, tuple[float, float, float]] (breaking change) - word_tokenization.py: add CharLevelStats, WordLevelStats, GlobalStats, TokenizationStats TypedDicts; update compute_stats() return type; _flatten_result() uses @overload to accept both TokenizationStats and generic Mapping without a cast - benchmarks/__init__.py: export RougeScore, CharLevelStats, WordLevelStats, GlobalStats, TokenizationStats - _fastcoref.py: rename CorefResultDict -> CorefResult; alias the fastcoref.modeling.CorefResult import as FastCorefResult to avoid clash - coref/__init__.py: export CorefResult - coref/core.py: update return type and fallback return to use CorefResult - tag/named_entity.py: improve EntitySpan docstring - tests/extra/testx_benchmarks.py: update rouge tests for named-field access; add test_rouge_score_return_type and test_compute_stats_return_type - CHANGELOG.md: add migration notes for all TypedDicts Co-authored-by: bact <128572+bact@users.noreply.github.com> Agent-Logs-Url: https://github.com/PyThaiNLP/pythainlp/sessions/de0a9df1-25ba-4e89-a047-7ee0664753c1
1 parent 8999b8c commit 61f0c8f

9 files changed

Lines changed: 265 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,73 @@ and this project adheres to
1919

2020
## [Unreleased]
2121

22+
### Added
23+
24+
- `RougeScore` TypedDict in `pythainlp.benchmarks`: precision, recall, and
25+
fmeasure fields replace the previous `tuple[float, float, float]` value
26+
in the `rouge_score()` return dict. **Breaking change** – migrate as follows:
27+
28+
```python
29+
# Before (tuple indexing, error-prone)
30+
precision, recall, fmeasure = scores["rouge1"]
31+
32+
# After (named fields)
33+
precision = scores["rouge1"]["precision"]
34+
recall = scores["rouge1"]["recall"]
35+
fmeasure = scores["rouge1"]["fmeasure"]
36+
```
37+
38+
- `CharLevelStats`, `WordLevelStats`, `GlobalStats`, and `TokenizationStats`
39+
TypedDicts in `pythainlp.benchmarks`: give named, type-safe access to the
40+
dict returned by `word_tokenization.compute_stats()`.
41+
42+
```python
43+
# Before (opaque nested dict)
44+
result = compute_stats(ref, hyp)
45+
tp = result["char_level"]["tp"]
46+
47+
# After (same access, now type-safe with TokenizationStats)
48+
from pythainlp.benchmarks import TokenizationStats
49+
result: TokenizationStats = compute_stats(ref, hyp)
50+
tp = result["char_level"]["tp"]
51+
```
52+
53+
- `CorefResult` TypedDict is now exported from `pythainlp.coref`.
54+
`coreference_resolution()` return type updated from `list[dict[str, Any]]`
55+
to `list[CorefResult]`.
56+
57+
```python
58+
# Before
59+
from pythainlp.coref import coreference_resolution
60+
61+
# After – import the TypedDict for type annotations
62+
from pythainlp.coref import CorefResult, coreference_resolution
63+
```
64+
65+
- `EntitySpan` TypedDict (`pythainlp.tag.named_entity`, #1363) and
66+
`BleuScore` TypedDict (`pythainlp.benchmarks`, #1365) were introduced in
67+
previous releases. Migration notes for completeness:
68+
69+
```python
70+
# EntitySpan – Before (plain dict)
71+
entity = {"text": ["สมชาย"], "span": [0, 1], "entity_type": "PERSON"}
72+
73+
# EntitySpan – After (TypedDict constructor)
74+
from pythainlp.tag.named_entity import EntitySpan
75+
entity = EntitySpan(text=["สมชาย"], span=[0, 1], entity_type="PERSON")
76+
```
77+
78+
```python
79+
# BleuScore – Before (plain dict, no type hint)
80+
score = bleu_score(refs, hyps)
81+
print(score["bleu"])
82+
83+
# BleuScore – After (TypedDict annotation)
84+
from pythainlp.benchmarks import BleuScore, bleu_score
85+
score: BleuScore = bleu_score(refs, hyps)
86+
print(score["bleu"])
87+
```
88+
2289
### Fixed
2390

2491
- thai2rom_onnx: fix ONNX encoder model and fix inference bugs (#1349)

pythainlp/benchmarks/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
__all__: list[str] = [
77
"BleuScore",
8+
"CharLevelStats",
9+
"GlobalStats",
10+
"RougeScore",
11+
"TokenizationStats",
12+
"WordLevelStats",
813
"benchmark",
914
"bleu_score",
1015
"character_error_rate",
@@ -14,9 +19,16 @@
1419

1520
from pythainlp.benchmarks.metrics import (
1621
BleuScore,
22+
RougeScore,
1723
bleu_score,
1824
character_error_rate,
1925
rouge_score,
2026
word_error_rate,
2127
)
22-
from pythainlp.benchmarks.word_tokenization import benchmark
28+
from pythainlp.benchmarks.word_tokenization import (
29+
CharLevelStats,
30+
GlobalStats,
31+
TokenizationStats,
32+
WordLevelStats,
33+
benchmark,
34+
)

pythainlp/benchmarks/metrics.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717

1818
class BleuScore(TypedDict):
19-
"""BLEU score"""
19+
"""BLEU score components returned by :func:`bleu_score`."""
2020

2121
bleu: float # BLEU score as a percentage (0.0 to 100.0)
2222
precisions: list[float]
@@ -26,6 +26,14 @@ class BleuScore(TypedDict):
2626
ref_length: int
2727

2828

29+
class RougeScore(TypedDict):
30+
"""Precision, recall, and F-measure for a single ROUGE type."""
31+
32+
precision: float
33+
recall: float
34+
fmeasure: float
35+
36+
2937
def _get_ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]:
3038
"""
3139
Get n-grams from a list of tokens.
@@ -249,7 +257,7 @@ def rouge_score(
249257
hypothesis: str,
250258
tokenize: str = "newmm",
251259
rouge_types: Optional[list[str]] = None,
252-
) -> dict[str, tuple[float, float, float]]:
260+
) -> dict[str, RougeScore]:
253261
"""
254262
Calculate ROUGE scores for Thai text with automatic tokenization.
255263
@@ -269,8 +277,9 @@ def rouge_score(
269277
:param Optional[list[str]] rouge_types: list of ROUGE types to calculate.
270278
Default is ["rouge1", "rouge2", "rougeL"]
271279
272-
:return: dictionary mapping ROUGE type to (precision, recall, fmeasure)
273-
:rtype: dict[str, tuple[float, float, float]]
280+
:return: dictionary mapping ROUGE type to a :class:`RougeScore` typed dict
281+
with ``'precision'``, ``'recall'``, and ``'fmeasure'`` keys.
282+
:rtype: dict[str, RougeScore]
274283
275284
:Example:
276285
::
@@ -280,9 +289,9 @@ def rouge_score(
280289
reference = "สวัสดีครับ วันนี้อากาศดีมาก"
281290
hypothesis = "สวัสดีค่ะ วันนี้อากาศดี"
282291
scores = rouge_score(reference, hypothesis)
283-
print(f"ROUGE-1 F-measure: {scores['rouge1'][2]:.4f}")
284-
print(f"ROUGE-2 F-measure: {scores['rouge2'][2]:.4f}")
285-
print(f"ROUGE-L F-measure: {scores['rougeL'][2]:.4f}")
292+
print(f"ROUGE-1 F-measure: {scores['rouge1']['fmeasure']:.4f}")
293+
print(f"ROUGE-2 F-measure: {scores['rouge2']['fmeasure']:.4f}")
294+
print(f"ROUGE-L F-measure: {scores['rougeL']['fmeasure']:.4f}")
286295
"""
287296
from pythainlp.tokenize import word_tokenize
288297

@@ -297,7 +306,7 @@ def rouge_score(
297306
hypothesis, engine=tokenize, keep_whitespace=False
298307
)
299308

300-
result: dict[str, tuple[float, float, float]] = {}
309+
result: dict[str, RougeScore] = {}
301310

302311
for rouge_type in rouge_types:
303312
if rouge_type == "rouge1":
@@ -309,9 +318,12 @@ def rouge_score(
309318
ref_count = len(ref_tokens)
310319
hyp_count = len(hyp_tokens)
311320

312-
result[rouge_type] = _calculate_precision_recall_fmeasure(
321+
precision, recall, fmeasure = _calculate_precision_recall_fmeasure(
313322
overlap, hyp_count, ref_count
314323
)
324+
result[rouge_type] = RougeScore(
325+
precision=precision, recall=recall, fmeasure=fmeasure
326+
)
315327

316328
elif rouge_type == "rouge2":
317329
# Bigram-based
@@ -325,19 +337,25 @@ def rouge_score(
325337
ref_count = len(ref_bigrams)
326338
hyp_count = len(hyp_bigrams)
327339

328-
result[rouge_type] = _calculate_precision_recall_fmeasure(
340+
precision, recall, fmeasure = _calculate_precision_recall_fmeasure(
329341
overlap, hyp_count, ref_count
330342
)
343+
result[rouge_type] = RougeScore(
344+
precision=precision, recall=recall, fmeasure=fmeasure
345+
)
331346

332347
elif rouge_type == "rougeL":
333348
# Longest Common Subsequence-based
334349
lcs_len = _lcs_length(ref_tokens, hyp_tokens)
335350
ref_count = len(ref_tokens)
336351
hyp_count = len(hyp_tokens)
337352

338-
result[rouge_type] = _calculate_precision_recall_fmeasure(
353+
precision, recall, fmeasure = _calculate_precision_recall_fmeasure(
339354
lcs_len, hyp_count, ref_count
340355
)
356+
result[rouge_type] = RougeScore(
357+
precision=precision, recall=recall, fmeasure=fmeasure
358+
)
341359

342360
return result
343361

pythainlp/benchmarks/word_tokenization.py

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
import re
77
import sys
8-
from typing import TYPE_CHECKING, Union
8+
from collections.abc import Mapping
9+
from typing import TYPE_CHECKING, Any, TypedDict, Union, overload
910

1011
if TYPE_CHECKING:
1112
import numpy as np
@@ -29,6 +30,41 @@
2930
TAILING_SEP_RX: re.Pattern[str] = re.compile(f"{re.escape(SEPARATOR)}$")
3031

3132

33+
class CharLevelStats(TypedDict):
34+
"""Character-level confusion matrix statistics for tokenization."""
35+
36+
tp: int
37+
fp: int
38+
tn: int
39+
fn: int
40+
41+
42+
class WordLevelStats(TypedDict):
43+
"""Word-level tokenization statistics."""
44+
45+
correctly_tokenised_words: int
46+
total_words_in_sample: int
47+
total_words_in_ref_sample: int
48+
49+
50+
class GlobalStats(TypedDict):
51+
"""Global tokenization indicators as a binary indicator string."""
52+
53+
tokenisation_indicators: str
54+
55+
56+
# Functional form is required because 'global' is a Python reserved keyword.
57+
TokenizationStats = TypedDict(
58+
"TokenizationStats",
59+
{
60+
"char_level": CharLevelStats,
61+
"word_level": WordLevelStats,
62+
"global": GlobalStats,
63+
},
64+
)
65+
"""Tokenization quality statistics at character, word, and global level."""
66+
67+
3268
def _f1(precision: float, recall: float) -> float:
3369
"""Compute f1.
3470
@@ -43,8 +79,21 @@ def _f1(precision: float, recall: float) -> float:
4379
return 2 * precision * recall / (precision + recall)
4480

4581

82+
@overload
83+
def _flatten_result(
84+
my_dict: TokenizationStats, sep: str = ...
85+
) -> dict[str, Union[int, str]]: ...
86+
87+
88+
@overload
89+
def _flatten_result(
90+
my_dict: Mapping[str, Mapping[str, Union[int, str]]], sep: str = ...
91+
) -> dict[str, Union[int, str]]: ...
92+
93+
4694
def _flatten_result(
47-
my_dict: dict[str, dict[str, Union[int, str]]], sep: str = ":"
95+
my_dict: Any,
96+
sep: str = ":",
4897
) -> dict[str, Union[int, str]]:
4998
"""Flatten two-dimension dictionary.
5099
@@ -55,8 +104,9 @@ def _flatten_result(
55104
{ "a:b": 7 }
56105
57106
58-
:param dict[str, dict[str, Union[int, str]]] my_dict: dictionary
59-
containing stats
107+
:param my_dict: dictionary containing stats
108+
:type my_dict: TokenizationStats or
109+
collections.abc.Mapping[str, collections.abc.Mapping[str, Union[int, str]]]
60110
:param str sep: separator between the two keys (default: ":")
61111
62112
:return: a one-dimension dictionary with keys combined
@@ -139,7 +189,7 @@ def preprocessing(txt: str, remove_space: bool = True) -> str:
139189

140190
def compute_stats(
141191
ref_sample: str, raw_sample: str
142-
) -> dict[str, dict[str, Union[int, str]]]:
192+
) -> TokenizationStats:
143193
"""Compute statistics for tokenization quality
144194
145195
These statistics include:
@@ -156,7 +206,7 @@ def compute_stats(
156206
:param str samples: samples that we want to evaluate
157207
158208
:return: metrics at character- and word-level and indicators of correctly tokenized words
159-
:rtype: dict[str, dict[str, Union[int, str]]]
209+
:rtype: TokenizationStats
160210
"""
161211
import numpy as np
162212

@@ -194,20 +244,20 @@ def compute_stats(
194244
tokenization_indicators_str = list(map(str, tokenization_indicators))
195245

196246
return {
197-
"char_level": {
198-
"tp": c_tp,
199-
"fp": c_fp,
200-
"tn": c_tn,
201-
"fn": c_fn,
202-
},
203-
"word_level": {
204-
"correctly_tokenised_words": correctly_tokenised_words,
205-
"total_words_in_sample": int(np.sum(sample_arr)),
206-
"total_words_in_ref_sample": int(np.sum(ref_sample_arr)),
207-
},
208-
"global": {
209-
"tokenisation_indicators": "".join(tokenization_indicators_str)
210-
},
247+
"char_level": CharLevelStats(
248+
tp=c_tp,
249+
fp=c_fp,
250+
tn=c_tn,
251+
fn=c_fn,
252+
),
253+
"word_level": WordLevelStats(
254+
correctly_tokenised_words=correctly_tokenised_words,
255+
total_words_in_sample=int(np.sum(sample_arr)),
256+
total_words_in_ref_sample=int(np.sum(ref_sample_arr)),
257+
),
258+
"global": GlobalStats(
259+
tokenisation_indicators="".join(tokenization_indicators_str),
260+
),
211261
}
212262

213263

pythainlp/coref/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# SPDX-License-Identifier: Apache-2.0
44
"""PyThaiNLP Coreference Resolution"""
55

6-
__all__: list[str] = ["coreference_resolution"]
6+
__all__: list[str] = ["CorefResult", "coreference_resolution"]
77

8+
from pythainlp.coref._fastcoref import CorefResult
89
from pythainlp.coref.core import coreference_resolution

pythainlp/coref/_fastcoref.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
from typing import TYPE_CHECKING, Optional, TypedDict
77

88
if TYPE_CHECKING:
9-
from fastcoref.modeling import CorefModel, CorefResult
9+
from fastcoref.modeling import CorefModel
10+
from fastcoref.modeling import CorefResult as FastCorefResult
1011
from spacy.language import Language
1112

1213

13-
class CorefResultDict(TypedDict):
14-
"""Dictionary representation of coreference resolution results."""
14+
class CorefResult(TypedDict):
15+
"""Coreference resolution result for a single text."""
1516

1617
text: str
1718
clusters_string: list[list[str]]
@@ -42,14 +43,14 @@ def __init__(
4243
self.model_name, device=device, nlp=self.nlp
4344
)
4445

45-
def _to_json(self, _predict: "CorefResult") -> CorefResultDict:
46+
def _to_json(self, _predict: "FastCorefResult") -> CorefResult:
4647
return {
4748
"text": _predict.text,
4849
"clusters_string": _predict.get_clusters(as_strings=True),
4950
"clusters": _predict.get_clusters(as_strings=False),
5051
}
5152

52-
def predict(self, texts: list[str]) -> list[CorefResultDict]:
53+
def predict(self, texts: list[str]) -> list[CorefResult]:
5354
return [
5455
self._to_json(pred) for pred in self.model.predict(texts=texts)
5556
]

0 commit comments

Comments
 (0)