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/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]