Skip to content

Commit 06e0bbd

Browse files
Export roman2khmer v3 model to TFLite/CoreML + exact-match shortcut bundle
Ships the frequency-oversampled (v3) model and the top-500 exact-match shortcut layer under ml/roman2khmer/dist/ (tracked, unlike artifacts/) so the bundle can be merged into the iOS/Android projects on another machine without re-running training. compare_all.py showed v3+shortcut beats plain (v2) training and the shortcut alone across every slice of the held-out set. Also parameterizes convert_to_mobile.py's conversion routine so it can target either model version instead of only the default path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2b8c464 commit 06e0bbd

11 files changed

Lines changed: 14476 additions & 19 deletions

File tree

ml/roman2khmer/conversion/convert_to_mobile.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@
2424
from training.model import build_model
2525

2626

27-
def convert_tflite(model):
27+
def convert_tflite(model, tflite_path):
2828
converter = tf.lite.TFLiteConverter.from_keras_model(model)
2929
converter.optimizations = [tf.lite.Optimize.DEFAULT]
3030
tflite_bytes = converter.convert()
31-
config.TFLITE_PATH.write_bytes(tflite_bytes)
31+
tflite_path.write_bytes(tflite_bytes)
3232
return tflite_bytes
3333

3434

35-
def convert_coreml(model):
35+
def convert_coreml(model, coreml_path):
3636
@tf.function
3737
def f(chars, prev):
3838
return model({"chars": chars, "prev": prev})
@@ -51,7 +51,7 @@ def f(chars, prev):
5151
minimum_deployment_target=ct.target.iOS15,
5252
compute_precision=ct.precision.FLOAT32,
5353
)
54-
mlmodel.save(str(config.COREML_PATH))
54+
mlmodel.save(str(coreml_path))
5555
return mlmodel
5656

5757

@@ -107,8 +107,8 @@ def benchmark(fn, inputs, n=50):
107107
return (time.time() - t0) / n * 1000
108108

109109

110-
def main():
111-
trained = keras.models.load_model(config.KERAS_MODEL_PATH, safe_mode=False)
110+
def convert(keras_path, tflite_path, coreml_path, dest_dir):
111+
trained = keras.models.load_model(keras_path, safe_mode=False)
112112
vocab = load_vocab()
113113
context_vocab = load_context_vocab()
114114

@@ -128,12 +128,12 @@ def main():
128128

129129
keras_probs = model.predict(X_sample, verbose=0)
130130

131-
print("converting to TFLite...")
132-
tflite_bytes = convert_tflite(model)
131+
print(f"converting {keras_path.name} to TFLite...")
132+
tflite_bytes = convert_tflite(model, tflite_path)
133133
tflite_probs = tflite_predict(tflite_bytes, X_sample)
134134

135-
print("converting to CoreML...")
136-
mlmodel = convert_coreml(model)
135+
print(f"converting {keras_path.name} to CoreML...")
136+
mlmodel = convert_coreml(model, coreml_path)
137137

138138
max_diff = np.abs(keras_probs - tflite_probs).max()
139139
top1 = agreement(keras_probs, tflite_probs, 1)
@@ -143,7 +143,7 @@ def main():
143143
tflite_latency = benchmark(lambda x: tflite_predict(tflite_bytes, x), X_sample)
144144
print(f"TFLite latency: {tflite_latency:.2f} ms/inference")
145145

146-
tflite_size_kb = config.TFLITE_PATH.stat().st_size / 1024
146+
tflite_size_kb = tflite_path.stat().st_size / 1024
147147
print(f"TFLite size: {tflite_size_kb:.1f} KB")
148148

149149
try:
@@ -160,15 +160,20 @@ def main():
160160
coreml_latency = benchmark(lambda x: coreml_predict(mlmodel, x), X_sample)
161161
print(f"CoreML latency: {coreml_latency:.2f} ms/inference")
162162

163-
shutil.copy(config.VOCAB_PATH, config.MODEL_DIR / config.VOCAB_PATH.name)
164-
shutil.copy(config.CHAR_VOCAB_PATH, config.MODEL_DIR / config.CHAR_VOCAB_PATH.name)
165-
shutil.copy(config.CONTEXT_VOCAB_PATH, config.MODEL_DIR / config.CONTEXT_VOCAB_PATH.name)
163+
dest_dir.mkdir(parents=True, exist_ok=True)
164+
shutil.copy(config.VOCAB_PATH, dest_dir / config.VOCAB_PATH.name)
165+
shutil.copy(config.CHAR_VOCAB_PATH, dest_dir / config.CHAR_VOCAB_PATH.name)
166+
shutil.copy(config.CONTEXT_VOCAB_PATH, dest_dir / config.CONTEXT_VOCAB_PATH.name)
167+
168+
print(f"saved {tflite_path}")
169+
print(f"saved {coreml_path}")
170+
print(f"saved {dest_dir / config.VOCAB_PATH.name}")
171+
print(f"saved {dest_dir / config.CHAR_VOCAB_PATH.name}")
172+
print(f"saved {dest_dir / config.CONTEXT_VOCAB_PATH.name}")
166173

167-
print(f"saved {config.TFLITE_PATH}")
168-
print(f"saved {config.COREML_PATH}")
169-
print(f"saved {config.MODEL_DIR / config.VOCAB_PATH.name}")
170-
print(f"saved {config.MODEL_DIR / config.CHAR_VOCAB_PATH.name}")
171-
print(f"saved {config.MODEL_DIR / config.CONTEXT_VOCAB_PATH.name}")
174+
175+
def main():
176+
convert(config.KERAS_MODEL_PATH, config.TFLITE_PATH, config.COREML_PATH, config.MODEL_DIR)
172177

173178

174179
if __name__ == "__main__":
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Produce the shippable roman2khmer bundle in dist/ (tracked in git, unlike
2+
artifacts/) so it can be merged into the iOS project on another machine
3+
without re-running training or conversion.
4+
5+
Ships the v3 (frequency-oversampled) model, since comparison/compare_all.py
6+
showed it beats v2 across the board, plus the exact-match shortcut map for
7+
the SHORTCUT_TOP_N highest-frequency words (see comparison/shortcut.py) --
8+
the two combined gave the best numbers in every slice of that comparison.
9+
10+
Usage: python -m conversion.export_dist (run from ml/roman2khmer/)
11+
"""
12+
13+
import json
14+
import sys
15+
from pathlib import Path
16+
17+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18+
import config
19+
from conversion.convert_to_mobile import convert
20+
from comparison.shortcut import build_shortcut_map
21+
from training.dataset import load_vocab
22+
23+
DIST_DIR = config.ROOT / "dist"
24+
25+
26+
def main():
27+
DIST_DIR.mkdir(parents=True, exist_ok=True)
28+
29+
convert(
30+
config.KERAS_MODEL_V3_PATH,
31+
DIST_DIR / "Roman2Khmer.tflite",
32+
DIST_DIR / "Roman2Khmer.mlpackage",
33+
DIST_DIR,
34+
)
35+
36+
vocab = load_vocab()
37+
shortcut_map = build_shortcut_map(vocab, config.SHORTCUT_TOP_N)
38+
with open(DIST_DIR / "shortcut.json", "w", encoding="utf-8") as f:
39+
json.dump(shortcut_map, f, ensure_ascii=False, indent=2)
40+
print(f"saved {DIST_DIR / 'shortcut.json'} ({len(shortcut_map)} exact romanizations)")
41+
42+
43+
if __name__ == "__main__":
44+
main()

ml/roman2khmer/dist/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# roman2khmer shippable bundle
2+
3+
Committed to git (unlike `../artifacts/`) so it can be merged into the iOS
4+
(and Android) projects on another machine without re-running training or
5+
conversion. Regenerate with `python -m conversion.export_dist` from
6+
`ml/roman2khmer/` if the model or vocab ever changes.
7+
8+
This is the **v3** model (frequency-oversampled training) plus the
9+
exact-match shortcut for the 500 highest-frequency words -- see the
10+
`comparison/` scripts for how that combination was chosen over plain (v2)
11+
training and over the shortcut alone.
12+
13+
## Files
14+
15+
- `Roman2Khmer.tflite` / `Roman2Khmer.mlpackage` -- the model, two inputs:
16+
- `chars`: int32[20], the romanized text typed so far, encoded via
17+
`char_vocab.json` (`pad_index`/`unk_index`/`chars` map, 0-padded on the right)
18+
- `prev`: int32 scalar, the previous word, encoded via `context_vocab.json`
19+
(index 0 = `<unk>` -- no reliable previous-word context, e.g. first word
20+
of a message; index 1 = `<s>` -- explicit sentence start)
21+
- output: float32[6782] softmax over `vocab.json` (index -> Khmer word)
22+
- `vocab.json`, `char_vocab.json`, `context_vocab.json` -- the encode/decode
23+
tables the two inputs/output above are defined against.
24+
- `shortcut.json` -- `{romanization: khmer_word}` for the 500 highest-
25+
frequency words' exact known spellings. Apply this **before** the model,
26+
and **only** once the user has finished typing a word (not mid-prefix --
27+
a partial prefix can coincide character-for-character with one of these
28+
words' full spelling and wrongly hijack the prediction otherwise, see
29+
`comparison/shortcut.py`'s docstring for the measured collision rate):
30+
if the typed text is an exact key in this map, use its value as the
31+
top-1 suggestion and let the model fill in the rest of the ranked list.
32+
33+
## Known limitations (not yet addressed by this bundle)
34+
35+
- Offline top-1/top-3/top-5 accuracy on held-out data is roughly 31%/45%/50%
36+
overall (see `comparison/compare_all.py` output) -- useful as a ranked
37+
suggestion-bar candidate, not a silent auto-correct/auto-commit.
38+
- CoreML inference was exported but not runtime-validated (coremltools can
39+
only run predictions on macOS); validate on a Mac/iOS device before
40+
shipping.
41+
- Not yet wired into `KhmerlangCorrector`/`SuggestionProvider` (iOS) or the
42+
Android equivalent -- that integration is a separate follow-up.
Binary file not shown.
Binary file not shown.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"fileFormatVersion": "1.0.0",
3+
"itemInfoEntries": {
4+
"38607878-7a33-499e-8da5-cb01dd8ccfec": {
5+
"author": "com.apple.CoreML",
6+
"description": "CoreML Model Weights",
7+
"name": "weights",
8+
"path": "com.apple.CoreML/weights"
9+
},
10+
"e5dfb06c-5251-41c7-8b5f-0f4221dc225d": {
11+
"author": "com.apple.CoreML",
12+
"description": "CoreML Model Specification",
13+
"name": "model.mlmodel",
14+
"path": "com.apple.CoreML/model.mlmodel"
15+
}
16+
},
17+
"rootModelIdentifier": "e5dfb06c-5251-41c7-8b5f-0f4221dc225d"
18+
}
1.4 MB
Binary file not shown.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"pad_index": 0,
3+
"unk_index": 1,
4+
"chars": {
5+
"a": 2,
6+
"b": 3,
7+
"c": 4,
8+
"d": 5,
9+
"e": 6,
10+
"f": 7,
11+
"g": 8,
12+
"h": 9,
13+
"i": 10,
14+
"j": 11,
15+
"k": 12,
16+
"l": 13,
17+
"m": 14,
18+
"n": 15,
19+
"o": 16,
20+
"p": 17,
21+
"q": 18,
22+
"r": 19,
23+
"s": 20,
24+
"t": 21,
25+
"u": 22,
26+
"v": 23,
27+
"w": 24,
28+
"x": 25,
29+
"y": 26,
30+
"z": 27
31+
},
32+
"max_len": 20
33+
}

0 commit comments

Comments
 (0)