basa 0.1.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.
basa/core/typo.py ADDED
@@ -0,0 +1,470 @@
1
+ """
2
+ BASA - Typo Correction Module
3
+ ==============================
4
+ Detects and corrects out-of-vocabulary words using Levenshtein distance
5
+ against a user-supplied vocabulary set.
6
+
7
+ Design philosophy:
8
+ - Vocabulary is NOT bundled into the library core. Every domain has
9
+ different vocabulary needs (fintech, e-commerce, ML, telecom), so
10
+ the caller is responsible for providing a relevant word list.
11
+ - The corrector is intentionally conservative: short words and
12
+ low-confidence matches are left untouched to avoid false positives.
13
+ - A lookup cache prevents redundant computation when the same token
14
+ appears many times in a corpus (very common in real-world data).
15
+
16
+ Limitations (to be addressed in v0.2):
17
+ - Linear scan over vocab is O(|vocab| × |word| × |candidate|).
18
+ For large vocabularies (50 k+ words) consider replacing the inner
19
+ loop with a BK-Tree or SymSpell index.
20
+ - Correction is purely character-level; no phonetic or semantic context
21
+ is used. The closest-distance word wins regardless of meaning.
22
+
23
+ Pipeline position:
24
+ Typo correction should run AFTER slang normalization and repeated-
25
+ character reduction, so that tokens like "gkkkk" are already collapsed
26
+ to "gk" (and then translated to "tidak") before the corrector sees them.
27
+
28
+ Recommended order:
29
+ lowercase → repeated-char normalization → slang replacement
30
+ → typo correction → whitespace cleanup
31
+
32
+ Usage:
33
+ from basa.core.typo import TypoCorrector
34
+
35
+ corrector = TypoCorrector(min_word_length=4)
36
+ corrector.add_to_vocab({"makan", "minum", "masak", "mander"})
37
+
38
+ corrector.correct("mander") # → "mander" (in vocab, no change)
39
+ corrector.correct("mnder") # → "mander" (distance 1, corrected)
40
+ corrector.correct("ok") # → "ok" (below min_word_length, skipped)
41
+
42
+ # Batch sentence correction
43
+ corrector.correct_text("saya mkan dan mnder")
44
+ # → "saya makan dan mander"
45
+
46
+ # Inspect cache hit rate (useful for profiling)
47
+ print(corrector.cache_info())
48
+
49
+ Notes:
50
+ - All comparisons are lowercased internally.
51
+ - The module-level singleton `typo` ships with an empty vocabulary.
52
+ Add domain words via add_to_vocab() or pass vocab= at construction.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+ # LEVENSHTEIN DISTANCE
59
+ # ─────────────────────────────────────────────────────────────────────────────
60
+
61
+
62
+ def levenshtein_distance(s1: str, s2: str) -> int:
63
+ """
64
+ Compute the Levenshtein (edit) distance between two strings.
65
+
66
+ The distance counts the minimum number of single-character edits
67
+ (insertions, deletions, or substitutions) required to transform
68
+ s1 into s2.
69
+
70
+ Implementation uses the standard iterative DP approach with a
71
+ single previous-row buffer, keeping memory usage O(min(|s1|, |s2|)).
72
+
73
+ Args:
74
+ s1: First string.
75
+ s2: Second string.
76
+
77
+ Returns:
78
+ Non-negative integer representing the edit distance.
79
+
80
+ Examples:
81
+ >>> levenshtein_distance("makan", "makan")
82
+ 0
83
+ >>> levenshtein_distance("mkan", "makan")
84
+ 1
85
+ >>> levenshtein_distance("mnder", "mander")
86
+ 1
87
+ >>> levenshtein_distance("xyz", "abc")
88
+ 3
89
+ """
90
+ # Ensure s1 is the longer string (optimises the inner loop slightly)
91
+ if len(s1) < len(s2):
92
+ return levenshtein_distance(s2, s1)
93
+
94
+ # Edge case: one string is empty
95
+ if len(s2) == 0:
96
+ return len(s1)
97
+
98
+ previous_row = range(len(s2) + 1)
99
+
100
+ for i, c1 in enumerate(s1):
101
+ current_row = [i + 1]
102
+ for j, c2 in enumerate(s2):
103
+ insertions = previous_row[j + 1] + 1
104
+ deletions = current_row[j] + 1
105
+ substitutions = previous_row[j] + (c1 != c2)
106
+ current_row.append(min(insertions, deletions, substitutions))
107
+ previous_row = current_row
108
+
109
+ return previous_row[-1]
110
+
111
+
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+ # TYPO CORRECTOR
114
+ # ─────────────────────────────────────────────────────────────────────────────
115
+
116
+
117
+ class TypoCorrector:
118
+ """
119
+ Context-free typo corrector based on minimum Levenshtein distance.
120
+
121
+ The corrector scans an unknown token against a vocabulary set and
122
+ returns the closest-matching word within a configurable edit distance.
123
+ Two safety guards prevent aggressive over-correction:
124
+
125
+ 1. ``min_word_length``: tokens shorter than this threshold are returned
126
+ unchanged. Very short words (1–3 chars) have many near-neighbours
127
+ at distance 1–2, making false positives very likely.
128
+
129
+ 2. ``min_confidence``: a soft ratio guard. After the best candidate
130
+ is found, confidence is computed as::
131
+
132
+ confidence = 1 - (edit_distance / len(word))
133
+
134
+ If confidence is below this threshold the original word is kept.
135
+ Default 0.5 means at most half the characters may be wrong.
136
+
137
+ Args:
138
+ vocab: Initial vocabulary set (lowercase recommended).
139
+ min_word_length: Minimum token length to attempt correction.
140
+ Tokens below this length are returned as-is.
141
+ Default: 4.
142
+ min_confidence: Minimum correction confidence in [0, 1].
143
+ Default: 0.5.
144
+
145
+ Example:
146
+ >>> corrector = TypoCorrector(min_word_length=4)
147
+ >>> corrector.add_to_vocab({"makan", "minum", "masak"})
148
+ >>> corrector.correct("mkan")
149
+ 'makan'
150
+ >>> corrector.correct("ok") # length 2 < min_word_length
151
+ 'ok'
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ vocab: set[str] | None = None,
157
+ min_word_length: int = 4,
158
+ min_confidence: float = 0.5,
159
+ ) -> None:
160
+ self.vocab: set[str] = {w.lower() for w in vocab} if vocab else set()
161
+ self.min_word_length = min_word_length
162
+ self.min_confidence = min_confidence
163
+
164
+ # ── Protected vocab (whitelist) ───────────────────────────────────────
165
+ # Words in this set are always returned unchanged by correct(), even
166
+ # when they are not in self.vocab. This lets normalize.py silently
167
+ # shield all slang-output values (e.g. "mahal", "terima kasih") from
168
+ # being mis-corrected back to a short user vocab like {"nasi", "goreng"}.
169
+ # Managed internally — not exposed as a public vocabulary to the caller.
170
+ self._protected: set[str] = set()
171
+
172
+ # ── Lookup cache ─────────────────────────────────────────────────────
173
+ # Maps lowercased token → corrected word.
174
+ # In real-world corpora the same misspelling often appears hundreds of
175
+ # times (e.g. "gk", "bgt", "udh"). Caching avoids recomputing the
176
+ # full vocab scan on every occurrence.
177
+ # The cache is invalidated whenever the vocabulary changes.
178
+ self._cache: dict[str, str] = {}
179
+
180
+ # ── Cache statistics ─────────────────────────────────────────────────
181
+ # Useful for profiling and tuning cache effectiveness.
182
+ self._cache_hits: int = 0
183
+ self._cache_misses: int = 0
184
+
185
+ # ─── Internal ────────────────────────────────────────────────────────────
186
+
187
+ def _invalidate_cache(self) -> None:
188
+ """
189
+ Clear the lookup cache.
190
+
191
+ Must be called whenever self.vocab is mutated so that stale
192
+ corrections are not returned for newly added words.
193
+ """
194
+ self._cache.clear()
195
+ self._cache_hits = 0
196
+ self._cache_misses = 0
197
+
198
+ # ─── Protected vocab management ──────────────────────────────────────────
199
+
200
+ def set_protected(self, words: set[str]) -> None:
201
+ """
202
+ Replace the internal whitelist with the given word set.
203
+
204
+ Words in the protected set are always returned as-is by correct(),
205
+ bypassing both the Levenshtein scan and the confidence filter.
206
+ This is used by normalize.py to shield slang-output values from
207
+ accidental re-correction.
208
+
209
+ Args:
210
+ words: Set of lowercase words to protect (multi-word values are
211
+ split on whitespace so each token is protected individually).
212
+
213
+ Example:
214
+ >>> corrector.set_protected({"mahal", "terima kasih"})
215
+ """
216
+ self._protected = {
217
+ token for phrase in words for token in phrase.lower().split()
218
+ }
219
+ self._invalidate_cache()
220
+
221
+ # ─── Vocabulary management ───────────────────────────────────────────────
222
+
223
+ def add_to_vocab(self, words: set[str]) -> None:
224
+ """
225
+ Add words to the vocabulary and invalidate the lookup cache.
226
+
227
+ Args:
228
+ words: Set of words to add (stored as lowercase).
229
+
230
+ Example:
231
+ >>> corrector.add_to_vocab({"lightgbm", "catboost", "xgboost"})
232
+ """
233
+ self.vocab.update(w.lower() for w in words)
234
+ self._invalidate_cache()
235
+
236
+ def remove_from_vocab(self, words: set[str]) -> None:
237
+ """
238
+ Remove words from the vocabulary and invalidate the lookup cache.
239
+
240
+ Args:
241
+ words: Set of words to remove.
242
+
243
+ Example:
244
+ >>> corrector.remove_from_vocab({"kredivo"})
245
+ """
246
+ self.vocab -= {w.lower() for w in words}
247
+ self._invalidate_cache()
248
+
249
+ def clear_vocab(self) -> None:
250
+ """
251
+ Empty the vocabulary entirely and invalidate the cache.
252
+
253
+ Example:
254
+ >>> corrector.clear_vocab()
255
+ >>> len(corrector)
256
+ 0
257
+ """
258
+ self.vocab.clear()
259
+ self._invalidate_cache()
260
+
261
+ # ─── Correction ──────────────────────────────────────────────────────────
262
+
263
+ def correct(self, word: str, max_dist: int = 2) -> str:
264
+ """
265
+ Correct a single token against the vocabulary.
266
+
267
+ Decision logic:
268
+ 1. If vocab is empty → return original (nothing to compare).
269
+ 2. If token is already in vocab → return original (correct).
270
+ 3. If len(token) < min_word_length → return original (too risky).
271
+ 4. Check cache → return cached result if available.
272
+ 5. Linear scan over vocab with length-delta pruning.
273
+ 6. Apply confidence filter → return original if below threshold.
274
+
275
+ Args:
276
+ word: Input token (any case).
277
+ max_dist: Maximum Levenshtein distance to consider a correction.
278
+ Higher values are more permissive but riskier.
279
+ Default: 2.
280
+
281
+ Returns:
282
+ Corrected word (lowercase) if a confident match is found,
283
+ otherwise the original word unchanged.
284
+
285
+ Examples:
286
+ >>> corrector.correct("mkan") # dist 1 from "makan"
287
+ 'makan'
288
+ >>> corrector.correct("ok") # below min_word_length
289
+ 'ok'
290
+ >>> corrector.correct("xyz123") # no close match in vocab
291
+ 'xyz123'
292
+ """
293
+ word_lower = word.lower()
294
+
295
+ # Guard 1: No vocab loaded — nothing to do
296
+ if not self.vocab:
297
+ return word
298
+
299
+ # Guard 2: Word is protected (slang output whitelist) — skip correction
300
+ # This prevents words like "mahal" or "kasih" (produced by slang
301
+ # normalization) from being mis-corrected to a short user vocab word
302
+ # (e.g. "nasi" or "goreng") that happens to be close in edit distance.
303
+ if word_lower in self._protected:
304
+ return word
305
+
306
+ # Guard 3: Word already in vocab — already correct
307
+ if word_lower in self.vocab:
308
+ return word
309
+
310
+ # Guard 4: Word too short — correction is too risky
311
+ # e.g. "di", "ya", "ok" have many neighbours at distance 1–2
312
+ if len(word_lower) < self.min_word_length:
313
+ return word
314
+
315
+ # Guard 5: Return cached result if available
316
+ if word_lower in self._cache:
317
+ self._cache_hits += 1
318
+ return self._cache[word_lower]
319
+
320
+ self._cache_misses += 1
321
+
322
+ # ── Linear scan with length-delta pruning ────────────────────────────
323
+ # Skipping candidates whose length differs by more than max_dist
324
+ # avoids computing Levenshtein for obviously too-different words,
325
+ # providing a significant speedup for large vocabularies.
326
+ best_match = word
327
+ min_dist = float("inf")
328
+
329
+ for candidate in self.vocab:
330
+ # Fast pre-filter: if lengths differ by more than max_dist,
331
+ # the edit distance is guaranteed to exceed max_dist — skip.
332
+ if abs(len(word_lower) - len(candidate)) > max_dist:
333
+ continue
334
+
335
+ dist = levenshtein_distance(word_lower, candidate)
336
+
337
+ if dist < min_dist and dist <= max_dist:
338
+ min_dist = dist
339
+ best_match = candidate
340
+
341
+ # ── Confidence filter ────────────────────────────────────────────────
342
+ # confidence = fraction of characters that are "correct".
343
+ # e.g. "mkan" (len 4) corrected to "makan" (dist 1):
344
+ # confidence = 1 - 1/4 = 0.75 → accepted if min_confidence ≤ 0.75
345
+ if best_match != word:
346
+ confidence = 1.0 - (min_dist / max(len(word_lower), 1))
347
+ if confidence < self.min_confidence:
348
+ best_match = word # Not confident enough — keep original
349
+
350
+ # Store result in cache before returning
351
+ self._cache[word_lower] = best_match
352
+ return best_match
353
+
354
+ def correct_text(self, text: str, max_dist: int = 2) -> str:
355
+ """
356
+ Correct all tokens in a text string.
357
+
358
+ Splits on whitespace, corrects each token independently, then
359
+ rejoins with a single space. Punctuation attached to words
360
+ (e.g. "mkan,") is treated as part of the token and may not
361
+ match the vocab — consider stripping punctuation upstream.
362
+
363
+ Args:
364
+ text: Input text string.
365
+ max_dist: Passed through to correct().
366
+
367
+ Returns:
368
+ Text with out-of-vocab tokens corrected where possible.
369
+
370
+ Example:
371
+ >>> corrector.correct_text("saya mkan dan mnder sore ini")
372
+ 'saya makan dan mander sore ini'
373
+ """
374
+ if not text:
375
+ return text
376
+
377
+ corrected = [self.correct(w, max_dist) for w in text.split()]
378
+ return " ".join(corrected)
379
+
380
+ def suggest(self, word: str, max_dist: int = 2, top_k: int = 3) -> list[str]:
381
+ """
382
+ Suggest closest vocabulary matches for an unknown word.
383
+
384
+ Unlike correct(), this returns a list of possibilities without
385
+ forcing a replacement, making it safe for exploratory pipelines.
386
+
387
+ Args:
388
+ word: Input token (any case).
389
+ max_dist: Maximum Levenshtein distance to consider.
390
+ top_k: Maximum number of suggestions to return.
391
+
392
+ Returns:
393
+ List of candidate strings, sorted by closest edit distance.
394
+ """
395
+ word_lower = word.lower()
396
+
397
+ if not self.vocab:
398
+ return []
399
+
400
+ if word_lower in self.vocab:
401
+ return [word_lower]
402
+
403
+ candidates = []
404
+ for candidate in self.vocab:
405
+ if abs(len(word_lower) - len(candidate)) > max_dist:
406
+ continue
407
+
408
+ dist = levenshtein_distance(word_lower, candidate)
409
+ if dist <= max_dist:
410
+ candidates.append((dist, candidate))
411
+
412
+ candidates.sort(key=lambda x: (x[0], x[1]))
413
+ return [c[1] for c in candidates[:top_k]]
414
+
415
+ # ─── Diagnostics ─────────────────────────────────────────────────────────
416
+
417
+ def cache_info(self) -> dict[str, int]:
418
+ """
419
+ Return cache hit/miss statistics.
420
+
421
+ Useful for profiling: a high hit rate indicates the cache is
422
+ working well; a low rate may suggest highly varied input.
423
+
424
+ Returns:
425
+ Dict with keys ``hits``, ``misses``, and ``size``.
426
+
427
+ Example:
428
+ >>> corrector.cache_info()
429
+ {'hits': 120, 'misses': 35, 'size': 35}
430
+ """
431
+ return {
432
+ "hits": self._cache_hits,
433
+ "misses": self._cache_misses,
434
+ "size": len(self._cache),
435
+ }
436
+
437
+ # ─── Dunder helpers ──────────────────────────────────────────────────────
438
+
439
+ def __len__(self) -> int:
440
+ """Return the number of words in the vocabulary."""
441
+ return len(self.vocab)
442
+
443
+ def __contains__(self, word: str) -> bool:
444
+ """Check if a word is in the vocabulary (case-insensitive)."""
445
+ return word.lower() in self.vocab
446
+
447
+ def __repr__(self) -> str:
448
+ return (
449
+ f"TypoCorrector("
450
+ f"vocab_size={len(self.vocab)}, "
451
+ f"min_word_length={self.min_word_length}, "
452
+ f"min_confidence={self.min_confidence})"
453
+ )
454
+
455
+
456
+ # ─────────────────────────────────────────────────────────────────────────────
457
+ # MODULE-LEVEL CONVENIENCE SINGLETON
458
+ #
459
+ # Ships with an empty vocabulary — the caller must populate it.
460
+ #
461
+ # Quick start:
462
+ # from basa.core.typo import typo
463
+ # typo.add_to_vocab({"makan", "minum", "masak"})
464
+ # typo.correct_text("saya mkan dan mnder")
465
+ #
466
+ # For domain-specific use:
467
+ # from basa.core.typo import TypoCorrector
468
+ # corrector = TypoCorrector(vocab=my_word_set, min_word_length=4)
469
+ # ─────────────────────────────────────────────────────────────────────────────
470
+ typo = TypoCorrector()
File without changes
File without changes
File without changes
basa/dataset/split.py ADDED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
basa/tokenize/word.py ADDED
File without changes
File without changes
File without changes
File without changes
File without changes
basa/utils/__init__.py ADDED
File without changes
File without changes
basa/utils/regex.py ADDED
File without changes
File without changes