morphbpe-pl 0.3.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.
- morphbpe/__init__.py +22 -0
- morphbpe/cli.py +84 -0
- morphbpe/hf_tokenizer.py +232 -0
- morphbpe/morfeusz_backend.py +86 -0
- morphbpe/morph_metrics.py +225 -0
- morphbpe/tokenizer.py +614 -0
- morphbpe_pl-0.3.0.dist-info/METADATA +195 -0
- morphbpe_pl-0.3.0.dist-info/RECORD +11 -0
- morphbpe_pl-0.3.0.dist-info/WHEEL +5 -0
- morphbpe_pl-0.3.0.dist-info/entry_points.txt +2 -0
- morphbpe_pl-0.3.0.dist-info/top_level.txt +1 -0
morphbpe/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""MorphBPE: Morphologically-aware BPE tokenizer for Polish."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from morphbpe.tokenizer import (
|
|
6
|
+
MorphBPETokenizer,
|
|
7
|
+
pre_tokenize,
|
|
8
|
+
SPECIAL_TOKENS,
|
|
9
|
+
BYTE_TOKENS,
|
|
10
|
+
BYTE_TOKENS_INV,
|
|
11
|
+
)
|
|
12
|
+
from morphbpe.morph_metrics import evaluate_all, evaluate_lexeme
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"MorphBPETokenizer",
|
|
16
|
+
"pre_tokenize",
|
|
17
|
+
"SPECIAL_TOKENS",
|
|
18
|
+
"BYTE_TOKENS",
|
|
19
|
+
"BYTE_TOKENS_INV",
|
|
20
|
+
"evaluate_all",
|
|
21
|
+
"evaluate_lexeme",
|
|
22
|
+
]
|
morphbpe/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Command-line interface for MorphBPE."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from morphbpe.tokenizer import MorphBPETokenizer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="morphbpe",
|
|
12
|
+
description="MorphBPE: Morphologically-aware BPE tokenizer for Polish",
|
|
13
|
+
)
|
|
14
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
|
|
16
|
+
# ── train ────────────────────────────────────────────────────────
|
|
17
|
+
p_train = sub.add_parser("train", help="Train a tokenizer from a text file")
|
|
18
|
+
p_train.add_argument("corpus", help="Path to plain-text corpus (one doc per line)")
|
|
19
|
+
p_train.add_argument("-v", "--vocab-size", type=int, default=1000)
|
|
20
|
+
p_train.add_argument("-a", "--alpha", type=float, default=2.0)
|
|
21
|
+
p_train.add_argument(
|
|
22
|
+
"-c", "--constraint", choices=["suffix", "prefix", "both"], default="suffix"
|
|
23
|
+
)
|
|
24
|
+
p_train.add_argument(
|
|
25
|
+
"-o", "--output", default="morphbpe-model", help="Output directory"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ── encode ───────────────────────────────────────────────────────
|
|
29
|
+
p_enc = sub.add_parser("encode", help="Encode text using a trained tokenizer")
|
|
30
|
+
p_enc.add_argument("model", help="Tokenizer directory")
|
|
31
|
+
p_enc.add_argument(
|
|
32
|
+
"text", nargs="?", default=None, help="Text to encode (or stdin)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# ── info ─────────────────────────────────────────────────────────
|
|
36
|
+
p_info = sub.add_parser("info", help="Show tokenizer info")
|
|
37
|
+
p_info.add_argument("model", help="Tokenizer directory")
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
if args.command == "train":
|
|
42
|
+
_cmd_train(args)
|
|
43
|
+
elif args.command == "encode":
|
|
44
|
+
_cmd_encode(args)
|
|
45
|
+
elif args.command == "info":
|
|
46
|
+
_cmd_info(args)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cmd_train(args):
|
|
50
|
+
tok = MorphBPETokenizer(
|
|
51
|
+
vocab_size=args.vocab_size,
|
|
52
|
+
alpha=args.alpha,
|
|
53
|
+
constraint=args.constraint,
|
|
54
|
+
)
|
|
55
|
+
tok.train_from_file(args.corpus)
|
|
56
|
+
tok.save(args.output)
|
|
57
|
+
print(f"Saved to {args.output}/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_encode(args):
|
|
61
|
+
tok = MorphBPETokenizer.load(args.model)
|
|
62
|
+
if args.text:
|
|
63
|
+
lines = [args.text]
|
|
64
|
+
else:
|
|
65
|
+
lines = sys.stdin.read().splitlines()
|
|
66
|
+
|
|
67
|
+
for line in lines:
|
|
68
|
+
tokens = []
|
|
69
|
+
for word in line.split():
|
|
70
|
+
tokens.extend(tok.encode_word(word))
|
|
71
|
+
print(" ".join(tokens))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_info(args):
|
|
75
|
+
tok = MorphBPETokenizer.load(args.model)
|
|
76
|
+
print(f"Vocab size: {tok.get_vocab_size()}")
|
|
77
|
+
print(f"Merges: {len(tok.merges)}")
|
|
78
|
+
print(f"Alpha: {tok.alpha}")
|
|
79
|
+
print(f"Constraint: {tok.constraint}")
|
|
80
|
+
print(f"Morfeusz: {tok._backend.has_morfeusz}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
morphbpe/hf_tokenizer.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""HuggingFace tokenizer wrapper for MorphBPE.
|
|
2
|
+
|
|
3
|
+
Provides a PreTrainedTokenizer-compatible interface so MorphBPE
|
|
4
|
+
can be used with HuggingFace transformers pipelines.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from morphbpe.hf_tokenizer import MorphBPEHFTokenizer
|
|
8
|
+
|
|
9
|
+
tok = MorphBPEHFTokenizer.from_pretrained("path/to/model")
|
|
10
|
+
ids = tok("prezydent podpisał ustawę")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, List, Optional, Union
|
|
16
|
+
|
|
17
|
+
from morphbpe.tokenizer import MorphBPETokenizer, SPECIAL_TOKENS
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MorphBPEHFTokenizer:
|
|
21
|
+
"""Lightweight HF-compatible wrapper around MorphBPETokenizer.
|
|
22
|
+
|
|
23
|
+
Implements the core interface needed by transformers:
|
|
24
|
+
- __call__(text) -> dict with input_ids, attention_mask
|
|
25
|
+
- encode(text) -> List[int]
|
|
26
|
+
- decode(ids) -> str
|
|
27
|
+
- batch_encode(texts) -> dict with padded batches
|
|
28
|
+
- save_pretrained(dir) / from_pretrained(dir)
|
|
29
|
+
- get_vocab() / get_vocab_size()
|
|
30
|
+
- __len__() / __contains__()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, tokenizer: MorphBPETokenizer):
|
|
34
|
+
self._tok = tokenizer
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_pretrained(cls, path: str) -> "MorphBPEHFTokenizer":
|
|
38
|
+
tok = MorphBPETokenizer.load(path)
|
|
39
|
+
return cls(tok)
|
|
40
|
+
|
|
41
|
+
def save_pretrained(self, directory: str) -> None:
|
|
42
|
+
self._tok.save(directory)
|
|
43
|
+
config_path = Path(directory) / "tokenizer_config.json"
|
|
44
|
+
config = {
|
|
45
|
+
"tokenizer_class": "MorphBPEHFTokenizer",
|
|
46
|
+
"model_type": "morphbpe",
|
|
47
|
+
"vocab_size": len(self._tok),
|
|
48
|
+
"alpha": self._tok.alpha,
|
|
49
|
+
"constraint": self._tok.constraint,
|
|
50
|
+
"pad_token": "<pad>",
|
|
51
|
+
"unk_token": "<unk>",
|
|
52
|
+
"cls_token": "<cls>",
|
|
53
|
+
"sep_token": "<sep>",
|
|
54
|
+
"mask_token": "<mask>",
|
|
55
|
+
"pad_token_id": self._tok.pad_token_id,
|
|
56
|
+
"unk_token_id": self._tok.unk_token_id,
|
|
57
|
+
"cls_token_id": self._tok.cls_token_id,
|
|
58
|
+
"sep_token_id": self._tok.sep_token_id,
|
|
59
|
+
"mask_token_id": self._tok.mask_token_id,
|
|
60
|
+
}
|
|
61
|
+
config_path.write_text(
|
|
62
|
+
json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __call__(
|
|
66
|
+
self,
|
|
67
|
+
text: Union[str, List[str]],
|
|
68
|
+
add_special_tokens: bool = True,
|
|
69
|
+
padding: Union[bool, str] = True,
|
|
70
|
+
truncation: bool = False,
|
|
71
|
+
max_length: Optional[int] = None,
|
|
72
|
+
return_tensors: Optional[str] = None,
|
|
73
|
+
return_attention_mask: bool = True,
|
|
74
|
+
return_token_type_ids: bool = False,
|
|
75
|
+
**kwargs,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Encode text(s) and return dict compatible with transformers.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
text : str or List[str]
|
|
82
|
+
Input text(s).
|
|
83
|
+
add_special_tokens : bool
|
|
84
|
+
Prepend [CLS] and append [SEP].
|
|
85
|
+
padding : bool or str
|
|
86
|
+
If True/"longest", pad to longest in batch. If "max_length", pad to max_length.
|
|
87
|
+
truncation : bool
|
|
88
|
+
If True, truncate to max_length.
|
|
89
|
+
max_length : int, optional
|
|
90
|
+
Maximum sequence length.
|
|
91
|
+
return_tensors : str, optional
|
|
92
|
+
If "pt", return PyTorch tensors.
|
|
93
|
+
return_attention_mask : bool
|
|
94
|
+
If True, include attention_mask.
|
|
95
|
+
return_token_type_ids : bool
|
|
96
|
+
If True, include token_type_ids (all zeros for single sentence).
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
dict
|
|
101
|
+
{"input_ids": ..., "attention_mask": ..., "token_type_ids": ...}
|
|
102
|
+
"""
|
|
103
|
+
is_batch = isinstance(text, list)
|
|
104
|
+
|
|
105
|
+
if is_batch:
|
|
106
|
+
result = self._tok.batch_encode(
|
|
107
|
+
text,
|
|
108
|
+
add_special_tokens=add_special_tokens,
|
|
109
|
+
padding=bool(padding),
|
|
110
|
+
max_length=max_length,
|
|
111
|
+
return_attention_mask=return_attention_mask,
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
ids = self._tok.encode(text, add_special_tokens=add_special_tokens)
|
|
115
|
+
if truncation and max_length:
|
|
116
|
+
ids = ids[:max_length]
|
|
117
|
+
result = {"input_ids": ids}
|
|
118
|
+
if return_attention_mask:
|
|
119
|
+
result["attention_mask"] = [1] * len(ids)
|
|
120
|
+
if padding and not is_batch:
|
|
121
|
+
# Single text: no padding needed unless max_length specified
|
|
122
|
+
if max_length and len(ids) < max_length:
|
|
123
|
+
pad_len = max_length - len(ids)
|
|
124
|
+
result["input_ids"] = ids + [self._tok.pad_token_id] * pad_len
|
|
125
|
+
if return_attention_mask:
|
|
126
|
+
result["attention_mask"] = [1] * len(ids) + [0] * pad_len
|
|
127
|
+
|
|
128
|
+
if return_token_type_ids:
|
|
129
|
+
if is_batch:
|
|
130
|
+
result["token_type_ids"] = [
|
|
131
|
+
[0] * len(ids) for ids in result["input_ids"]
|
|
132
|
+
]
|
|
133
|
+
else:
|
|
134
|
+
result["token_type_ids"] = [0] * len(result["input_ids"])
|
|
135
|
+
|
|
136
|
+
# Convert to tensors if requested
|
|
137
|
+
if return_tensors == "pt":
|
|
138
|
+
try:
|
|
139
|
+
import torch
|
|
140
|
+
|
|
141
|
+
for key in result:
|
|
142
|
+
if isinstance(result[key], list):
|
|
143
|
+
if isinstance(result[key][0], list):
|
|
144
|
+
result[key] = torch.tensor(result[key])
|
|
145
|
+
else:
|
|
146
|
+
result[key] = torch.tensor([result[key]])
|
|
147
|
+
except ImportError:
|
|
148
|
+
pass
|
|
149
|
+
elif return_tensors == "np":
|
|
150
|
+
try:
|
|
151
|
+
import numpy as np
|
|
152
|
+
|
|
153
|
+
for key in result:
|
|
154
|
+
if isinstance(result[key], list):
|
|
155
|
+
result[key] = np.array(result[key])
|
|
156
|
+
except ImportError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
def encode(
|
|
162
|
+
self,
|
|
163
|
+
text: str,
|
|
164
|
+
add_special_tokens: bool = True,
|
|
165
|
+
) -> List[int]:
|
|
166
|
+
return self._tok.encode(text, add_special_tokens=add_special_tokens)
|
|
167
|
+
|
|
168
|
+
def decode(
|
|
169
|
+
self,
|
|
170
|
+
ids: List[int],
|
|
171
|
+
skip_special_tokens: bool = True,
|
|
172
|
+
) -> str:
|
|
173
|
+
return self._tok.decode(ids, skip_special_tokens=skip_special_tokens)
|
|
174
|
+
|
|
175
|
+
def batch_encode(
|
|
176
|
+
self,
|
|
177
|
+
texts: List[str],
|
|
178
|
+
add_special_tokens: bool = True,
|
|
179
|
+
padding: bool = True,
|
|
180
|
+
max_length: Optional[int] = None,
|
|
181
|
+
) -> Dict[str, List[List[int]]]:
|
|
182
|
+
return self._tok.batch_encode(
|
|
183
|
+
texts,
|
|
184
|
+
add_special_tokens=add_special_tokens,
|
|
185
|
+
padding=padding,
|
|
186
|
+
max_length=max_length,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def get_vocab(self) -> Dict[str, int]:
|
|
190
|
+
return self._tok.vocab
|
|
191
|
+
|
|
192
|
+
def get_vocab_size(self) -> int:
|
|
193
|
+
return self._tok.get_vocab_size()
|
|
194
|
+
|
|
195
|
+
def __len__(self) -> int:
|
|
196
|
+
return len(self._tok)
|
|
197
|
+
|
|
198
|
+
def __contains__(self, token: str) -> bool:
|
|
199
|
+
return token in self._tok
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def model_max_length(self) -> int:
|
|
203
|
+
return 2048
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def pad_token_id(self) -> int:
|
|
207
|
+
return self._tok.pad_token_id
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def unk_token_id(self) -> int:
|
|
211
|
+
return self._tok.unk_token_id
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def cls_token_id(self) -> int:
|
|
215
|
+
return self._tok.cls_token_id
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def sep_token_id(self) -> int:
|
|
219
|
+
return self._tok.sep_token_id
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def mask_token_id(self) -> int:
|
|
223
|
+
return self._tok.mask_token_id
|
|
224
|
+
|
|
225
|
+
def convert_ids_to_tokens(self, ids: List[int]) -> List[str]:
|
|
226
|
+
return [self._tok.id2token.get(i, "<unk>") for i in ids]
|
|
227
|
+
|
|
228
|
+
def convert_tokens_to_ids(self, tokens: List[str]) -> List[int]:
|
|
229
|
+
return [self._tok.token2id.get(t, self._tok.unk_token_id) for t in tokens]
|
|
230
|
+
|
|
231
|
+
def convert_tokens_to_string(self, tokens: List[str]) -> str:
|
|
232
|
+
return "".join(tokens)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Morpheme boundary detection: Morfeusz2 with heuristic fallback."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Set, Tuple
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import morfeusz2
|
|
7
|
+
MORFEUSZ_AVAILABLE = True
|
|
8
|
+
except ImportError:
|
|
9
|
+
MORFEUSZ_AVAILABLE = False
|
|
10
|
+
|
|
11
|
+
# Heuristic suffix/prefix lists for when Morfeusz2 is unavailable
|
|
12
|
+
_HEURISTIC_PREFIXES = [
|
|
13
|
+
"nie", "prze", "roz", "pod", "nad", "bez", "naj", "przy",
|
|
14
|
+
]
|
|
15
|
+
_HEURISTIC_SUFFIXES = [
|
|
16
|
+
"owanie", "ować", "owy", "owa", "owe",
|
|
17
|
+
"ych", "ego", "emu", "ami", "ach",
|
|
18
|
+
"ów", "em", "ę", "ą", "ć",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MorfeuszBackend:
|
|
23
|
+
"""Detects morpheme boundaries using Morfeusz2 or heuristic fallback."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, use_morfeusz: bool = True):
|
|
26
|
+
self._morfeusz = None
|
|
27
|
+
if use_morfeusz and MORFEUSZ_AVAILABLE:
|
|
28
|
+
self._morfeusz = morfeusz2.Morfeusz()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def has_morfeusz(self) -> bool:
|
|
32
|
+
return self._morfeusz is not None
|
|
33
|
+
|
|
34
|
+
def get_boundaries(self, word: str) -> Tuple[Set[int], Set[int]]:
|
|
35
|
+
"""Return (prefix_boundaries, suffix_boundaries) as character positions."""
|
|
36
|
+
if self._morfeusz is not None:
|
|
37
|
+
return self._morfeusz_boundaries(word)
|
|
38
|
+
return self._heuristic_boundaries(word)
|
|
39
|
+
|
|
40
|
+
def get_boundaries_batch(
|
|
41
|
+
self, words: list[str]
|
|
42
|
+
) -> Dict[str, Tuple[Set[int], Set[int]]]:
|
|
43
|
+
"""Compute boundaries for a list of unique words."""
|
|
44
|
+
return {w: self.get_boundaries(w) for w in words}
|
|
45
|
+
|
|
46
|
+
# ── Morfeusz2 path ──────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def _morfeusz_boundaries(self, word: str) -> Tuple[Set[int], Set[int]]:
|
|
49
|
+
prefix_b: Set[int] = set()
|
|
50
|
+
suffix_b: Set[int] = set()
|
|
51
|
+
analysis = self._morfeusz.analyse(word)
|
|
52
|
+
if not analysis:
|
|
53
|
+
return prefix_b, suffix_b
|
|
54
|
+
|
|
55
|
+
interp = analysis[0]
|
|
56
|
+
lemma = interp[2][1] if len(interp[2]) > 1 else interp[2][0]
|
|
57
|
+
if not lemma or len(lemma) >= len(word):
|
|
58
|
+
return prefix_b, suffix_b
|
|
59
|
+
|
|
60
|
+
if word.startswith(lemma):
|
|
61
|
+
suffix_b.add(len(lemma))
|
|
62
|
+
else:
|
|
63
|
+
common = 0
|
|
64
|
+
for i in range(min(len(lemma), len(word))):
|
|
65
|
+
if lemma[i] == word[i]:
|
|
66
|
+
common = i + 1
|
|
67
|
+
else:
|
|
68
|
+
break
|
|
69
|
+
if 2 < common < len(word):
|
|
70
|
+
prefix_b.add(common)
|
|
71
|
+
|
|
72
|
+
return prefix_b, suffix_b
|
|
73
|
+
|
|
74
|
+
# ── Heuristic fallback ──────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _heuristic_boundaries(word: str) -> Tuple[Set[int], Set[int]]:
|
|
78
|
+
prefix_b: Set[int] = set()
|
|
79
|
+
suffix_b: Set[int] = set()
|
|
80
|
+
for pfx in _HEURISTIC_PREFIXES:
|
|
81
|
+
if word.startswith(pfx) and len(word) > len(pfx) + 2:
|
|
82
|
+
prefix_b.add(len(pfx))
|
|
83
|
+
for sfx in _HEURISTIC_SUFFIXES:
|
|
84
|
+
if word.endswith(sfx) and len(word) > len(sfx) + 2:
|
|
85
|
+
suffix_b.add(len(word) - len(sfx))
|
|
86
|
+
return prefix_b, suffix_b
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Morphological metrics: SI, ISS, MFL for tokenizer evaluation.
|
|
2
|
+
|
|
3
|
+
Metrics:
|
|
4
|
+
- SI (Stem Integrity): fraction of forms where the stem is preserved as a single token
|
|
5
|
+
- ISS (Inflectional Suffix Separation): fraction of forms where the inflectional
|
|
6
|
+
suffix is a separate token (not merged with stem)
|
|
7
|
+
- MFL (Mean Fragmentation per Lexeme): average number of tokens per form,
|
|
8
|
+
averaged across all forms of a lexeme
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from morphbpe.morfeusz_backend import MorfeuszBackend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LexemeResult:
|
|
19
|
+
"""Per-lexeme evaluation result."""
|
|
20
|
+
lemma: str
|
|
21
|
+
forms: List[str]
|
|
22
|
+
mfl: float = 0.0
|
|
23
|
+
si: float = 0.0
|
|
24
|
+
iss: float = 0.0
|
|
25
|
+
per_form: List[dict] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Polish test paradigms ──────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
# 10 lexemes: 5 verbs (incl. suppletive iść, być) + 5 nouns
|
|
31
|
+
MORPH_PARADIGMS: Dict[str, List[str]] = {
|
|
32
|
+
# Verbs
|
|
33
|
+
"robić": [
|
|
34
|
+
"robię", "robisz", "robi", "robimy", "robicie", "robią",
|
|
35
|
+
"robiłem", "robiłeś", "robił", "robiłam", "robiłaś", "robiła",
|
|
36
|
+
"robiłem", "robiliśmy", "robiliście", "robili",
|
|
37
|
+
"rób", "róbmy", "róbcie",
|
|
38
|
+
"robić",
|
|
39
|
+
],
|
|
40
|
+
"pisać": [
|
|
41
|
+
"piszę", "piszesz", "pisze", "piszemy", "piszecie", "piszą",
|
|
42
|
+
"pisałem", "pisałeś", "pisał", "pisałam", "pisałaś", "pisała",
|
|
43
|
+
"pisaliśmy", "pisaliście", "pisali",
|
|
44
|
+
"pisz", "piszmy", "piszcie",
|
|
45
|
+
"pisać",
|
|
46
|
+
],
|
|
47
|
+
"czytać": [
|
|
48
|
+
"czytam", "czytasz", "czyta", "czytamy", "czytacie", "czytają",
|
|
49
|
+
"czytałem", "czytałeś", "czytał", "czytałam", "czytałaś", "czytała",
|
|
50
|
+
"czytaliśmy", "czytaliście", "czytali",
|
|
51
|
+
"czytaj", "czytajmy", "czytajcie",
|
|
52
|
+
"czytać",
|
|
53
|
+
],
|
|
54
|
+
# Suppletive
|
|
55
|
+
"iść": [
|
|
56
|
+
"idę", "idziesz", "idzie", "idziemy", "idziecie", "idą",
|
|
57
|
+
"szedłem", "szedłeś", "szedł", "szłam", "szłaś", "szła",
|
|
58
|
+
"szliśmy", "szliście", "szli",
|
|
59
|
+
"idź", "idźmy", "idźcie",
|
|
60
|
+
"iść",
|
|
61
|
+
],
|
|
62
|
+
"być": [
|
|
63
|
+
"jestem", "jesteś", "jest", "jesteśmy", "jesteście", "są",
|
|
64
|
+
"byłem", "byłeś", "był", "byłam", "byłaś", "była",
|
|
65
|
+
"byliśmy", "byliście", "byli",
|
|
66
|
+
"bądź", "bądźmy", "bądźcie",
|
|
67
|
+
"być",
|
|
68
|
+
],
|
|
69
|
+
# Nouns
|
|
70
|
+
"ustawa": [
|
|
71
|
+
"ustawa", "ustawy", "ustawie", "ustawę", "ustawą", "ustawo",
|
|
72
|
+
"ustawy", "ustaw", "ustawom", "ustawy", "ustawami", "ustawach",
|
|
73
|
+
],
|
|
74
|
+
"człowiek": [
|
|
75
|
+
"człowiek", "człowieka", "człowiekowi", "człowieka", "człowiekiem", "człowieku",
|
|
76
|
+
"ludzie", "ludzi", "ludziom", "ludzi", "ludźmi", "ludziach",
|
|
77
|
+
],
|
|
78
|
+
"dziecko": [
|
|
79
|
+
"dziecko", "dziecka", "dziecku", "dziecko", "dzieckiem", "dziecku",
|
|
80
|
+
"dzieci", "dzieci", "dzieciom", "dzieci", "dziećmi", "dzieciach",
|
|
81
|
+
],
|
|
82
|
+
"prawo": [
|
|
83
|
+
"prawo", "prawa", "prawu", "prawo", "prawem", "prawie",
|
|
84
|
+
"prawa", "praw", "prawom", "prawa", "prawami", "prawach",
|
|
85
|
+
],
|
|
86
|
+
"kraj": [
|
|
87
|
+
"kraj", "kraju", "krajowi", "kraj", "krajem", "kraju",
|
|
88
|
+
"kraje", "krajów", "krajom", "kraje", "krajami", "krajach",
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_stem(lemma: str, form: str) -> Tuple[str, str, str]:
|
|
94
|
+
"""Heuristic stem extraction: longest common prefix of lemma and form.
|
|
95
|
+
|
|
96
|
+
Returns (prefix, stem, suffix) of the form.
|
|
97
|
+
prefix = characters before stem (empty for most Polish forms)
|
|
98
|
+
stem = shared initial substring
|
|
99
|
+
suffix = remaining characters after stem
|
|
100
|
+
"""
|
|
101
|
+
common = 0
|
|
102
|
+
for i in range(min(len(lemma), len(form))):
|
|
103
|
+
if lemma[i] == form[i]:
|
|
104
|
+
common = i + 1
|
|
105
|
+
else:
|
|
106
|
+
break
|
|
107
|
+
if common < 2:
|
|
108
|
+
# Suppletive — no shared stem
|
|
109
|
+
return "", form, ""
|
|
110
|
+
stem = form[:common]
|
|
111
|
+
suffix = form[common:]
|
|
112
|
+
return "", stem, suffix
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def evaluate_lexeme(lemma: str, forms: List[str], encode_word_fn) -> LexemeResult:
|
|
116
|
+
"""Evaluate a single lexeme using morphological metrics.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
lemma : str
|
|
121
|
+
Dictionary form of the lexeme.
|
|
122
|
+
forms : list of str
|
|
123
|
+
All inflected forms to evaluate.
|
|
124
|
+
encode_word_fn : callable
|
|
125
|
+
Function that takes a word and returns list of subword tokens.
|
|
126
|
+
"""
|
|
127
|
+
unique_forms = list(dict.fromkeys(forms)) # deduplicate, preserve order
|
|
128
|
+
total_tokens = 0
|
|
129
|
+
stem_intact_count = 0
|
|
130
|
+
suffix_sep_count = 0
|
|
131
|
+
per_form = []
|
|
132
|
+
|
|
133
|
+
for form in unique_forms:
|
|
134
|
+
tokens = encode_word_fn(form)
|
|
135
|
+
n_tokens = len(tokens)
|
|
136
|
+
total_tokens += n_tokens
|
|
137
|
+
|
|
138
|
+
_, stem, suffix = _find_stem(lemma, form)
|
|
139
|
+
|
|
140
|
+
# SI: is the stem a single token (or contained in one token)?
|
|
141
|
+
stem_intact = any(stem in t for t in tokens) if stem else False
|
|
142
|
+
|
|
143
|
+
# ISS: is the suffix a separate token?
|
|
144
|
+
if suffix:
|
|
145
|
+
suffix_sep = suffix in tokens
|
|
146
|
+
else:
|
|
147
|
+
suffix_sep = True # no suffix to separate
|
|
148
|
+
|
|
149
|
+
if stem_intact:
|
|
150
|
+
stem_intact_count += 1
|
|
151
|
+
if suffix_sep:
|
|
152
|
+
suffix_sep_count += 1
|
|
153
|
+
|
|
154
|
+
per_form.append({
|
|
155
|
+
"form": form,
|
|
156
|
+
"tokens": tokens,
|
|
157
|
+
"n_tokens": n_tokens,
|
|
158
|
+
"stem": stem,
|
|
159
|
+
"suffix": suffix,
|
|
160
|
+
"stem_intact": stem_intact,
|
|
161
|
+
"suffix_sep": suffix_sep,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
n = len(unique_forms)
|
|
165
|
+
return LexemeResult(
|
|
166
|
+
lemma=lemma,
|
|
167
|
+
forms=unique_forms,
|
|
168
|
+
mfl=total_tokens / n if n else 0.0,
|
|
169
|
+
si=stem_intact_count / n if n else 0.0,
|
|
170
|
+
iss=suffix_sep_count / n if n else 0.0,
|
|
171
|
+
per_form=per_form,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def evaluate_all(
|
|
176
|
+
encode_word_fn,
|
|
177
|
+
paradigms: Optional[Dict[str, List[str]]] = None,
|
|
178
|
+
) -> Tuple[List[LexemeResult], Dict[str, float]]:
|
|
179
|
+
"""Evaluate tokenizer on all morphological paradigms.
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
results : list of LexemeResult
|
|
184
|
+
summary : dict with macro-averaged SI, ISS, MFL
|
|
185
|
+
"""
|
|
186
|
+
if paradigms is None:
|
|
187
|
+
paradigms = MORPH_PARADIGMS
|
|
188
|
+
|
|
189
|
+
results = []
|
|
190
|
+
for lemma, forms in paradigms.items():
|
|
191
|
+
r = evaluate_lexeme(lemma, forms, encode_word_fn)
|
|
192
|
+
results.append(r)
|
|
193
|
+
|
|
194
|
+
n = len(results)
|
|
195
|
+
summary = {
|
|
196
|
+
"SI": sum(r.si for r in results) / n if n else 0.0,
|
|
197
|
+
"ISS": sum(r.iss for r in results) / n if n else 0.0,
|
|
198
|
+
"MFL": sum(r.mfl for r in results) / n if n else 0.0,
|
|
199
|
+
"n_lexemes": n,
|
|
200
|
+
"n_forms": sum(len(r.forms) for r in results),
|
|
201
|
+
}
|
|
202
|
+
return results, summary
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def print_report(results: List[LexemeResult], summary: Dict[str, float]) -> str:
|
|
206
|
+
"""Format a readable report string."""
|
|
207
|
+
lines = []
|
|
208
|
+
lines.append("Morphological Metrics Report")
|
|
209
|
+
lines.append("=" * 70)
|
|
210
|
+
lines.append(
|
|
211
|
+
f"{'Lexeme':<15} {'Forms':>5} {'MFL':>6} {'SI':>6} {'ISS':>6}"
|
|
212
|
+
)
|
|
213
|
+
lines.append("-" * 70)
|
|
214
|
+
for r in results:
|
|
215
|
+
lines.append(
|
|
216
|
+
f"{r.lemma:<15} {len(r.forms):>5} "
|
|
217
|
+
f"{r.mfl:>6.2f} {r.si:>6.1%} {r.iss:>6.1%}"
|
|
218
|
+
)
|
|
219
|
+
lines.append("-" * 70)
|
|
220
|
+
lines.append(
|
|
221
|
+
f"{'MACRO AVG':<15} {summary['n_forms']:>5} "
|
|
222
|
+
f"{summary['MFL']:>6.2f} {summary['SI']:>6.1%} {summary['ISS']:>6.1%}"
|
|
223
|
+
)
|
|
224
|
+
lines.append("=" * 70)
|
|
225
|
+
return "\n".join(lines)
|