Skip to content

Commit e8704f0

Browse files
authored
Merge pull request #868 from lukhnos/postprocess
postprocess: 允許在詞庫後製過程中,手動調整常見問題詞的分數
2 parents c06024d + e4f0c4a commit e8704f0

11 files changed

Lines changed: 2238 additions & 4 deletions

File tree

.github/workflows/continuous-build-data.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ jobs:
2121
- uses: actions/setup-python@v6
2222
with:
2323
python-version: '3.12'
24+
- name: Unit test the curation library
25+
run: python3 -m unittest
26+
working-directory: Source/Data
2427
- name: Test data files
2528
run: make check
2629
working-directory: Source/Data

Source/Data/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ associated-phrases-v2.txt
33
cand.occ
44
data-plain-bpmf.txt
55
data.txt
6+
data-raw.txt

Source/Data/Makefile

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ all: data.txt data-plain-bpmf.txt associated-phrases-v2.txt
66

77
install: all
88

9-
data-plain-bpmf.txt: curation/compilers/plain_bpmf_compiler.py BPMFBase.txt BPMFPunctuations.txt
9+
data-plain-bpmf.txt: curation/compilers/plain_bpmf_compiler.py \
10+
curation/compilers/compiler_utils.py \
11+
BPMFBase.txt BPMFPunctuations.txt
1012
$(PYTHON) -m curation.compilers.plain_bpmf_compiler \
1113
--bpmf_base BPMFBase.txt \
1214
--punctuations BPMFPunctuations.txt \
1315
--output data-plain-bpmf.txt
1416

15-
data.txt: curation/compilers/main_compiler.py BPMFBase.txt BPMFMappings.txt BPMFPunctuations.txt \
16-
PhraseFreq.txt phrase.occ Symbols.txt Macros.txt\
17-
heterophony1.list heterophony2.list heterophony3.list
17+
data-raw.txt: curation/compilers/main_compiler.py \
18+
curation/compilers/compiler_utils.py \
19+
BPMFBase.txt BPMFMappings.txt BPMFPunctuations.txt \
20+
PhraseFreq.txt phrase.occ Symbols.txt Macros.txt \
21+
heterophony1.list heterophony2.list heterophony3.list
1822
$(PYTHON) -m curation.compilers.main_compiler \
1923
--heterophony1 heterophony1.list \
2024
--heterophony2 heterophony2.list \
@@ -25,6 +29,12 @@ data.txt: curation/compilers/main_compiler.py BPMFBase.txt BPMFMappings.txt BPMF
2529
--punctuations BPMFPunctuations.txt \
2630
--symbols Symbols.txt \
2731
--macros Macros.txt \
32+
--output data-raw.txt
33+
34+
data.txt: curation/compilers/postprocess.py curation/mandarin/grid.py data-raw.txt Postprocess.txt
35+
$(PYTHON) -m curation.compilers.postprocess \
36+
--input data-raw.txt \
37+
--directive Postprocess.txt \
2838
--output data.txt
2939

3040
associated-phrases-v2.txt: data.txt curation/builders/phrase_deriver.py associated-punctuation.txt
@@ -101,3 +111,13 @@ _mycodecheck:
101111
@for myfile in curation/*/*.py scripts/*.py; do \
102112
pycodestyle --ignore=E501,E722,E701,E221,W605 $$myfile; \
103113
done
114+
115+
format:
116+
black \
117+
curation/mandarin/*.py \
118+
curation/compilers/postprocess.py \
119+
tests/*.py
120+
121+
typecheck:
122+
mypy \
123+
curation/compilers/postprocess.py

Source/Data/Postprocess.txt

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# 詞庫後製設定檔
2+
3+
# epsilon 用來設定 override 對數分數的 ε 值。web 版精確度只有小數點後三位,因
4+
# 此建議 epsilon 設到這個數字
5+
6+
epsilon 0.001
7+
8+
9+
# before 用來確認後製修改之前的詞庫狀態
10+
11+
before ㄓㄜˋ-ㄧㄤˋ-ㄇㄟˊ-ㄕˋ-ㄐㄧㄡˋ-ㄏㄠˇ 這樣-沒-是-就好
12+
13+
14+
# promote-over-single-syllables 的意思是,給定多字詞,這個多字詞的分數,應該
15+
# 高於同數量同音單字的加總。例如打「沒事」但出現「沒是」,是因為在 unigram
16+
# model 下,P_log(沒) + P_log(是) > P_log(沒事)
17+
18+
promote-over-single-syllables 沒事 ㄇㄟˊ-ㄕˋ
19+
20+
21+
# assert 用於確認修改之後的詞庫狀態
22+
23+
assert ㄇㄟˊ-ㄕˋ 沒事
24+
assert ㄓㄜˋ-ㄧㄤˋ-ㄇㄟˊ-ㄕˋ-ㄐㄧㄡˋ-ㄏㄠˇ 這樣-沒事-就好
25+
26+
promote-over-single-syllables 好險 ㄏㄠˇ-ㄒㄧㄢˇ
27+
assert ㄏㄠˇ-ㄒㄧㄢˇ-ㄇㄟˊ-ㄕˋ-ㄦˊ-ㄑㄧㄝˇ-ㄏㄠˇ-ㄒㄧㄢˇ-ㄒㄧㄢˇ-ㄕˋ-ㄑㄧˋ-ㄇㄟˊ-ㄏㄨㄞˋ 好險-沒事-而且-好險-顯示器-沒-壞
28+
29+
30+
# promote-over-peers 的意思是,如果有許多同字數的同音詞,把指定的詞給列為最高
31+
# 順位。例如首字理論上可念成一聲(但實際則否)的「一顆」「一棵」「一科」這三
32+
# 個詞跟「醫科」有衝突,在語用上「ㄧ ㄎㄜ」應該出「醫科」為首。
33+
34+
promote-over-peers 醫科 ㄧ-ㄎㄜ
35+
36+
# 這個修改讓「一顆」的「一」只能用四聲讀,否則結果永遠出現「醫科」為先
37+
assert ㄧˋ-ㄎㄜ-ㄊㄤˊ-ㄍㄨㄛˇ 一顆-糖果
38+
assert ㄧ-ㄎㄜ-ㄉㄚˋ-ㄒㄩㄝˊ 醫科-大學
39+
assert ㄉㄨˊ-ㄧ-ㄎㄜ 讀-醫科
40+
assert ㄧ-ㄎㄜ-ㄕㄥ 醫科-生
41+
42+
# ㄎㄜ 與右鄰成詞(科目)時,「一」保留,promote 不覆蓋
43+
assert ㄧ-ㄎㄜ-ㄇㄨˋ 一-科目
44+
45+
# ㄧ 與左鄰成詞(第一、這一)時,「一」亦保留
46+
assert ㄉㄧˋ-ㄧ-ㄎㄜ 第一-科
47+
assert ㄓㄜˋ-ㄧ-ㄎㄜ 這一-科
48+
49+
# 副作用:量詞「一顆」「一棵」左右皆無法成詞時,誤判為「醫科」;
50+
# 日後若能依上下文正確判斷量詞,此二 assert 將失敗以示提醒。
51+
assert ㄔ-ㄧ-ㄎㄜ 吃-醫科
52+
assert ㄧ-ㄎㄜ-ㄘㄞˋ 醫科-菜
53+
54+
# 若念為四聲則不衝突,但此處「ㄧˋ ㄎㄜ」因為組詞模型欠缺判別下文的能力,
55+
# 首選仍為「一顆」
56+
assert ㄔ-ㄧˋ-ㄎㄜ 吃-一顆
57+
assert ㄧˋ-ㄎㄜ-ㄘㄞˋ 一顆-菜
58+
59+
60+
# 「裡裡外外」已經成詞,所以不需要再 promote 「裡裡」
61+
# promote-over-peers 裡裡 ㄌㄧˇ-ㄌㄧˇ
62+
# assert ㄌㄧˇ-ㄌㄧˇ-ㄨㄞˋ-ㄨㄞˋ 裡裡外外
63+
64+
promote-over-single-syllables 依舊 ㄧ-ㄐㄧㄡˋ
65+
assert ㄓㄜˋ-ㄨㄣˋ-ㄊㄧˊ-ㄧ-ㄐㄧㄡˋ-ㄘㄨㄣˊ-ㄗㄞˋ 這-問題-依舊-存在
66+
67+
# 這個問題目前還不能解決
68+
#
69+
# promote-over-single-syllables 依舊在 ㄧ-ㄐㄧㄡˋ-ㄗㄞˋ
70+
#
71+
# 提升後,斷詞仍然是「青山-一-就在」
72+
# assert ㄑㄧㄥ-ㄕㄢ-ㄧ-ㄐㄧㄡˋ-ㄗㄞˋ 青山-依舊在
73+
74+
promote-over-single-syllables 中醫 ㄓㄨㄥ-ㄧ
75+
promote-over-single-syllables 西醫 ㄒㄧ-ㄧ
76+
assert ㄓㄨㄥ-ㄧ-ㄩˇ-ㄒㄧ-ㄧ 中醫-與-西醫
77+
78+
# 以下兩例用來確保修改後,不會影響其他同音但詞界不同詞
79+
# 台中一中、中一中為完整長詞
80+
assert ㄓㄨㄥ-ㄧ-ㄓㄨㄥ 中一中
81+
assert ㄊㄞˊ-ㄓㄨㄥ-ㄧ-ㄓㄨㄥ 台中一中
82+
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import argparse
2+
from .compiler_utils import HEADER
3+
from ..mandarin.grid import most_plausible_walk
4+
import sys
5+
import unittest
6+
7+
errors: list[tuple[int, str]] = []
8+
warnings: list[tuple[int, str]] = []
9+
epsilon = 0.0001
10+
11+
12+
def set_epsilon(e):
13+
global epsilon
14+
epsilon = e
15+
16+
17+
def accrue_error(lineno, err):
18+
errors.append((lineno, err))
19+
20+
21+
def accrue_warning(lineno, warning):
22+
warnings.append((lineno, warning))
23+
24+
25+
def show_errors_and_warnings():
26+
combined = [(lineno, f"error (line {lineno}): {msg}\n") for lineno, msg in errors]
27+
combined += [
28+
(lineno, f"warning (line {lineno}): {msg}\n") for lineno, msg in warnings
29+
]
30+
31+
combined = sorted(combined, key=lambda x: x[0])
32+
33+
for _, msg in combined:
34+
sys.stderr.write(msg)
35+
36+
37+
def segmented_values(nodes):
38+
return "-".join(n.value for n in nodes)
39+
40+
41+
def find_top_unigram_in_lm(lm, reading):
42+
if reading not in lm:
43+
return None
44+
45+
unigrams = lm[reading]
46+
v, s = unigrams[0]
47+
return (v, float(s))
48+
49+
50+
def find_score_in_lm(lm, reading, value):
51+
if reading not in lm:
52+
return None
53+
54+
unigrams = lm[reading]
55+
for unigram in unigrams:
56+
if unigram[0] == value:
57+
return float(unigram[1])
58+
59+
return None
60+
61+
62+
def replace_score_in_lm(lm, reading, value, new_score):
63+
if reading not in lm:
64+
raise ValueError(f"reading {reading} not in language model!")
65+
66+
unigrams = lm[reading]
67+
68+
has_replacement = False
69+
new_unigrams = []
70+
for unigram in unigrams:
71+
uv, us = unigram
72+
if uv == value:
73+
if type(us) == str:
74+
new_unigrams.append((uv, str(new_score)))
75+
else:
76+
new_unigrams.append((uv, new_score))
77+
has_replacement = True
78+
else:
79+
new_unigrams.append((uv, us))
80+
81+
if not has_replacement:
82+
raise ValueError("reading:value %s:%s not in LM" % (reading, value))
83+
84+
unigrams = sorted(new_unigrams, key=lambda x: float(x[1]), reverse=True)
85+
lm[reading] = unigrams
86+
return True
87+
88+
89+
def promote_over_single_syllables(lineno, lm, value, reading):
90+
91+
readings = reading.split("-")
92+
93+
if len(value) != len(readings):
94+
return accrue_error(lineno, "number of codepoints don't match readings")
95+
96+
our_score = find_score_in_lm(lm, reading, value)
97+
if not our_score:
98+
return accrue_error(lineno, f"reading:value {reading}:{value} not in LM")
99+
100+
# validate data
101+
unigrams = [find_top_unigram_in_lm(lm, r) for r in readings]
102+
if not all(unigrams):
103+
return accrue_error(lineno, "cannot find all single-syllable readings")
104+
105+
their_scores = sum(u[1] for u in unigrams)
106+
107+
if their_scores <= our_score:
108+
return accrue_error(lineno, "no need to promote")
109+
110+
our_score = their_scores + epsilon
111+
return replace_score_in_lm(lm, reading, value, our_score)
112+
113+
114+
def promote_over_peers(lineno, lm, value, reading):
115+
our_score = find_score_in_lm(lm, reading, value)
116+
if not our_score:
117+
return accrue_error(lineno, f"reading:value {reading}:{value} not in LM")
118+
119+
top_gram = find_top_unigram_in_lm(lm, reading)
120+
if not top_gram:
121+
return accrue_error(lineno, f"no unigrams found for reading: {reading}")
122+
123+
top_value, top_score = top_gram
124+
if top_value == value:
125+
return accrue_error(lineno, f"value {value} already top among peers")
126+
127+
our_score = float(top_score) + epsilon
128+
return replace_score_in_lm(lm, reading, value, our_score)
129+
130+
131+
def run_assert(lineno, lm, readings, expected, warn_only=False):
132+
nodes = most_plausible_walk(readings.split("-"), lm)
133+
result = segmented_values(nodes)
134+
135+
if result != expected:
136+
if warn_only:
137+
return accrue_warning(lineno, f"expected: {expected}, actual: {result}")
138+
else:
139+
return accrue_error(lineno, f"expected: {expected}, actual: {result}")
140+
141+
142+
def postprocess(input, directive, output):
143+
lm = {}
144+
145+
with open(input) as f:
146+
lines = f.readlines()
147+
148+
for line in lines[1:]:
149+
# don't use bare split() since it also splits full-width spaces
150+
r, v, s = line.strip().split(" ")
151+
152+
if r in lm:
153+
lm[r].append((v, s))
154+
else:
155+
lm[r] = [(v, s)]
156+
157+
with open(directive) as f:
158+
lineno = 0
159+
160+
for line in f:
161+
lineno += 1
162+
163+
line = line.strip()
164+
if not line:
165+
continue
166+
if line.startswith("#"):
167+
continue
168+
169+
elements = line.split()
170+
if elements[0] == "assert":
171+
readings = elements[1]
172+
expected = elements[2]
173+
run_assert(lineno, lm, readings, expected)
174+
elif elements[0] == "before":
175+
readings = elements[1]
176+
expected = elements[2]
177+
run_assert(lineno, lm, readings, expected, warn_only=True)
178+
elif elements[0] == "promote-over-single-syllables":
179+
value = elements[1]
180+
reading = elements[2]
181+
promote_over_single_syllables(lineno, lm, value, reading)
182+
elif elements[0] == "promote-over-peers":
183+
value = elements[1]
184+
reading = elements[2]
185+
promote_over_peers(lineno, lm, value, reading)
186+
elif elements[0] == "epsilon":
187+
set_epsilon(float(elements[1]))
188+
else:
189+
accrue_error(lineno, f"unknown command: {elements[0]}")
190+
191+
if errors or warnings:
192+
show_errors_and_warnings()
193+
194+
if warnings:
195+
print("%d warning(s) found" % len(warnings))
196+
197+
if errors:
198+
print("%d error(s) found" % len(errors))
199+
sys.exit(1)
200+
201+
with open(output, "w") as f:
202+
f.write(HEADER)
203+
204+
for r in sorted(lm.keys(), key=lambda x: x.encode()):
205+
for v, s in lm[r]:
206+
f.write("%s %s %s\n" % (r, v, s))
207+
208+
209+
def main():
210+
parser = argparse.ArgumentParser(description="postprocess compiled phrase database")
211+
parser.add_argument("--input", required=True, help="path to source data")
212+
parser.add_argument("--directive", required=True, help="path to directive file")
213+
parser.add_argument("--output", required=True, help="path to postprocessed output")
214+
args = parser.parse_args()
215+
postprocess(input=args.input, directive=args.directive, output=args.output)
216+
217+
218+
if __name__ == "__main__":
219+
main()

0 commit comments

Comments
 (0)