Skip to content

Commit eab82e6

Browse files
committed
Improve the usability of the analysis script
- Introduce `make analyze` to run the analyzer - Move the analyzer to curation/analyzers/ - Improve the UI/UX of the analysis report
1 parent 416783a commit eab82e6

6 files changed

Lines changed: 309 additions & 154 deletions

File tree

Source/Data/Makefile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
PYTHON ?= python3
22

3-
.PHONY: sort clean
3+
.PHONY: sort clean format typecheck analyze
44

55
all: data.txt data-plain-bpmf.txt associated-phrases-v2.txt
66

@@ -114,10 +114,22 @@ _mycodecheck:
114114

115115
format:
116116
black \
117+
curation/analyzers/find_cover_issues.py \
117118
curation/mandarin/*.py \
118119
curation/compilers/postprocess.py \
120+
curation/utils/lmreader.py \
119121
tests/*.py
120122

121123
typecheck:
122124
mypy \
125+
curation/analyzers/find_cover_issues.py \
123126
curation/compilers/postprocess.py
127+
128+
129+
ANALYZE_LIMIT = 50
130+
131+
analyze: curation/analyzers/find_cover_issues.py data.txt
132+
$(PYTHON) -m curation.analyzers.find_cover_issues \
133+
--input data.txt \
134+
--limit $(ANALYZE_LIMIT)
135+

Source/Data/curation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
# >>> from curation.builders import frequency_builder
6060

6161
__all__ = [
62+
"analyzers",
6263
"builders",
6364
"compilers",
6465
"validators",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__all__ = [
2+
"find_cover_issues",
3+
]
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""
2+
List the entries in the LM with issues of insufficient score or ambiguous
3+
covers.
4+
"""
5+
6+
import argparse
7+
import math
8+
from ..utils.lmreader import read_raw_lm_entries
9+
import sys
10+
11+
12+
def analyze(input: str, limit: int = -1) -> None:
13+
cutoff = None if limit <= 0 else limit
14+
raw_entries = read_raw_lm_entries(input)
15+
16+
# Filter punctuation entries.
17+
entries = [entry for entry in raw_entries if not entry[0].startswith("_")]
18+
19+
data = [(rd.split("-"), val, float(scr)) for rd, val, scr in entries]
20+
21+
reading_to_emoji: dict[str, list[str]] = {}
22+
23+
# Keeps track of the highest score a single-char unigram can have.
24+
reading_to_char_score: dict[str, tuple[str, float]] = {}
25+
26+
# Keeps track of the highest score a value can have.
27+
value_to_score: dict[str, float] = {}
28+
29+
monochar_unigram_count = 0
30+
multichar_unigram_count = 0
31+
emoji_count = 0
32+
macro_count = 0
33+
34+
for readings, value, score in data:
35+
# Skip macros
36+
if value.startswith("MACRO@"):
37+
macro_count += 1
38+
continue
39+
40+
# Tally emojis
41+
if score == -8:
42+
emoji_count += 1
43+
key = "-".join(readings)
44+
current = reading_to_emoji.get(key, [])
45+
current.append(value)
46+
reading_to_emoji[key] = current
47+
continue
48+
49+
prev_score = value_to_score.get(value, -math.inf)
50+
if score > prev_score:
51+
value_to_score[value] = score
52+
53+
if len(readings) > 1:
54+
multichar_unigram_count += 1
55+
else:
56+
monochar_unigram_count += 1
57+
58+
key = readings[0]
59+
60+
_, prev_score = reading_to_char_score.get(key, ("", -math.inf))
61+
if score > prev_score:
62+
reading_to_char_score[key] = (value, score)
63+
64+
# Unigrams that can never be typed
65+
faulty: list[tuple[str, str]] = []
66+
67+
# Multi-char phrases that are overriden by individual characters, but
68+
# since those characters are exactly the same as those in the phrase,
69+
# we don't mind ("we are indifferent") that those phrases' score are
70+
# insufficient.
71+
indifferents: list[
72+
tuple[list[str], str, float, list[tuple[str, float]], float, float]
73+
] = []
74+
75+
# Multi-char phrases that are overriden by individual characters with
76+
# much higher scores in total. These are the problematic phrases we are
77+
# trying to promote with caution.
78+
insufficients: list[
79+
tuple[list[str], str, float, list[tuple[str, float]], float, float]
80+
] = []
81+
82+
# Multi-char, homophonic phrases that compete with each other.
83+
competing_unigrams: list[tuple[str, float, str, float]] = []
84+
85+
# Seen readings.
86+
phrase_readings = set()
87+
88+
for readings, value, score in data:
89+
# We only care about multi-character phrases. No emojis.
90+
if len(readings) < 2 or score == -8:
91+
continue
92+
93+
joined_reading = "-".join(readings)
94+
phrase_readings.add(joined_reading)
95+
96+
# Keeps track of "competing" values with the same "component"
97+
# readings.
98+
comp: list[tuple[str, float]] = []
99+
ts = 0.0
100+
bad = False
101+
for reading in readings:
102+
if reading not in reading_to_char_score:
103+
bad = True
104+
break
105+
106+
uv, us = reading_to_char_score[reading]
107+
ts += us
108+
comp.append((uv, us))
109+
110+
if bad:
111+
faulty.append((joined_reading, value))
112+
continue
113+
114+
if ts >= score:
115+
i = (readings, value, score, comp, ts, (score - ts))
116+
117+
k = "".join([x[0] for x in comp])
118+
if value == k:
119+
indifferents.append(i)
120+
else:
121+
if k in value_to_score and value != k:
122+
# If k also happens to be another phrase.
123+
if score < value_to_score[k]:
124+
competing_unigrams.append((value, score, k, value_to_score[k]))
125+
insufficients.append(i)
126+
127+
# Sort by the phrases' own score, since they represent how frequently
128+
# they show up in the training corpus.
129+
insufficients = sorted(insufficients, key=lambda i: i[2], reverse=True)
130+
indifferents = sorted(indifferents, key=lambda i: i[2], reverse=True)
131+
132+
# Ditto for competing_unigrams
133+
competing_unigrams = sorted(competing_unigrams, key=lambda i: i[1], reverse=True)
134+
135+
def form_entry(heading, e):
136+
readings, phrase, score, competing_unigrams, their_score, delta = e
137+
138+
competing_phrase = "+".join(c[0] for c in competing_unigrams)
139+
reading = "-".join(readings)
140+
141+
return f"{heading} {phrase} {score:7.4f} < {competing_phrase} {their_score:7.4f} {reading}"
142+
143+
def print_suppression_if_needed(total):
144+
if cutoff is not None and total > cutoff:
145+
print(f"...and {total - cutoff} more entries suppressed")
146+
print()
147+
148+
separator = "-" * 72
149+
print(separator)
150+
print("Summary")
151+
print(separator)
152+
print(f"{monochar_unigram_count:6d} unigrams with one character")
153+
print(f"{multichar_unigram_count:6d} unigrams with multiple characters")
154+
print(f"{emoji_count:6d} emojis")
155+
print(f"{macro_count:6d} macros")
156+
print()
157+
158+
print(separator)
159+
print("Multi-Character Phrases with Issues")
160+
print(separator)
161+
print(
162+
"%d unigrams that are not the top candidate (%.1f%% of unigrams)"
163+
% (
164+
len(insufficients),
165+
len(insufficients) / float(multichar_unigram_count) * 100.0,
166+
)
167+
)
168+
print()
169+
print("of which:")
170+
171+
insufficients_map = {}
172+
for x in range(2, 7):
173+
entries_xch = [i for i in insufficients if len(i[0]) == x]
174+
insufficients_map[x] = entries_xch
175+
print(f"{len(entries_xch):6d} {x}-character unigrams")
176+
177+
print()
178+
print(
179+
f"{len(competing_unigrams)} unigrams also compete with unigrams with top-ranking characters"
180+
)
181+
print(
182+
f"{len(indifferents)} unigrams whose scores are lower than their identical components"
183+
)
184+
print()
185+
186+
for x in range(2, 7):
187+
entries_xch = insufficients_map[x]
188+
189+
if not entries_xch:
190+
continue
191+
192+
print(separator)
193+
print(f"Top Insufficient {x}-Character Unigrams")
194+
print(separator)
195+
196+
for e in entries_xch[:cutoff]:
197+
print(form_entry("insufficient", e))
198+
print_suppression_if_needed(len(entries_xch))
199+
200+
print(separator)
201+
print("Top Phrases that Compete with Other 'Peer' Phrases")
202+
print(separator)
203+
for entry in competing_unigrams[:cutoff]:
204+
our_value, our_score, their_value, their_score = entry
205+
print(
206+
f"competing {our_value} {our_score:7.4f} < {their_value} {their_score:7.4f}"
207+
)
208+
print_suppression_if_needed(len(competing_unigrams))
209+
210+
print(separator)
211+
print("Multi-Character Phrases with Issues but We Don't Care")
212+
print(separator)
213+
214+
for i in indifferents[:cutoff]:
215+
print(form_entry("indifferent", i))
216+
print_suppression_if_needed(len(indifferents))
217+
218+
if faulty:
219+
print(separator)
220+
print("Unigrams that Cannot Be Typed")
221+
print(separator)
222+
for f in faulty:
223+
print(f)
224+
print()
225+
226+
keys = reading_to_emoji.keys() - reading_to_char_score.keys() - phrase_readings
227+
if len(keys) > 0:
228+
print(separator)
229+
print("Emojis with No Covering Phrases (But May Have Smaller Covering Phrases)")
230+
print(separator)
231+
for k in list(keys)[:cutoff]:
232+
values = ", ".join(reading_to_emoji[k])
233+
print(f"{values:<10s} {k}")
234+
print_suppression_if_needed(len(keys))
235+
236+
237+
def main():
238+
parser = argparse.ArgumentParser(description="find issues with phrases")
239+
parser.add_argument("--input", required=True, help="path to the LM file")
240+
parser.add_argument(
241+
"--limit", type=int, default=20, help="sample limit (-1 means unlimited)"
242+
)
243+
args = parser.parse_args()
244+
analyze(input=args.input, limit=args.limit)
245+
246+
247+
if __name__ == "__main__":
248+
main()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from ..compilers.compiler_utils import HEADER
2+
3+
4+
def read_raw_lm_entries(path: str) -> list[tuple[str, str, str]]:
5+
"""Read a McBopomofo LM file and return the raw entries"""
6+
7+
entries = []
8+
9+
with open(path) as f:
10+
lines = f.readlines()
11+
12+
if not lines or lines[0] != HEADER:
13+
raise AssertionError(f"{path} is not a sorted McBopomofo LM file")
14+
15+
for line in lines[1:]:
16+
# only split with one single whitespace, since split() can split
17+
# with full-width spaces characters, which we actually want
18+
reading, value, score = line.strip().split(" ")
19+
20+
entries.append((reading, value, score))
21+
22+
return entries
23+
24+
25+
def read_lm(path: str) -> dict[str, list[tuple[str, str]]]:
26+
"""Read a McBopomofo LM file and return the unigram mappings
27+
28+
Each reading maps to a list of (value, score) pair, but the score is
29+
in string; this is to minimize diff, since reading floating point numbers
30+
in and writing it out does not always guarantee to produce the same string
31+
output, especially when different tools written in different languages
32+
are involved."""
33+
34+
lm: dict[str, list[tuple[str, str]]] = {}
35+
36+
entries = read_raw_lm_entries(path)
37+
38+
for reading, value, score in entries:
39+
if reading in lm:
40+
lm[reading].append((value, score))
41+
else:
42+
lm[reading] = [(value, score)]
43+
44+
return lm

0 commit comments

Comments
 (0)