File size: 5,531 Bytes
379f378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""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]