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 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()
@@ -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)