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/tokenizer.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""MorphBPE tokenizer: BPE with morphological boundary penalties."""
|
|
2
|
+
|
|
3
|
+
import collections
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Set, Tuple, Union
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
|
|
13
|
+
from morphbpe.morfeusz_backend import MorfeuszBackend
|
|
14
|
+
|
|
15
|
+
# ── Special tokens ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
SPECIAL_TOKENS = {
|
|
18
|
+
"<pad>": 0,
|
|
19
|
+
"<unk>": 1,
|
|
20
|
+
"<cls>": 2,
|
|
21
|
+
"<sep>": 3,
|
|
22
|
+
"<mask>": 4,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# ── Byte-level tokens ──────────────────────────────────────────────
|
|
26
|
+
# 256 byte tokens for fallback encoding of any UTF-8 input.
|
|
27
|
+
# IDs 5..260 are reserved for bytes. Character vocab starts at 261.
|
|
28
|
+
|
|
29
|
+
def _make_byte_tokens() -> Tuple[Dict[str, int], Dict[int, str]]:
|
|
30
|
+
"""Create byte-level token mappings. Returns (token2id, id2token)."""
|
|
31
|
+
t2i: Dict[str, int] = {}
|
|
32
|
+
i2t: Dict[int, str] = {}
|
|
33
|
+
for i in range(256):
|
|
34
|
+
# Use printable representation: raw byte for printable ASCII,
|
|
35
|
+
# hex escape for others (similar to GPT-2's byte encoder)
|
|
36
|
+
if 33 <= i <= 126 and i != 96: # printable except backtick
|
|
37
|
+
ch = chr(i)
|
|
38
|
+
else:
|
|
39
|
+
ch = f"<0x{i:02X}>"
|
|
40
|
+
t2i[ch] = i + 5
|
|
41
|
+
i2t[i + 5] = ch
|
|
42
|
+
return t2i, i2t
|
|
43
|
+
|
|
44
|
+
BYTE_TOKENS, BYTE_TOKENS_INV = _make_byte_tokens()
|
|
45
|
+
|
|
46
|
+
# Character that maps to each byte value for UTF-8 fallback
|
|
47
|
+
_BYTE_CHARS: Dict[int, str] = {}
|
|
48
|
+
for _i in range(256):
|
|
49
|
+
if 33 <= _i <= 126 and _i != 96:
|
|
50
|
+
_BYTE_CHARS[_i] = chr(_i)
|
|
51
|
+
else:
|
|
52
|
+
_BYTE_CHARS[_i] = f"<0x{_i:02X}>"
|
|
53
|
+
|
|
54
|
+
# ID where regular character vocab starts (after special + byte tokens)
|
|
55
|
+
VOCAB_OFFSET = 261
|
|
56
|
+
|
|
57
|
+
# ── Pre-tokenization regex ──────────────────────────────────────────
|
|
58
|
+
# Splits punctuation, contractions, numbers, and whitespace.
|
|
59
|
+
# Similar to GPT-2's tokenizer but adapted for Polish.
|
|
60
|
+
|
|
61
|
+
_PRE_TOKENIZE_RE = re.compile(
|
|
62
|
+
r"""
|
|
63
|
+
(?:[^\W\d_]+) | # letters (including Polish diacritics)
|
|
64
|
+
(?:\d[\d\.]*\d) | # numbers (including decimals)
|
|
65
|
+
(?:\d+) | # single digits
|
|
66
|
+
(?:[^\w\s]) | # punctuation (single char)
|
|
67
|
+
(?:\s+) # whitespace
|
|
68
|
+
""",
|
|
69
|
+
re.UNICODE | re.VERBOSE,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pre_tokenize(text: str) -> List[str]:
|
|
74
|
+
"""Split text into pre-tokens (words, punctuation, numbers)."""
|
|
75
|
+
return _PRE_TOKENIZE_RE.findall(text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Trie for O(m) encoding ──────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _TrieNode:
|
|
82
|
+
"""Trie node for fast token lookup."""
|
|
83
|
+
|
|
84
|
+
__slots__ = ("children", "token_id")
|
|
85
|
+
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self.children: Dict[str, "_TrieNode"] = {}
|
|
88
|
+
self.token_id: Optional[int] = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _Trie:
|
|
92
|
+
"""Trie for longest-match-first tokenization."""
|
|
93
|
+
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self.root = _TrieNode()
|
|
96
|
+
|
|
97
|
+
def insert(self, token: str, token_id: int) -> None:
|
|
98
|
+
node = self.root
|
|
99
|
+
for ch in token:
|
|
100
|
+
if ch not in node.children:
|
|
101
|
+
node.children[ch] = _TrieNode()
|
|
102
|
+
node = node.children[ch]
|
|
103
|
+
node.token_id = token_id
|
|
104
|
+
|
|
105
|
+
def tokenize(self, word: str, unk_id: int) -> List[Tuple[str, int]]:
|
|
106
|
+
"""Greedy longest-match tokenization with byte-level fallback.
|
|
107
|
+
|
|
108
|
+
Returns (token_str, id) pairs. For unknown characters, encodes
|
|
109
|
+
as UTF-8 bytes using the byte token table.
|
|
110
|
+
"""
|
|
111
|
+
result: List[Tuple[str, int]] = []
|
|
112
|
+
i = 0
|
|
113
|
+
n = len(word)
|
|
114
|
+
while i < n:
|
|
115
|
+
node = self.root
|
|
116
|
+
best_end = -1
|
|
117
|
+
best_id = -1
|
|
118
|
+
j = i
|
|
119
|
+
while j < n and word[j] in node.children:
|
|
120
|
+
node = node.children[word[j]]
|
|
121
|
+
j += 1
|
|
122
|
+
if node.token_id is not None:
|
|
123
|
+
best_end = j
|
|
124
|
+
best_id = node.token_id
|
|
125
|
+
if best_end == -1:
|
|
126
|
+
# No match — encode character as UTF-8 bytes
|
|
127
|
+
ch = word[i]
|
|
128
|
+
ch_bytes = ch.encode("utf-8")
|
|
129
|
+
for b in ch_bytes:
|
|
130
|
+
byte_str = _BYTE_CHARS[b]
|
|
131
|
+
byte_id = BYTE_TOKENS[byte_str]
|
|
132
|
+
result.append((byte_str, byte_id))
|
|
133
|
+
i += 1
|
|
134
|
+
else:
|
|
135
|
+
result.append((word[i:best_end], best_id))
|
|
136
|
+
i = best_end
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── MorphBPE Tokenizer ──────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class MorphBPETokenizer:
|
|
144
|
+
"""Morphologically-aware BPE tokenizer.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
vocab_size : int
|
|
149
|
+
Target vocabulary size (default 1000).
|
|
150
|
+
alpha : float
|
|
151
|
+
Penalty strength for boundary-crossing merges.
|
|
152
|
+
0.0 = standard BPE, 1.0 = full block, >1.0 = over-penalization.
|
|
153
|
+
constraint : str
|
|
154
|
+
Which boundaries to enforce: "suffix", "prefix", or "both".
|
|
155
|
+
use_morfeusz : bool
|
|
156
|
+
Use Morfeusz2 if available; otherwise heuristic fallback.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
vocab_size: int = 1000,
|
|
162
|
+
alpha: float = 1.0,
|
|
163
|
+
constraint: str = "suffix",
|
|
164
|
+
use_morfeusz: bool = True,
|
|
165
|
+
):
|
|
166
|
+
if constraint not in ("suffix", "prefix", "both"):
|
|
167
|
+
raise ValueError(f"constraint must be suffix/prefix/both, got {constraint}")
|
|
168
|
+
|
|
169
|
+
self.vocab_size = vocab_size
|
|
170
|
+
self.alpha = alpha
|
|
171
|
+
self.constraint = constraint
|
|
172
|
+
|
|
173
|
+
self._backend = MorfeuszBackend(use_morfeusz=use_morfeusz)
|
|
174
|
+
self.token2id: Dict[str, int] = {}
|
|
175
|
+
self.id2token: Dict[int, str] = {}
|
|
176
|
+
self.merges: List[Tuple[str, str]] = []
|
|
177
|
+
|
|
178
|
+
# Special tokens are always present
|
|
179
|
+
self._special_tokens = dict(SPECIAL_TOKENS)
|
|
180
|
+
self._inv_special_tokens: Dict[int, str] = {v: k for k, v in SPECIAL_TOKENS.items()}
|
|
181
|
+
self.pad_token_id = 0
|
|
182
|
+
self.unk_token_id = 1
|
|
183
|
+
self.cls_token_id = 2
|
|
184
|
+
self.sep_token_id = 3
|
|
185
|
+
self.mask_token_id = 4
|
|
186
|
+
|
|
187
|
+
# Trie for fast encoding (built after training/loading)
|
|
188
|
+
self._trie: Optional[_Trie] = None
|
|
189
|
+
self._encode_cache: Dict[str, List[str]] = {}
|
|
190
|
+
|
|
191
|
+
# ── Properties ──────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def special_tokens(self) -> Dict[str, int]:
|
|
195
|
+
return dict(self._special_tokens)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def all_special_tokens(self) -> List[str]:
|
|
199
|
+
return list(self._special_tokens.keys())
|
|
200
|
+
|
|
201
|
+
def __len__(self) -> int:
|
|
202
|
+
return len(self.token2id)
|
|
203
|
+
|
|
204
|
+
def __contains__(self, token: str) -> bool:
|
|
205
|
+
return token in self.token2id
|
|
206
|
+
|
|
207
|
+
# ── Trie management ─────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
def _build_trie(self) -> None:
|
|
210
|
+
"""Build trie from current vocab for fast encoding."""
|
|
211
|
+
self._trie = _Trie()
|
|
212
|
+
for token, idx in self.token2id.items():
|
|
213
|
+
self._trie.insert(token, idx)
|
|
214
|
+
self._encode_cache.clear()
|
|
215
|
+
|
|
216
|
+
# ── Training ────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def train(self, texts: List[str], verbose: bool = True) -> float:
|
|
219
|
+
"""Train tokenizer on a list of texts. Returns training time in seconds."""
|
|
220
|
+
start = time.time()
|
|
221
|
+
|
|
222
|
+
word_freq: Dict[str, int] = collections.defaultdict(int)
|
|
223
|
+
boundaries: Dict[str, Tuple[Set[int], Set[int]]] = {}
|
|
224
|
+
it = tqdm(texts, desc="Preprocessing") if verbose else texts
|
|
225
|
+
for text in it:
|
|
226
|
+
# Use pre-tokenizer instead of naive split
|
|
227
|
+
tokens = pre_tokenize(text)
|
|
228
|
+
for tok in tokens:
|
|
229
|
+
if tok.isspace():
|
|
230
|
+
continue # skip whitespace tokens
|
|
231
|
+
word_freq[" ".join(list(tok))] += 1
|
|
232
|
+
if tok not in boundaries:
|
|
233
|
+
boundaries[tok] = self._backend.get_boundaries(tok)
|
|
234
|
+
|
|
235
|
+
# Initial character vocab (after special tokens)
|
|
236
|
+
chars: Set[str] = set()
|
|
237
|
+
for w in word_freq:
|
|
238
|
+
chars.update(w.split())
|
|
239
|
+
|
|
240
|
+
# Initialize vocab with special tokens + byte tokens
|
|
241
|
+
self.token2id = dict(SPECIAL_TOKENS)
|
|
242
|
+
self.token2id.update(BYTE_TOKENS)
|
|
243
|
+
self.id2token = {v: k for k, v in self.token2id.items()}
|
|
244
|
+
|
|
245
|
+
# Character vocab starts after special + byte tokens
|
|
246
|
+
next_id = VOCAB_OFFSET
|
|
247
|
+
|
|
248
|
+
for c in sorted(chars):
|
|
249
|
+
if c not in self.token2id:
|
|
250
|
+
self.token2id[c] = next_id
|
|
251
|
+
self.id2token[next_id] = c
|
|
252
|
+
next_id += 1
|
|
253
|
+
|
|
254
|
+
n_merges = self.vocab_size - len(self.token2id)
|
|
255
|
+
it = tqdm(range(n_merges), desc="Merging") if verbose else range(n_merges)
|
|
256
|
+
for _ in it:
|
|
257
|
+
pairs = self._pair_scores(word_freq, boundaries)
|
|
258
|
+
if not pairs:
|
|
259
|
+
break
|
|
260
|
+
best = max(pairs, key=pairs.get)
|
|
261
|
+
self.merges.append(best)
|
|
262
|
+
word_freq = self._apply_merge(word_freq, best)
|
|
263
|
+
token = "".join(best)
|
|
264
|
+
if token not in self.token2id:
|
|
265
|
+
self.token2id[token] = next_id
|
|
266
|
+
self.id2token[next_id] = token
|
|
267
|
+
next_id += 1
|
|
268
|
+
|
|
269
|
+
# Build trie for fast encoding
|
|
270
|
+
self._build_trie()
|
|
271
|
+
|
|
272
|
+
elapsed = time.time() - start
|
|
273
|
+
if verbose:
|
|
274
|
+
print(
|
|
275
|
+
f"Trained: vocab={len(self.token2id)}, "
|
|
276
|
+
f"merges={len(self.merges)}, time={elapsed:.1f}s"
|
|
277
|
+
)
|
|
278
|
+
return elapsed
|
|
279
|
+
|
|
280
|
+
def train_from_file(self, path: str, verbose: bool = True) -> float:
|
|
281
|
+
"""Train from a plain-text file (one document per line)."""
|
|
282
|
+
texts = Path(path).read_text(encoding="utf-8").splitlines()
|
|
283
|
+
texts = [t for t in texts if t.strip()]
|
|
284
|
+
return self.train(texts, verbose=verbose)
|
|
285
|
+
|
|
286
|
+
# ── Encode / Decode ─────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def encode_word(self, word: str) -> List[str]:
|
|
289
|
+
"""Tokenize a single word into subword strings."""
|
|
290
|
+
if not self._trie:
|
|
291
|
+
self._build_trie()
|
|
292
|
+
|
|
293
|
+
if word in self._encode_cache:
|
|
294
|
+
return self._encode_cache[word]
|
|
295
|
+
|
|
296
|
+
# Use trie for fast encoding
|
|
297
|
+
pairs = self._trie.tokenize(word, self.unk_token_id)
|
|
298
|
+
result = [tok_str for tok_str, _ in pairs]
|
|
299
|
+
self._encode_cache[word] = result
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
def encode(
|
|
303
|
+
self,
|
|
304
|
+
text: str,
|
|
305
|
+
add_special_tokens: bool = False,
|
|
306
|
+
) -> List[int]:
|
|
307
|
+
"""Encode text to a list of token IDs.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
text : str
|
|
312
|
+
Input text.
|
|
313
|
+
add_special_tokens : bool
|
|
314
|
+
If True, prepend [CLS] and append [SEP].
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
List[int]
|
|
319
|
+
Token IDs.
|
|
320
|
+
"""
|
|
321
|
+
if not self._trie:
|
|
322
|
+
self._build_trie()
|
|
323
|
+
|
|
324
|
+
ids: List[int] = []
|
|
325
|
+
if add_special_tokens:
|
|
326
|
+
ids.append(self.cls_token_id)
|
|
327
|
+
|
|
328
|
+
# Pre-tokenize to handle punctuation
|
|
329
|
+
tokens = pre_tokenize(text)
|
|
330
|
+
for tok in tokens:
|
|
331
|
+
if tok.isspace():
|
|
332
|
+
continue # skip whitespace
|
|
333
|
+
for subtok in self.encode_word(tok):
|
|
334
|
+
ids.append(self.token2id.get(subtok, self.unk_token_id))
|
|
335
|
+
|
|
336
|
+
if add_special_tokens:
|
|
337
|
+
ids.append(self.sep_token_id)
|
|
338
|
+
|
|
339
|
+
return ids
|
|
340
|
+
|
|
341
|
+
def decode(
|
|
342
|
+
self,
|
|
343
|
+
ids: List[int],
|
|
344
|
+
skip_special_tokens: bool = True,
|
|
345
|
+
spaces_between_words: bool = True,
|
|
346
|
+
) -> str:
|
|
347
|
+
"""Decode token IDs back to text.
|
|
348
|
+
|
|
349
|
+
Parameters
|
|
350
|
+
----------
|
|
351
|
+
ids : List[int]
|
|
352
|
+
Token IDs to decode.
|
|
353
|
+
skip_special_tokens : bool
|
|
354
|
+
If True, skip special tokens in output.
|
|
355
|
+
spaces_between_words : bool
|
|
356
|
+
If True, insert spaces between word-initial tokens.
|
|
357
|
+
|
|
358
|
+
Returns
|
|
359
|
+
-------
|
|
360
|
+
str
|
|
361
|
+
Decoded text.
|
|
362
|
+
"""
|
|
363
|
+
special_ids = set(SPECIAL_TOKENS.values())
|
|
364
|
+
parts: List[str] = []
|
|
365
|
+
for i in ids:
|
|
366
|
+
if skip_special_tokens and i in special_ids:
|
|
367
|
+
continue
|
|
368
|
+
token = self.id2token.get(i, "<unk>")
|
|
369
|
+
if token == "<unk>":
|
|
370
|
+
continue
|
|
371
|
+
parts.append(token)
|
|
372
|
+
|
|
373
|
+
if not spaces_between_words:
|
|
374
|
+
return "".join(parts)
|
|
375
|
+
|
|
376
|
+
# Heuristic: insert space before tokens that look like word starts
|
|
377
|
+
# (lowercase letter or Polish uppercase at word boundary)
|
|
378
|
+
result = parts[0] if parts else ""
|
|
379
|
+
for i in range(1, len(parts)):
|
|
380
|
+
prev = parts[i - 1]
|
|
381
|
+
curr = parts[i]
|
|
382
|
+
# Space if current token starts a new word:
|
|
383
|
+
# - previous token ended with punctuation
|
|
384
|
+
# - current token is a standalone character that's likely a word start
|
|
385
|
+
if (
|
|
386
|
+
prev[-1] in ".,;:!?)]}\u201d\u2019"
|
|
387
|
+
or curr[0] in "({[\u201c\u2018"
|
|
388
|
+
or (len(prev) == 1 and prev in "iyIY") # Polish conjunctions
|
|
389
|
+
):
|
|
390
|
+
result += " " + curr
|
|
391
|
+
else:
|
|
392
|
+
result += curr
|
|
393
|
+
return result
|
|
394
|
+
|
|
395
|
+
def batch_encode(
|
|
396
|
+
self,
|
|
397
|
+
texts: List[str],
|
|
398
|
+
add_special_tokens: bool = False,
|
|
399
|
+
padding: bool = True,
|
|
400
|
+
max_length: Optional[int] = None,
|
|
401
|
+
return_attention_mask: bool = True,
|
|
402
|
+
) -> Dict[str, List[List[int]]]:
|
|
403
|
+
"""Encode a batch of texts.
|
|
404
|
+
|
|
405
|
+
Parameters
|
|
406
|
+
----------
|
|
407
|
+
texts : List[str]
|
|
408
|
+
Input texts.
|
|
409
|
+
add_special_tokens : bool
|
|
410
|
+
If True, prepend [CLS] and append [SEP].
|
|
411
|
+
padding : bool
|
|
412
|
+
If True, pad to max length in batch.
|
|
413
|
+
max_length : int, optional
|
|
414
|
+
Maximum sequence length (truncates if longer).
|
|
415
|
+
return_attention_mask : bool
|
|
416
|
+
If True, return attention masks.
|
|
417
|
+
|
|
418
|
+
Returns
|
|
419
|
+
-------
|
|
420
|
+
dict
|
|
421
|
+
{"input_ids": List[List[int]], "attention_mask": List[List[int]]}
|
|
422
|
+
"""
|
|
423
|
+
batch_ids = [self.encode(t, add_special_tokens=add_special_tokens) for t in texts]
|
|
424
|
+
|
|
425
|
+
if max_length:
|
|
426
|
+
batch_ids = [ids[:max_length] for ids in batch_ids]
|
|
427
|
+
|
|
428
|
+
result = {"input_ids": batch_ids}
|
|
429
|
+
|
|
430
|
+
if padding:
|
|
431
|
+
max_len = max(len(ids) for ids in batch_ids) if batch_ids else 0
|
|
432
|
+
padded = []
|
|
433
|
+
masks = []
|
|
434
|
+
for ids in batch_ids:
|
|
435
|
+
mask = [1] * len(ids) + [0] * (max_len - len(ids))
|
|
436
|
+
padded.append(ids + [self.pad_token_id] * (max_len - len(ids)))
|
|
437
|
+
masks.append(mask)
|
|
438
|
+
result["input_ids"] = padded
|
|
439
|
+
if return_attention_mask:
|
|
440
|
+
result["attention_mask"] = masks
|
|
441
|
+
elif return_attention_mask:
|
|
442
|
+
result["attention_mask"] = [[1] * len(ids) for ids in batch_ids]
|
|
443
|
+
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def vocab(self) -> Dict[str, int]:
|
|
448
|
+
"""HuggingFace-compatible vocab property."""
|
|
449
|
+
return dict(self.token2id)
|
|
450
|
+
|
|
451
|
+
def get_vocab_size(self) -> int:
|
|
452
|
+
return len(self.token2id)
|
|
453
|
+
|
|
454
|
+
# ── Save / Load ─────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def save(self, directory: str) -> None:
|
|
457
|
+
"""Save tokenizer to a directory."""
|
|
458
|
+
path = Path(directory)
|
|
459
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
|
|
461
|
+
config = {
|
|
462
|
+
"vocab_size": self.vocab_size,
|
|
463
|
+
"alpha": self.alpha,
|
|
464
|
+
"constraint": self.constraint,
|
|
465
|
+
"has_morfeusz": self._backend.has_morfeusz,
|
|
466
|
+
"special_tokens": self._special_tokens,
|
|
467
|
+
"model_type": "morphbpe",
|
|
468
|
+
}
|
|
469
|
+
(path / "config.json").write_text(
|
|
470
|
+
json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
471
|
+
)
|
|
472
|
+
(path / "vocab.json").write_text(
|
|
473
|
+
json.dumps(self.token2id, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
474
|
+
)
|
|
475
|
+
merges_lines = [f"{a} {b}" for a, b in self.merges]
|
|
476
|
+
(path / "merges.txt").write_text(
|
|
477
|
+
"\n".join(merges_lines), encoding="utf-8"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
@classmethod
|
|
481
|
+
def load(cls, directory: str) -> "MorphBPETokenizer":
|
|
482
|
+
"""Load tokenizer from a directory."""
|
|
483
|
+
path = Path(directory)
|
|
484
|
+
config = json.loads((path / "config.json").read_text(encoding="utf-8"))
|
|
485
|
+
tok = cls(
|
|
486
|
+
vocab_size=config["vocab_size"],
|
|
487
|
+
alpha=config["alpha"],
|
|
488
|
+
constraint=config["constraint"],
|
|
489
|
+
use_morfeusz=config.get("has_morfeusz", True),
|
|
490
|
+
)
|
|
491
|
+
tok.token2id = json.loads((path / "vocab.json").read_text(encoding="utf-8"))
|
|
492
|
+
tok.id2token = {}
|
|
493
|
+
for k, v in tok.token2id.items():
|
|
494
|
+
tok.id2token[int(v)] = k
|
|
495
|
+
raw = (path / "merges.txt").read_text(encoding="utf-8").strip()
|
|
496
|
+
if raw:
|
|
497
|
+
tok.merges = [tuple(line.split(" ", 1)) for line in raw.splitlines()]
|
|
498
|
+
|
|
499
|
+
# Load special tokens if present
|
|
500
|
+
if "special_tokens" in config:
|
|
501
|
+
tok._special_tokens = config["special_tokens"]
|
|
502
|
+
tok._inv_special_tokens = {v: k for k, v in tok._special_tokens.items()}
|
|
503
|
+
|
|
504
|
+
# Build trie for fast encoding
|
|
505
|
+
tok._build_trie()
|
|
506
|
+
|
|
507
|
+
return tok
|
|
508
|
+
|
|
509
|
+
# ── Metrics ─────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
def fertility(self, texts: List[str]) -> float:
|
|
512
|
+
"""Average tokens per word."""
|
|
513
|
+
n_tok = n_word = 0
|
|
514
|
+
for text in texts:
|
|
515
|
+
for word in text.split():
|
|
516
|
+
n_tok += len(self.encode_word(word))
|
|
517
|
+
n_word += 1
|
|
518
|
+
return n_tok / n_word if n_word else 0.0
|
|
519
|
+
|
|
520
|
+
def morph_edit_distance(self, texts: List[str]) -> float:
|
|
521
|
+
"""Mean MorphED across words (≥3 chars)."""
|
|
522
|
+
vals = self._per_word_morphed(texts)
|
|
523
|
+
return float(np.mean(vals)) if vals else 0.0
|
|
524
|
+
|
|
525
|
+
def morph_edit_distance_ci(
|
|
526
|
+
self, texts: List[str], n_resamples: int = 1000, seed: int = 42
|
|
527
|
+
) -> Tuple[float, float, float]:
|
|
528
|
+
"""MorphED with bootstrap 95% CI → (mean, lower, upper)."""
|
|
529
|
+
vals = np.array(self._per_word_morphed(texts))
|
|
530
|
+
if len(vals) == 0:
|
|
531
|
+
return 0.0, 0.0, 0.0
|
|
532
|
+
rng = np.random.RandomState(seed)
|
|
533
|
+
means = [
|
|
534
|
+
np.mean(rng.choice(vals, size=len(vals), replace=True))
|
|
535
|
+
for _ in range(n_resamples)
|
|
536
|
+
]
|
|
537
|
+
return float(np.mean(vals)), float(np.percentile(means, 2.5)), float(np.percentile(means, 97.5))
|
|
538
|
+
|
|
539
|
+
def _per_word_morphed(self, texts: List[str]) -> List[int]:
|
|
540
|
+
result: List[int] = []
|
|
541
|
+
for text in texts:
|
|
542
|
+
for word in text.split():
|
|
543
|
+
if len(word) < 3:
|
|
544
|
+
continue
|
|
545
|
+
prefix_b, suffix_b = self._backend.get_boundaries(word)
|
|
546
|
+
all_b = sorted(prefix_b | suffix_b)
|
|
547
|
+
morphemes: List[str] = []
|
|
548
|
+
prev = 0
|
|
549
|
+
for b in all_b:
|
|
550
|
+
morphemes.append(word[prev:b])
|
|
551
|
+
prev = b
|
|
552
|
+
morphemes.append(word[prev:])
|
|
553
|
+
tokens = self.encode_word(word)
|
|
554
|
+
result.append(_list_edit_distance(morphemes, tokens))
|
|
555
|
+
return result
|
|
556
|
+
|
|
557
|
+
# ── Internal BPE logic ──────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
def _active_boundaries(
|
|
560
|
+
self, prefix_b: Set[int], suffix_b: Set[int]
|
|
561
|
+
) -> Set[int]:
|
|
562
|
+
if self.constraint == "prefix":
|
|
563
|
+
return prefix_b
|
|
564
|
+
if self.constraint == "suffix":
|
|
565
|
+
return suffix_b
|
|
566
|
+
return prefix_b | suffix_b
|
|
567
|
+
|
|
568
|
+
def _pair_scores(
|
|
569
|
+
self,
|
|
570
|
+
word_freq: Dict[str, int],
|
|
571
|
+
boundaries: Dict[str, Tuple[Set[int], Set[int]]],
|
|
572
|
+
) -> Dict[Tuple[str, str], float]:
|
|
573
|
+
pairs: Dict[Tuple[str, str], float] = collections.defaultdict(float)
|
|
574
|
+
for word, freq in word_freq.items():
|
|
575
|
+
tokens = word.split()
|
|
576
|
+
orig = word.replace(" ", "")
|
|
577
|
+
pfx_b, sfx_b = boundaries.get(orig, (set(), set()))
|
|
578
|
+
active = self._active_boundaries(pfx_b, sfx_b)
|
|
579
|
+
|
|
580
|
+
pos = 0
|
|
581
|
+
for i in range(len(tokens) - 1):
|
|
582
|
+
tlen = len(tokens[i])
|
|
583
|
+
crosses = (pos + tlen) in active
|
|
584
|
+
if crosses:
|
|
585
|
+
pairs[(tokens[i], tokens[i + 1])] += freq * (1.0 - self.alpha)
|
|
586
|
+
else:
|
|
587
|
+
pairs[(tokens[i], tokens[i + 1])] += freq
|
|
588
|
+
pos += tlen
|
|
589
|
+
return pairs
|
|
590
|
+
|
|
591
|
+
@staticmethod
|
|
592
|
+
def _apply_merge(
|
|
593
|
+
word_freq: Dict[str, int], pair: Tuple[str, str]
|
|
594
|
+
) -> Dict[str, int]:
|
|
595
|
+
bigram = " ".join(pair)
|
|
596
|
+
repl = "".join(pair)
|
|
597
|
+
return {w.replace(bigram, repl): f for w, f in word_freq.items()}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _list_edit_distance(a: List[str], b: List[str]) -> int:
|
|
601
|
+
"""Edit distance between two lists of strings."""
|
|
602
|
+
m, n = len(a), len(b)
|
|
603
|
+
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
|
604
|
+
for i in range(m + 1):
|
|
605
|
+
dp[i][0] = i
|
|
606
|
+
for j in range(n + 1):
|
|
607
|
+
dp[0][j] = j
|
|
608
|
+
for i in range(1, m + 1):
|
|
609
|
+
for j in range(1, n + 1):
|
|
610
|
+
if a[i - 1] == b[j - 1]:
|
|
611
|
+
dp[i][j] = dp[i - 1][j - 1]
|
|
612
|
+
else:
|
|
613
|
+
dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
614
|
+
return dp[m][n]
|