File size: 12,819 Bytes
2664e66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
#!/usr/bin/env python3
"""End-to-end Hangul -> mixed-script conversion (CP6).

Pipeline per sentence
  1. Kiwi tokenises; noun-like tokens (NNG/NNP/NNB/NR/XR/XPN/XSN) define convertible regions.
  2. Inside each region every substring of 2..8 syllables that is a lexicon key becomes a
     candidate span (lattice); a region with no lexicon hit at all goes to the Composer fallback
     (character-level candidates ranked by a Hanja bigram LM).
  3. The cross-encoder scores every (span, candidate) pair; softmax over the span's candidates
     gives p(best).
  4. Dynamic programming picks a non-overlapping set of spans maximising
     sum(len(span) * (p_best - tau)), so short low-confidence spans are left in Hangul.

Usage:
  convert.py --ckpt results/run5/ckpt --lexicon processed/lexicon_v3_sto.tsv "문장" ["문장" ...]
  convert.py --ckpt ... --lexicon ... --stdin        (one sentence per line, JSON out)
Options: --tau 0.5 --max-cands 12 --compose (enable Composer fallback, needs --chars/--counts)
"""
import argparse
import collections
import json
import math
import os
import sys

import torch

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.candidates import CandidateGenerator, Composer, _std_initial  # noqa: E402
from src.train_scorer import Scorer, mark  # noqa: E402
from transformers import AutoTokenizer  # noqa: E402

NOUN_TAGS = {'NNG', 'NNP', 'NNB', 'NR', 'XR', 'XPN', 'XSN'}
AFFIX_TAGS = {'XSN', 'XPN', 'NNB'}          # single-syllable Sino affixes: 的 側 黨 長 性 化 ...
NUM = dict(zip('영일이삼사오육칠팔구십백천만억', '零一二三四五六七八九十百千萬億'))
NUM_UNIT = dict(zip('년월일시분초', '年月日時分秒'))
import re as _re
NUM_RE = _re.compile(r'(?<![가-힣])((?:[영일이삼사오육칠팔구십백천만억]){2,}(?:[년월일시분초])?|[영일이삼사오육칠팔구십백천만억][년월일시분초])(?![가-힣])')


class Converter:
    def __init__(self, ckpt, lexicon, device=None, max_cands=12, tau=0.5, composer=None, max_len=128,
                 hangul_counts=None, prior_floor=0.0, prior_gate=0.0, prior_mode='gate',
                 region='noun', affix=True, numerals=True, unknown_prior=0.0):
        self.device = torch.device(device or ('cuda' if torch.cuda.is_available() else 'cpu'))
        self.tok = AutoTokenizer.from_pretrained(ckpt)
        self.model = Scorer(ckpt)
        head = os.path.join(ckpt, 'head.pt')
        if os.path.exists(head):
            self.model.head.load_state_dict(torch.load(head, map_location='cpu'))
        self.model.to(self.device).eval()
        self.drop_tt = getattr(self.model.enc.config, 'type_vocab_size', 2) < 2
        self.gen = CandidateGenerator(lexicon, composer=composer)
        # candidate order = lexicon count order (CandidateGenerator keeps file order = count desc)
        self.max_cands, self.tau, self.max_len = max_cands, tau, max_len
        # conversion prior p_sino(form) = hanja_count / (hanja_count + hangul_count): how often the
        # form was written in Hanja vs Hangul in the mixed-script corpus (native words -> ~0)
        self.hanja_count = collections.Counter()
        with open(lexicon, encoding='utf-8') as fh:
            next(fh)
            for line in fh:
                cols = line.rstrip('\n').split('\t')
                if len(cols) > 3 and cols[3].isdigit():
                    self.hanja_count[_std_initial(cols[0])] += int(cols[3])
        self.hangul_count = {}
        if hangul_counts and os.path.exists(hangul_counts):
            with open(hangul_counts, encoding='utf-8') as fh:
                next(fh)
                for line in fh:
                    w, n = line.rstrip('\n').split('\t')[:2]
                    self.hangul_count[w] = int(n)
        self.prior_floor = prior_floor
        self.prior_gate, self.prior_mode = prior_gate, prior_mode
        self.region, self.affix, self.numerals = region, affix, numerals
        self.unknown_prior = unknown_prior

    def p_sino(self, form):
        if not self.hangul_count:
            return 1.0
        h = self.hanja_count.get(_std_initial(form), 0)
        g = self.hangul_count.get(form, 0)
        if h == 0 and g == 0:
            return self.unknown_prior   # form unknown to the historical corpus (modern terms, OCR fragments)
        return max(self.prior_floor, h / (h + g + 1.0))

    # ---- candidate spans -------------------------------------------------------------
    def spans(self, sentence):
        out = []
        toks = self.gen.kiwi.tokenize(sentence)
        if self.region in ('all', 'token'):
            regions = [(m.start(), m.end()) for m in _re.finditer(r'[가-힣]+', sentence)]
        else:
            regions = [(t.start, t.start + t.len) for t in toks if t.tag in NOUN_TAGS]
        # token mode: a span must start at a Kiwi token start and end at a token end (any tag),
        # which blocks matches that straddle word boundaries in unspaced old text (파견책 -> 견책)
        starts = {t.start for t in toks}; ends = {t.start + t.len for t in toks}
        # every token a span covers must be noun-like: blocks 건설과 -> 建設課 (과 is a particle) and
        # 위해 -> 危害 (verb stem + ending) while still allowing multi-token compounds (자연+과학)
        bad_pos = set()
        for t in toks:
            if t.tag not in NOUN_TAGS and t.tag not in ('SN', 'SL', 'SH'):
                bad_pos.update(range(t.start, t.start + t.len))
        if self.region == 'token':
            aligned = lambda a, b: a in starts and b in ends and not any(i in bad_pos for i in range(a, b))
        else:
            aligned = lambda a, b: True
        for s, e in regions:
            region = sentence[s:e]
            hit = False
            for a in range(len(region)):
                for L in range(2, min(8, len(region) - a) + 1):
                    if not aligned(s + a, s + a + L):
                        continue
                    w = region[a:a + L]
                    cands = self.gen.table.get(_std_initial(w))
                    if cands:
                        hit = True
                        out.append({'start': s + a, 'end': s + a + L, 'surface': w, 'cands': cands[: self.max_cands], 'src': 'lex'})
            if not hit and self.gen.composer and 2 <= len(region) <= 6:
                comp = self.gen.composer.compose(region, k=self.max_cands)
                if comp:
                    out.append({'start': s, 'end': e, 'surface': region, 'cands': comp, 'src': 'compose'})
        if self.affix:   # single-syllable bound Sino morphemes tagged by Kiwi, looked up as 1-char keys
            for t in toks:
                if t.tag in AFFIX_TAGS and t.len == 1:
                    cands = self.gen.table.get(t.form)
                    if cands:
                        out.append({'start': t.start, 'end': t.start + 1, 'surface': t.form, 'cands': cands[: self.max_cands], 'src': 'affix'})
        if self.numerals:   # rule: Sino-Korean numeral strings (+ time unit) -> Hanja, fixed p=1
            for m in NUM_RE.finditer(sentence):
                w = m.group(1)
                hj = ''.join(NUM.get(ch) or NUM_UNIT.get(ch) or ch for ch in w)
                out.append({'start': m.start(1), 'end': m.end(1), 'surface': w, 'cands': [hj], 'src': 'numeral'})
        return out

    # ---- scoring -----------------------------------------------------------------------
    @torch.no_grad()
    def score(self, sentence, spans, bs=96):
        texts, cands, owner = [], [], []
        for i, sp in enumerate(spans):
            ctx = mark(sentence, sp['start'], sp['end'])
            for c in sp['cands']:
                texts.append(ctx); cands.append(c); owner.append(i)
        scores = []
        for b in range(0, len(texts), bs):
            enc = self.tok(texts[b:b + bs], cands[b:b + bs], truncation='only_first', max_length=self.max_len,
                           padding=True, return_tensors='pt')
            if self.drop_tt:
                enc.pop('token_type_ids', None)
            enc = {k: v.to(self.device) for k, v in enc.items()}
            with torch.autocast(device_type='cuda', dtype=torch.float16, enabled=self.device.type == 'cuda'):
                scores.extend(self.model(**enc).float().cpu().tolist())
        per = collections.defaultdict(list)
        for i, s in zip(owner, scores):
            per[i].append(s)
        for i, sp in enumerate(spans):
            ss = per[i]
            m = max(ss); ex = [math.exp(x - m) for x in ss]; z = sum(ex)
            probs = [x / z for x in ex]
            j = max(range(len(ss)), key=lambda k: probs[k])
            sp['hanja'], sp['p'] = sp['cands'][j], probs[j]
            sp['ranked'] = sorted(zip(sp['cands'], probs), key=lambda kv: -kv[1])[:5]
            sp['p_sino'] = round(self.p_sino(sp['surface']), 3)
        return spans

    # ---- overlap resolution --------------------------------------------------------------
    def select(self, sentence, spans):
        n = len(sentence)
        best = [0.0] * (n + 1); choice = [None] * (n + 1)
        by_end = collections.defaultdict(list)
        for sp in spans:
            by_end[sp['end']].append(sp)
        for e in range(1, n + 1):
            best[e], choice[e] = best[e - 1], None
            for sp in by_end[e]:
                ps = 1.0 if sp['src'] == 'numeral' else self.p_sino(sp['surface'])
                if ps < self.prior_gate:
                    continue                      # native/rarely-Hanja form: leave in Hangul
                conf = sp['p'] * ps if self.prior_mode == 'product' else sp['p']
                gain = (sp['end'] - sp['start']) * (conf - self.tau)
                if gain > 0 and best[sp['start']] + gain > best[e]:
                    best[e], choice[e] = best[sp['start']] + gain, sp
        chosen, e = [], n
        while e > 0:
            if choice[e] is None:
                e -= 1
            else:
                chosen.append(choice[e]); e = choice[e]['start']
        return sorted(chosen, key=lambda s: s['start'])

    def convert(self, sentence):
        spans = self.spans(sentence)
        if not spans:
            return {'input': sentence, 'output': sentence, 'spans': []}
        spans = self.score(sentence, spans)
        chosen = self.select(sentence, spans)
        out, pos = [], 0
        for sp in chosen:
            out.append(sentence[pos:sp['start']]); out.append(sp['hanja']); pos = sp['end']
        out.append(sentence[pos:])
        return {'input': sentence, 'output': ''.join(out),
                'spans': [{k: sp[k] for k in ('start', 'end', 'surface', 'hanja', 'p', 'p_sino', 'src', 'ranked')} for sp in chosen]}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--ckpt', required=True); ap.add_argument('--lexicon', required=True)
    ap.add_argument('--tau', type=float, default=0.7); ap.add_argument('--max-cands', type=int, default=12)
    ap.add_argument('--compose', action='store_true'); ap.add_argument('--chars', nargs='*', default=[])
    ap.add_argument('--counts'); ap.add_argument('--stdin', action='store_true'); ap.add_argument('--device')
    ap.add_argument('--hangul-counts', help='okhc_hangul_counts.tsv for the conversion prior')
    ap.add_argument('--prior-gate', type=float, default=0.3); ap.add_argument('--prior-mode', default='gate', choices=['gate', 'product'])
    ap.add_argument('--region', default='token', choices=['noun', 'all', 'token']); ap.add_argument('--no-affix', action='store_true'); ap.add_argument('--no-numerals', action='store_true')
    ap.add_argument('sentences', nargs='*')
    ap.add_argument('--preset', choices=['historical', 'modern'], help='historical: gate 0.3/tau 0.7 (1920-60s newspapers); modern: gate 0.05/tau 0.5')
    a = ap.parse_args()
    if a.preset == 'modern':
        a.prior_gate, a.tau = 0.05, 0.5
    elif a.preset == 'historical':
        a.prior_gate, a.tau = 0.3, 0.7
    composer = Composer(a.chars, a.counts) if a.compose and a.chars else None
    cv = Converter(a.ckpt, a.lexicon, device=a.device, max_cands=a.max_cands, tau=a.tau, composer=composer,
                   hangul_counts=a.hangul_counts, prior_gate=a.prior_gate, prior_mode=a.prior_mode,
                   region=a.region, affix=not a.no_affix, numerals=not a.no_numerals)
    sents = [l.rstrip('\n') for l in sys.stdin] if a.stdin else a.sentences
    for s in sents:
        r = cv.convert(s)
        if a.stdin:
            print(json.dumps(r, ensure_ascii=False))
        else:
            print(r['output'])
            for sp in r['spans']:
                print(f"   {sp['surface']}→{sp['hanja']} p={sp['p']:.2f} p_sino={sp['p_sino']} {sp['src']} {[(h, round(p, 2)) for h, p in sp['ranked'][:3]]}")


if __name__ == '__main__':
    main()