150 lines
5.4 KiB
Python
150 lines
5.4 KiB
Python
|
|
"""MDL-guided morpheme segmentation for tiny vocabularies, pure python.
|
||
|
|
|
||
|
|
Greedy Morfessor-flavored search: start with whole words as morphs, repeatedly
|
||
|
|
apply the single split that most reduces description length
|
||
|
|
L(lexicon) + L(corpus | lexicon). Vocabularies here are tiny (10-100 word
|
||
|
|
types), so an O(V * maxlen) sweep per iteration is instant.
|
||
|
|
|
||
|
|
Alignment conditioning: tokens known (from align.py) to share a gloss get a
|
||
|
|
bonus for splits that expose their shared substring — this is the
|
||
|
|
"segmentation conditioned on alignment" step from the plan, and is what keeps
|
||
|
|
MDL from over-segmenting on 20-word corpora.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import math
|
||
|
|
from collections import Counter
|
||
|
|
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||
|
|
|
||
|
|
_MIN_MORPH = 1
|
||
|
|
|
||
|
|
|
||
|
|
def _lex_cost(morphs: Iterable[str]) -> float:
|
||
|
|
# ~1 char = a few bits; +1 per morph for the boundary/index overhead
|
||
|
|
return sum(len(m) + 1 for m in set(morphs)) * 4.0
|
||
|
|
|
||
|
|
|
||
|
|
def _corpus_cost(usage: Counter) -> float:
|
||
|
|
total = sum(usage.values())
|
||
|
|
if total == 0:
|
||
|
|
return 0.0
|
||
|
|
return -sum(c * math.log2(c / total) for c in usage.values())
|
||
|
|
|
||
|
|
|
||
|
|
class Segmenter:
|
||
|
|
def __init__(self, share_bonus: float = 8.0):
|
||
|
|
self.share_bonus = share_bonus
|
||
|
|
self.seg: Dict[str, List[str]] = {}
|
||
|
|
|
||
|
|
def fit(
|
||
|
|
self,
|
||
|
|
words: Sequence[str],
|
||
|
|
counts: Optional[Counter] = None,
|
||
|
|
share_groups: Optional[List[Set[str]]] = None,
|
||
|
|
max_iters: int = 200,
|
||
|
|
) -> "Segmenter":
|
||
|
|
"""words: vocabulary (task-language word types).
|
||
|
|
counts: token frequencies (defaults to 1 each).
|
||
|
|
share_groups: sets of words believed to share a morpheme (same gloss
|
||
|
|
alignment); splits exposing a shared prefix/suffix get a bonus."""
|
||
|
|
counts = counts or Counter({w: 1 for w in words})
|
||
|
|
self.seg = {w: [w] for w in dict.fromkeys(words) if w}
|
||
|
|
shared_subs = self._shared_substrings(share_groups or [])
|
||
|
|
|
||
|
|
for _ in range(max_iters):
|
||
|
|
best = self._best_split(counts, shared_subs)
|
||
|
|
if best is None:
|
||
|
|
break
|
||
|
|
word, mi, cut = best
|
||
|
|
m = self.seg[word][mi]
|
||
|
|
self.seg[word][mi : mi + 1] = [m[:cut], m[cut:]]
|
||
|
|
return self
|
||
|
|
|
||
|
|
def _shared_substrings(self, groups: List[Set[str]]) -> Set[str]:
|
||
|
|
subs: Set[str] = set()
|
||
|
|
for g in groups:
|
||
|
|
g = [w for w in g if w]
|
||
|
|
if len(g) < 2:
|
||
|
|
continue
|
||
|
|
# longest common prefix and suffix over the group
|
||
|
|
pre = g[0]
|
||
|
|
suf = g[0]
|
||
|
|
for w in g[1:]:
|
||
|
|
while pre and not w.startswith(pre):
|
||
|
|
pre = pre[:-1]
|
||
|
|
while suf and not w.endswith(suf):
|
||
|
|
suf = suf[1:]
|
||
|
|
if len(pre) >= 2:
|
||
|
|
subs.add(pre)
|
||
|
|
if len(suf) >= 2:
|
||
|
|
subs.add(suf)
|
||
|
|
return subs
|
||
|
|
|
||
|
|
def _cost(self, counts: Counter, shared_subs: Set[str]) -> float:
|
||
|
|
usage: Counter = Counter()
|
||
|
|
for w, morphs in self.seg.items():
|
||
|
|
for m in morphs:
|
||
|
|
usage[m] += counts[w]
|
||
|
|
cost = _lex_cost(usage.keys()) + _corpus_cost(usage)
|
||
|
|
cost -= self.share_bonus * sum(1 for m in usage if m in shared_subs)
|
||
|
|
return cost
|
||
|
|
|
||
|
|
def _best_split(self, counts: Counter, shared_subs: Set[str]):
|
||
|
|
base = self._cost(counts, shared_subs)
|
||
|
|
best_gain, best = 1e-6, None
|
||
|
|
for w, morphs in self.seg.items():
|
||
|
|
for mi, m in enumerate(morphs):
|
||
|
|
if len(m) < 2 * _MIN_MORPH:
|
||
|
|
continue
|
||
|
|
for cut in range(_MIN_MORPH, len(m) - _MIN_MORPH + 1):
|
||
|
|
morphs[mi : mi + 1] = [m[:cut], m[cut:]]
|
||
|
|
gain = base - self._cost(counts, shared_subs)
|
||
|
|
morphs[mi : mi + 2] = [m]
|
||
|
|
if gain > best_gain:
|
||
|
|
best_gain, best = gain, (w, mi, cut)
|
||
|
|
return best
|
||
|
|
|
||
|
|
def segment(self, word: str) -> List[str]:
|
||
|
|
"""Segment a word; unseen words are matched greedily against the
|
||
|
|
learned morph inventory (longest-match, both ends first)."""
|
||
|
|
if word in self.seg:
|
||
|
|
return list(self.seg[word])
|
||
|
|
morphs = {m for parts in self.seg.values() for m in parts}
|
||
|
|
return _greedy_decompose(word, morphs)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def morphs(self) -> Set[str]:
|
||
|
|
return {m for parts in self.seg.values() for m in parts}
|
||
|
|
|
||
|
|
|
||
|
|
def _greedy_decompose(word: str, morphs: Set[str]) -> List[str]:
|
||
|
|
"""Best-effort decomposition of an unseen word over a morph set: dynamic
|
||
|
|
programming for fewest chunks, unknown spans kept as single chunks."""
|
||
|
|
n = len(word)
|
||
|
|
INF = float("inf")
|
||
|
|
# cost[i] = (num chunks, num unknown chars) to segment word[:i]
|
||
|
|
cost = [(INF, INF)] * (n + 1)
|
||
|
|
back: List[Optional[Tuple[int, str]]] = [None] * (n + 1)
|
||
|
|
cost[0] = (0, 0)
|
||
|
|
for i in range(n):
|
||
|
|
if cost[i][0] == INF:
|
||
|
|
continue
|
||
|
|
for j in range(i + 1, n + 1):
|
||
|
|
piece = word[i:j]
|
||
|
|
known = piece in morphs
|
||
|
|
c = (cost[i][0] + 1, cost[i][1] + (0 if known else len(piece)))
|
||
|
|
# prefer fewer unknown chars, then fewer chunks
|
||
|
|
key = (c[1], c[0])
|
||
|
|
if key < (cost[j][1], cost[j][0]):
|
||
|
|
cost[j] = c
|
||
|
|
back[j] = (i, piece)
|
||
|
|
out: List[str] = []
|
||
|
|
i = n
|
||
|
|
while i > 0 and back[i]:
|
||
|
|
prev, piece = back[i]
|
||
|
|
out.append(piece)
|
||
|
|
i = prev
|
||
|
|
out.reverse()
|
||
|
|
return out or [word]
|