Skip to content

Commit a4296cb

Browse files
authored
Merge pull request #1368 from PyThaiNLP/copilot/add-typed-dicts-for-complex-dictionaries
Add TypedDicts for complex return dicts: RougeScore, TokenizationStat family, CorefResult
2 parents 9836c96 + c80c228 commit a4296cb

10 files changed

Lines changed: 219 additions & 71 deletions

File tree

CHANGELOG.md

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

2020
## [Unreleased]
2121

22+
### Added
23+
24+
- `EntitySpan` TypedDict (#1363).
25+
Migration notes:
26+
27+
```python
28+
# EntitySpan – Before (plain dict)
29+
entity = {"text": ["สมชาย"], "span": [0, 1], "entity_type": "PERSON"}
30+
31+
# EntitySpan – After (TypedDict)
32+
from pythainlp.tag.named_entity import EntitySpan
33+
entity = EntitySpan(text=["สมชาย"], span=[0, 1], entity_type="PERSON")
34+
```
35+
2236
### Fixed
2337

2438
- 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+
"CharLevelStat",
9+
"GlobalStat",
10+
"RougeScore",
11+
"TokenizationStat",
12+
"WordLevelStat",
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+
CharLevelStat,
30+
GlobalStat,
31+
TokenizationStat,
32+
WordLevelStat,
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: 69 additions & 23 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,37 @@
2930
TAILING_SEP_RX: re.Pattern[str] = re.compile(f"{re.escape(SEPARATOR)}$")
3031

3132

33+
class CharLevelStat(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 WordLevelStat(TypedDict):
43+
"""Word-level tokenization statistics."""
44+
45+
correctly_tokenized_words: int
46+
total_words_in_sample: int
47+
total_words_in_ref_sample: int
48+
49+
50+
class GlobalStat(TypedDict):
51+
"""Global tokenization indicator as a binary indicator string."""
52+
53+
tokenization_indicators: str
54+
55+
56+
class TokenizationStat(TypedDict):
57+
"""Tokenization quality statistics at character, word, and global level."""
58+
59+
char_level: CharLevelStat
60+
word_level: WordLevelStat
61+
global_: GlobalStat
62+
63+
3264
def _f1(precision: float, recall: float) -> float:
3365
"""Compute f1.
3466
@@ -43,8 +75,21 @@ def _f1(precision: float, recall: float) -> float:
4375
return 2 * precision * recall / (precision + recall)
4476

4577

78+
@overload
79+
def _flatten_result(
80+
my_dict: TokenizationStat, sep: str = ...
81+
) -> dict[str, Union[int, str]]: ...
82+
83+
84+
@overload
85+
def _flatten_result(
86+
my_dict: Mapping[str, Mapping[str, Union[int, str]]], sep: str = ...
87+
) -> dict[str, Union[int, str]]: ...
88+
89+
4690
def _flatten_result(
47-
my_dict: dict[str, dict[str, Union[int, str]]], sep: str = ":"
91+
my_dict: Any,
92+
sep: str = ":",
4893
) -> dict[str, Union[int, str]]:
4994
"""Flatten two-dimension dictionary.
5095
@@ -55,8 +100,9 @@ def _flatten_result(
55100
{ "a:b": 7 }
56101
57102
58-
:param dict[str, dict[str, Union[int, str]]] my_dict: dictionary
59-
containing stats
103+
:param my_dict: dictionary containing stats
104+
:type my_dict: TokenizationStat or
105+
collections.abc.Mapping[str, collections.abc.Mapping[str, Union[int, str]]]
60106
:param str sep: separator between the two keys (default: ":")
61107
62108
:return: a one-dimension dictionary with keys combined
@@ -139,7 +185,7 @@ def preprocessing(txt: str, remove_space: bool = True) -> str:
139185

140186
def compute_stats(
141187
ref_sample: str, raw_sample: str
142-
) -> dict[str, dict[str, Union[int, str]]]:
188+
) -> TokenizationStat:
143189
"""Compute statistics for tokenization quality
144190
145191
These statistics include:
@@ -156,7 +202,7 @@ def compute_stats(
156202
:param str samples: samples that we want to evaluate
157203
158204
:return: metrics at character- and word-level and indicators of correctly tokenized words
159-
:rtype: dict[str, dict[str, Union[int, str]]]
205+
:rtype: TokenizationStat
160206
"""
161207
import numpy as np
162208

@@ -185,29 +231,29 @@ def compute_stats(
185231

186232
# Find correctly tokenized words in the sample
187233
ss_boundaries = _find_word_boundaries(sample_arr)
188-
tokenization_indicators = _find_words_correctly_tokenised(
234+
tokenization_indicators = _find_words_correctly_tokenized(
189235
word_boundaries, ss_boundaries
190236
)
191237

192-
correctly_tokenised_words: int = int(np.sum(tokenization_indicators))
238+
correctly_tokenized_words: int = int(np.sum(tokenization_indicators))
193239

194240
tokenization_indicators_str = list(map(str, tokenization_indicators))
195241

196242
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-
},
243+
"char_level": CharLevelStat(
244+
tp=c_tp,
245+
fp=c_fp,
246+
tn=c_tn,
247+
fn=c_fn,
248+
),
249+
"word_level": WordLevelStat(
250+
correctly_tokenized_words=correctly_tokenized_words,
251+
total_words_in_sample=int(np.sum(sample_arr)),
252+
total_words_in_ref_sample=int(np.sum(ref_sample_arr)),
253+
),
254+
"global_": GlobalStat(
255+
tokenization_indicators="".join(tokenization_indicators_str),
256+
),
211257
}
212258

213259

@@ -271,7 +317,7 @@ def _find_word_boundaries(
271317
return list(zip(start_idx, end_idx))
272318

273319

274-
def _find_words_correctly_tokenised(
320+
def _find_words_correctly_tokenized(
275321
ref_boundaries: list[tuple[int, int]],
276322
predicted_boundaries: list[tuple[int, int]],
277323
) -> tuple[int, ...]:

pythainlp/cli/benchmark.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def __init__(self, name: str, argv: Sequence[str]) -> None:
108108
"char_level:fp",
109109
"char_level:tn",
110110
"char_level:fn",
111-
"word_level:correctly_tokenised_words",
111+
"word_level:correctly_tokenized_words",
112112
"word_level:total_words_in_sample",
113113
"word_level:total_words_in_ref_sample",
114114
]
@@ -127,12 +127,12 @@ def __init__(self, name: str, argv: Sequence[str]) -> None:
127127
)
128128

129129
statistics["word_level:precision"] = (
130-
statistics["word_level:correctly_tokenised_words"]
130+
statistics["word_level:correctly_tokenized_words"]
131131
/ statistics["word_level:total_words_in_sample"]
132132
)
133133

134134
statistics["word_level:recall"] = (
135-
statistics["word_level:correctly_tokenised_words"]
135+
statistics["word_level:correctly_tokenized_words"]
136136
/ statistics["word_level:total_words_in_ref_sample"]
137137
)
138138

@@ -146,7 +146,7 @@ def __init__(self, name: str, argv: Sequence[str]) -> None:
146146
for c in [
147147
"total_words_in_sample",
148148
"total_words_in_ref_sample",
149-
"correctly_tokenised_words",
149+
"correctly_tokenized_words",
150150
"precision",
151151
"recall",
152152
]:

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)