cfahlgren1 HF Staff commited on
Commit
a028f12
·
0 Parent(s):

Duplicate from cfahlgren1/enron-autocomplete

Browse files
Files changed (9) hide show
  1. .gitattributes +36 -0
  2. README.md +111 -0
  3. best.pt +3 -0
  4. hero.png +3 -0
  5. model.onnx +3 -0
  6. model_quint8.onnx +3 -0
  7. report.json +548 -0
  8. train.py +557 -0
  9. vocab.json +9 -0
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ hero.png filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: onnx
3
+ pipeline_tag: text-generation
4
+ datasets:
5
+ - corbt/enron-emails
6
+ language:
7
+ - en
8
+ tags:
9
+ - autocomplete
10
+ - gru
11
+ - onnxruntime-web
12
+ - in-browser
13
+ ---
14
+
15
+ # enron-autocomplete
16
+
17
+ [![Typing "Please accept my resignation. I will be o" and the model suggesting "ut of the office"](hero.png)](https://huggingface.co/spaces/cfahlgren1/enron-autocomplete)
18
+
19
+ A tiny email autocomplete that runs in your browser. It learned to write from Enron's emails.
20
+
21
+ **[Try the demo](https://huggingface.co/spaces/cfahlgren1/enron-autocomplete)**
22
+
23
+ | | |
24
+ |---|---|
25
+ | Size | 944K parameters, 0.98 MB |
26
+ | Speed | about 4 ms per suggestion, on CPU in Chrome |
27
+ | Training | 14 minutes on one A10G with Hugging Face Jobs, 35 cents |
28
+ | Data | 119,859 Enron emails |
29
+
30
+ ## What it does
31
+
32
+ It finishes the word or short phrase you're typing.
33
+
34
+ | You type | It suggests |
35
+ |---|---|
36
+ | Please accept my resig | nation |
37
+ | Please accept my resignation. I will be o | ut of the office |
38
+ | The books are fine. Please call me i | f you have any |
39
+ | Enron N | orth America Corp. |
40
+
41
+ ## How sure it has to be
42
+
43
+ The model knows how sure it is of each guess. You pick a cutoff, and it only suggests when it's at least that sure. A higher cutoff means fewer suggestions, and more of them right.
44
+
45
+ | Cutoff | How often it suggests | How often it's right |
46
+ |---|---|---|
47
+ | 20% | 30% of the time | 48% |
48
+ | 35% | 18% of the time | 66% |
49
+ | 50% | 11% of the time | 80% |
50
+ | 70% | 7% of the time | 90% |
51
+
52
+ The demo uses 40%.
53
+
54
+ These numbers come from 5,000 places in emails from people the model never saw in training. "Right" means the suggestion exactly matched what the person typed next. Right suggestions are about 6 characters long on average.
55
+
56
+ ## Files
57
+
58
+ | File | What it is |
59
+ |---|---|
60
+ | `model_quint8.onnx` | The model to use. 0.98 MB. |
61
+ | `model.onnx` | The same model before shrinking. 3.8 MB. |
62
+ | `best.pt` | The PyTorch checkpoint. |
63
+ | `vocab.json` | The character ids. |
64
+ | `report.json` | The full training and test results. |
65
+ | `train.py` | The training script. |
66
+
67
+ I also tried a bigger version with 3.46M parameters and 3.5 MB. It wasn't better. When both models suggest equally often, 9% of the time, the small one is right about 85% of the time and the big one 82%. The big one got the same training settings and may need different ones to do well.
68
+
69
+ ## Use it
70
+
71
+ The model takes one character and its memory of the text so far. It returns its guess for the next character and an updated memory. So each key press is one small step.
72
+
73
+ ```js
74
+ import * as ort from "onnxruntime-web";
75
+
76
+ const session = await ort.InferenceSession.create("model_quint8.onnx", {
77
+ executionProviders: ["wasm"],
78
+ });
79
+ let state = new ort.Tensor("float32", new Float32Array(512), [1, 512]);
80
+
81
+ async function step(id) {
82
+ const input_id = new ort.Tensor("int64", BigInt64Array.of(BigInt(id)), [1]);
83
+ const { logits, next_state } = await session.run({ input_id, state });
84
+ state = next_state;
85
+ return logits.data;
86
+ }
87
+
88
+ let logits = await step(1); // 1 starts a new email
89
+ for (const char of "Let me kn") logits = await step(char.charCodeAt(0) - 29);
90
+ ```
91
+
92
+ A character's id is its character code minus 29. A new line is 2 and the end of an email is 1. Only English keyboard characters are supported.
93
+
94
+ To build a suggestion, keep taking the most likely next character and multiply the probabilities together as you go. Stop at a new line, or when that running product drops below your cutoff. Show the text up to the last finished word.
95
+
96
+ ## Training
97
+
98
+ It read 40 million characters from 119,859 emails sent from enron.com addresses, 30 times over. Typos were added to what it read, so it keeps predicting when you make one. The run took 14 minutes on one A10G with Hugging Face Jobs and cost 35 cents.
99
+
100
+ [`train.py`](train.py) is the whole thing: it downloads the emails, trains, tests and exports the model. To train your own:
101
+
102
+ ```bash
103
+ hf jobs uv run --flavor a10g-large --secrets HF_TOKEN train.py --repo your-name/your-model
104
+ ```
105
+
106
+ ## Limits
107
+
108
+ - It only knows office email from 2001. Outside that it has little to say.
109
+ - With no cutoff it rambles. "Dear Santa," becomes "I am attaching a copy of the agreement to the Enron Corp."
110
+ - It learned real names and sign-offs from the emails, so keep suggestions short.
111
+ - English only.
best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8f2a663ad7a697a00ebf6bd15942824fcefa46975f9935c7ac6dd1eceedf3a36
3
+ size 3780287
hero.png ADDED

Git LFS Details

  • SHA256: d8ec18d7cbad3523d11aaa8e34e47146b9dad5ede8a9e67487ff0de0b2943349
  • Pointer size: 131 Bytes
  • Size of remote file: 222 kB
model.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1ccc402ffaa27e6382114ad0d44a2e5a01e9beabe9d81e3c3ae0ad2f9676627b
3
+ size 3780016
model_quint8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:079424a9d2a72b9e5b2d6002c6466b836023c088780b7d292748f37564dc143b
3
+ size 981294
report.json ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "args": {
3
+ "hidden": 512,
4
+ "epochs": 30,
5
+ "batch": 256,
6
+ "seq": 256,
7
+ "burn_in": 32,
8
+ "lr": 0.0015,
9
+ "typo_rate": 0.08,
10
+ "limit_frac": 1.0,
11
+ "eval_positions": 5000,
12
+ "context": 128,
13
+ "horizon": 48,
14
+ "repo": "cfahlgren1/email-autocomplete-gru",
15
+ "run_name": "gru-512",
16
+ "no_push": false
17
+ },
18
+ "device": "cuda",
19
+ "parameters": 944354,
20
+ "data": {
21
+ "train": {
22
+ "emails": 119859,
23
+ "chars": 40136658
24
+ },
25
+ "val": {
26
+ "emails": 8252,
27
+ "chars": 2775980
28
+ },
29
+ "test": {
30
+ "emails": 6922,
31
+ "chars": 2710169
32
+ }
33
+ },
34
+ "history": [
35
+ {
36
+ "epoch": 1,
37
+ "train_loss": 1.7093,
38
+ "val_loss": 1.3716,
39
+ "seconds": 23.9,
40
+ "chars_per_sec": 1678766
41
+ },
42
+ {
43
+ "epoch": 2,
44
+ "train_loss": 1.2792,
45
+ "val_loss": 1.2911,
46
+ "seconds": 23.6,
47
+ "chars_per_sec": 1701572
48
+ },
49
+ {
50
+ "epoch": 3,
51
+ "train_loss": 1.2231,
52
+ "val_loss": 1.2582,
53
+ "seconds": 23.6,
54
+ "chars_per_sec": 1700904
55
+ },
56
+ {
57
+ "epoch": 4,
58
+ "train_loss": 1.195,
59
+ "val_loss": 1.2396,
60
+ "seconds": 23.6,
61
+ "chars_per_sec": 1702926
62
+ },
63
+ {
64
+ "epoch": 5,
65
+ "train_loss": 1.1768,
66
+ "val_loss": 1.2254,
67
+ "seconds": 23.5,
68
+ "chars_per_sec": 1703932
69
+ },
70
+ {
71
+ "epoch": 6,
72
+ "train_loss": 1.1639,
73
+ "val_loss": 1.2171,
74
+ "seconds": 23.6,
75
+ "chars_per_sec": 1701905
76
+ },
77
+ {
78
+ "epoch": 7,
79
+ "train_loss": 1.1538,
80
+ "val_loss": 1.2087,
81
+ "seconds": 23.6,
82
+ "chars_per_sec": 1701450
83
+ },
84
+ {
85
+ "epoch": 8,
86
+ "train_loss": 1.1456,
87
+ "val_loss": 1.2043,
88
+ "seconds": 23.6,
89
+ "chars_per_sec": 1702601
90
+ },
91
+ {
92
+ "epoch": 9,
93
+ "train_loss": 1.1388,
94
+ "val_loss": 1.1969,
95
+ "seconds": 23.5,
96
+ "chars_per_sec": 1703175
97
+ },
98
+ {
99
+ "epoch": 10,
100
+ "train_loss": 1.1327,
101
+ "val_loss": 1.193,
102
+ "seconds": 23.6,
103
+ "chars_per_sec": 1701059
104
+ },
105
+ {
106
+ "epoch": 11,
107
+ "train_loss": 1.1276,
108
+ "val_loss": 1.1895,
109
+ "seconds": 23.6,
110
+ "chars_per_sec": 1702827
111
+ },
112
+ {
113
+ "epoch": 12,
114
+ "train_loss": 1.1228,
115
+ "val_loss": 1.1856,
116
+ "seconds": 23.6,
117
+ "chars_per_sec": 1700650
118
+ },
119
+ {
120
+ "epoch": 13,
121
+ "train_loss": 1.1186,
122
+ "val_loss": 1.1824,
123
+ "seconds": 23.5,
124
+ "chars_per_sec": 1703353
125
+ },
126
+ {
127
+ "epoch": 14,
128
+ "train_loss": 1.1141,
129
+ "val_loss": 1.1791,
130
+ "seconds": 23.6,
131
+ "chars_per_sec": 1702586
132
+ },
133
+ {
134
+ "epoch": 15,
135
+ "train_loss": 1.1108,
136
+ "val_loss": 1.1765,
137
+ "seconds": 23.6,
138
+ "chars_per_sec": 1702570
139
+ },
140
+ {
141
+ "epoch": 16,
142
+ "train_loss": 1.1075,
143
+ "val_loss": 1.175,
144
+ "seconds": 23.6,
145
+ "chars_per_sec": 1702686
146
+ },
147
+ {
148
+ "epoch": 17,
149
+ "train_loss": 1.1041,
150
+ "val_loss": 1.1718,
151
+ "seconds": 23.5,
152
+ "chars_per_sec": 1703957
153
+ },
154
+ {
155
+ "epoch": 18,
156
+ "train_loss": 1.1015,
157
+ "val_loss": 1.17,
158
+ "seconds": 23.6,
159
+ "chars_per_sec": 1701778
160
+ },
161
+ {
162
+ "epoch": 19,
163
+ "train_loss": 1.0985,
164
+ "val_loss": 1.1678,
165
+ "seconds": 23.6,
166
+ "chars_per_sec": 1702612
167
+ },
168
+ {
169
+ "epoch": 20,
170
+ "train_loss": 1.0959,
171
+ "val_loss": 1.1659,
172
+ "seconds": 23.6,
173
+ "chars_per_sec": 1702960
174
+ },
175
+ {
176
+ "epoch": 21,
177
+ "train_loss": 1.0935,
178
+ "val_loss": 1.1642,
179
+ "seconds": 23.6,
180
+ "chars_per_sec": 1701776
181
+ },
182
+ {
183
+ "epoch": 22,
184
+ "train_loss": 1.0913,
185
+ "val_loss": 1.1632,
186
+ "seconds": 23.6,
187
+ "chars_per_sec": 1702317
188
+ },
189
+ {
190
+ "epoch": 23,
191
+ "train_loss": 1.0896,
192
+ "val_loss": 1.1624,
193
+ "seconds": 23.6,
194
+ "chars_per_sec": 1701215
195
+ },
196
+ {
197
+ "epoch": 24,
198
+ "train_loss": 1.0874,
199
+ "val_loss": 1.1613,
200
+ "seconds": 23.6,
201
+ "chars_per_sec": 1701278
202
+ },
203
+ {
204
+ "epoch": 25,
205
+ "train_loss": 1.0863,
206
+ "val_loss": 1.1606,
207
+ "seconds": 23.6,
208
+ "chars_per_sec": 1700749
209
+ },
210
+ {
211
+ "epoch": 26,
212
+ "train_loss": 1.085,
213
+ "val_loss": 1.1596,
214
+ "seconds": 23.6,
215
+ "chars_per_sec": 1701575
216
+ },
217
+ {
218
+ "epoch": 27,
219
+ "train_loss": 1.0839,
220
+ "val_loss": 1.1592,
221
+ "seconds": 23.6,
222
+ "chars_per_sec": 1699705
223
+ },
224
+ {
225
+ "epoch": 28,
226
+ "train_loss": 1.083,
227
+ "val_loss": 1.1588,
228
+ "seconds": 23.6,
229
+ "chars_per_sec": 1700990
230
+ },
231
+ {
232
+ "epoch": 29,
233
+ "train_loss": 1.0823,
234
+ "val_loss": 1.1588,
235
+ "seconds": 23.6,
236
+ "chars_per_sec": 1702216
237
+ },
238
+ {
239
+ "epoch": 30,
240
+ "train_loss": 1.0821,
241
+ "val_loss": 1.1586,
242
+ "seconds": 23.5,
243
+ "chars_per_sec": 1704907
244
+ }
245
+ ],
246
+ "best_val_loss": 1.1586,
247
+ "test_positions": 5000,
248
+ "held_out_sender_suggestions": {
249
+ "0.05": {
250
+ "coverage": 0.624,
251
+ "precision": 0.2064,
252
+ "avg_chars_when_correct": 6.93,
253
+ "chars_saved_per_position": 0.892
254
+ },
255
+ "0.1": {
256
+ "coverage": 0.4622,
257
+ "precision": 0.3124,
258
+ "avg_chars_when_correct": 6.41,
259
+ "chars_saved_per_position": 0.925
260
+ },
261
+ "0.2": {
262
+ "coverage": 0.2974,
263
+ "precision": 0.4808,
264
+ "avg_chars_when_correct": 6.03,
265
+ "chars_saved_per_position": 0.863
266
+ },
267
+ "0.35": {
268
+ "coverage": 0.1796,
269
+ "precision": 0.6559,
270
+ "avg_chars_when_correct": 5.8,
271
+ "chars_saved_per_position": 0.683
272
+ },
273
+ "0.5": {
274
+ "coverage": 0.1114,
275
+ "precision": 0.7953,
276
+ "avg_chars_when_correct": 5.64,
277
+ "chars_saved_per_position": 0.5
278
+ },
279
+ "0.7": {
280
+ "coverage": 0.0694,
281
+ "precision": 0.8991,
282
+ "avg_chars_when_correct": 5.4,
283
+ "chars_saved_per_position": 0.337
284
+ }
285
+ },
286
+ "exports": {
287
+ "float": {
288
+ "size_mb": 3.78,
289
+ "native_cpu_single_thread_step": {
290
+ "p50_ms": 0.114,
291
+ "p95_ms": 0.125
292
+ },
293
+ "suggestions_1000_positions": {
294
+ "0.05": {
295
+ "coverage": 0.638,
296
+ "precision": 0.2132,
297
+ "avg_chars_when_correct": 6.7,
298
+ "chars_saved_per_position": 0.911
299
+ },
300
+ "0.1": {
301
+ "coverage": 0.496,
302
+ "precision": 0.3085,
303
+ "avg_chars_when_correct": 6.3,
304
+ "chars_saved_per_position": 0.964
305
+ },
306
+ "0.2": {
307
+ "coverage": 0.312,
308
+ "precision": 0.4583,
309
+ "avg_chars_when_correct": 5.94,
310
+ "chars_saved_per_position": 0.849
311
+ },
312
+ "0.35": {
313
+ "coverage": 0.202,
314
+ "precision": 0.604,
315
+ "avg_chars_when_correct": 5.77,
316
+ "chars_saved_per_position": 0.704
317
+ },
318
+ "0.5": {
319
+ "coverage": 0.119,
320
+ "precision": 0.7479,
321
+ "avg_chars_when_correct": 5.58,
322
+ "chars_saved_per_position": 0.497
323
+ },
324
+ "0.7": {
325
+ "coverage": 0.078,
326
+ "precision": 0.8718,
327
+ "avg_chars_when_correct": 5.21,
328
+ "chars_saved_per_position": 0.354
329
+ }
330
+ }
331
+ },
332
+ "quint8": {
333
+ "size_mb": 0.98,
334
+ "native_cpu_single_thread_step": {
335
+ "p50_ms": 0.059,
336
+ "p95_ms": 0.084
337
+ },
338
+ "suggestions_1000_positions": {
339
+ "0.05": {
340
+ "coverage": 0.655,
341
+ "precision": 0.2,
342
+ "avg_chars_when_correct": 6.83,
343
+ "chars_saved_per_position": 0.895
344
+ },
345
+ "0.1": {
346
+ "coverage": 0.492,
347
+ "precision": 0.311,
348
+ "avg_chars_when_correct": 6.35,
349
+ "chars_saved_per_position": 0.971
350
+ },
351
+ "0.2": {
352
+ "coverage": 0.318,
353
+ "precision": 0.4686,
354
+ "avg_chars_when_correct": 5.97,
355
+ "chars_saved_per_position": 0.89
356
+ },
357
+ "0.35": {
358
+ "coverage": 0.198,
359
+ "precision": 0.6414,
360
+ "avg_chars_when_correct": 5.66,
361
+ "chars_saved_per_position": 0.719
362
+ },
363
+ "0.5": {
364
+ "coverage": 0.123,
365
+ "precision": 0.7642,
366
+ "avg_chars_when_correct": 5.53,
367
+ "chars_saved_per_position": 0.52
368
+ },
369
+ "0.7": {
370
+ "coverage": 0.081,
371
+ "precision": 0.8889,
372
+ "avg_chars_when_correct": 5.42,
373
+ "chars_saved_per_position": 0.39
374
+ }
375
+ }
376
+ }
377
+ },
378
+ "prompt_samples": {
379
+ "Hi John,\n\nThanks for ": {
380
+ "continuation": "the info. I will be out of the office tomorrow a",
381
+ "suggestion_at_0.2": "the"
382
+ },
383
+ "Please let me know if ": {
384
+ "continuation": "you have any questions.\n\nThanks,\n\nKayThe follow",
385
+ "suggestion_at_0.2": "you have any"
386
+ },
387
+ "I will send you ": {
388
+ "continuation": "a copy of the attached document. I will be out o",
389
+ "suggestion_at_0.2": "a"
390
+ },
391
+ "Let me kn": {
392
+ "continuation": "ow if you have any questions.\n\nThanks,\n\nKayThe ",
393
+ "suggestion_at_0.2": "ow if you have"
394
+ },
395
+ "Thnaks for the ": {
396
+ "continuation": "info. I will be out of the office tomorrow and w",
397
+ "suggestion_at_0.2": ""
398
+ },
399
+ "I look forward to ": {
400
+ "continuation": "seeing you there. I will be out of the office to",
401
+ "suggestion_at_0.2": ""
402
+ },
403
+ "Can we schedule a ": {
404
+ "continuation": "meeting to discuss the attached spreadsheet for ",
405
+ "suggestion_at_0.2": "meeting"
406
+ },
407
+ "Sorry for the dela": {
408
+ "continuation": "y. I will be out of the office tomorrow and we w",
409
+ "suggestion_at_0.2": "y."
410
+ },
411
+ "Plese find attached ": {
412
+ "continuation": "the following contract for the Enron Corp. Gas P",
413
+ "suggestion_at_0.2": "the"
414
+ },
415
+ "We need to discuss ": {
416
+ "continuation": "the attached spreadsheet for the following dates",
417
+ "suggestion_at_0.2": "the"
418
+ }
419
+ },
420
+ "held_out_examples": [
421
+ {
422
+ "context": "ou trading a book yet? Playing any poker? Bingham was here f",
423
+ "truth": "or Q-School last week. Didn't go so well.",
424
+ "suggestion_at_0.2": "or"
425
+ },
426
+ {
427
+ "context": "Please use this model. I had a few change",
428
+ "truth": "s after talking to the guys in the west. Thanks!",
429
+ "suggestion_at_0.2": "s to"
430
+ },
431
+ {
432
+ "context": "not open the attachment. Please, send it as a MS Word docume",
433
+ "truth": "nt or a .pdf file.",
434
+ "suggestion_at_0.2": "nt."
435
+ },
436
+ {
437
+ "context": "n I do to help. I would also like to visit on status of e*p ",
438
+ "truth": "group and mariner.",
439
+ "suggestion_at_0.2": ""
440
+ },
441
+ {
442
+ "context": " complete hourly schedule for June 1, including IBM and inco",
443
+ "truth": "rporating all known changes confirmed by that ti",
444
+ "suggestion_at_0.2": "me"
445
+ },
446
+ {
447
+ "context": " delivery rights at SoCal/Topock for at least a pro rata por",
448
+ "truth": "tion of its released capacity.",
449
+ "suggestion_at_0.2": "tion of"
450
+ },
451
+ {
452
+ "context": "i talked to chris a li",
453
+ "truth": "ttle bit and he is going to call back tonight at",
454
+ "suggestion_at_0.2": "ttle"
455
+ },
456
+ {
457
+ "context": " I might be missing something about the way you guys do thes",
458
+ "truth": "e deals in Canada so I thought I would run this ",
459
+ "suggestion_at_0.2": "e"
460
+ },
461
+ {
462
+ "context": "Traci,\n\nThanks. This is the best solution given all the unce",
463
+ "truth": "rtainties we face.",
464
+ "suggestion_at_0.2": "rtainty"
465
+ },
466
+ {
467
+ "context": "\nAquila,s strategy comes as little surprise to Stuebi. As mu",
468
+ "truth": "ch success as the company has had, he said he th",
469
+ "suggestion_at_0.2": "ch"
470
+ },
471
+ {
472
+ "context": "Jim,\n\nWhat about 2 p.m., my time, on Friday, Au",
473
+ "truth": "gust 17. If this works for you, I shall try to i",
474
+ "suggestion_at_0.2": "gust"
475
+ },
476
+ {
477
+ "context": "'s and CDD's for Philedelphia, Allentown Washington DC, Balt",
478
+ "truth": "imore, Pittsburg, Newark, New York City, Cinncin",
479
+ "suggestion_at_0.2": "imore"
480
+ },
481
+ {
482
+ "context": " release.\n\nThe company will offer derivatives to hedge volat",
483
+ "truth": "ile electricity prices.",
484
+ "suggestion_at_0.2": "ility"
485
+ },
486
+ {
487
+ "context": "day, I need access to West deals in Sitara. At a mininum I s",
488
+ "truth": "till need access to all Chicago/Midwest deals al",
489
+ "suggestion_at_0.2": "ent"
490
+ },
491
+ {
492
+ "context": "on, attached for your review is a draft of the First Amendme",
493
+ "truth": "nt to Master Agreement between Enron North Ameri",
494
+ "suggestion_at_0.2": "nt to"
495
+ },
496
+ {
497
+ "context": "I have provided all the info that Tami Scudder ",
498
+ "truth": "requested today.",
499
+ "suggestion_at_0.2": "is"
500
+ },
501
+ {
502
+ "context": "ontana Power Company (MPC) System Border for Thursday and Fr",
503
+ "truth": "iday (July 12, 13) @ $67.85/MWh. Thanks, John",
504
+ "suggestion_at_0.2": "iday"
505
+ },
506
+ {
507
+ "context": "e EnBank contracts physical or financial? It seems to me we ",
508
+ "truth": "should use the same customer profiles - that is ",
509
+ "suggestion_at_0.2": ""
510
+ },
511
+ {
512
+ "context": "I have an appointment at 1:00 today and should be ",
513
+ "truth": "back to the office between 2 - 2:30.",
514
+ "suggestion_at_0.2": ""
515
+ },
516
+ {
517
+ "context": "I'm glad everythi",
518
+ "truth": "ng worked out for you. Hope you have a great tim",
519
+ "suggestion_at_0.2": "ng is"
520
+ },
521
+ {
522
+ "context": "o's Line 1100 from Eunice Station to Pecos River Station wil",
523
+ "truth": "l be out of service for 16 hours Saturday beginn",
524
+ "suggestion_at_0.2": "l be"
525
+ },
526
+ {
527
+ "context": "rty\" in the second line of paragraph 1. Otherwise they look ",
528
+ "truth": "great to me.",
529
+ "suggestion_at_0.2": ""
530
+ },
531
+ {
532
+ "context": "on Houston in the next day or so.\n\nPlease call me if you hav",
533
+ "truth": "e any questions.",
534
+ "suggestion_at_0.2": "e any questions."
535
+ },
536
+ {
537
+ "context": "s not guarantee any continuity of support for you.\n\nPlease, ",
538
+ "truth": "let me know what you decide to do over the next ",
539
+ "suggestion_at_0.2": ""
540
+ },
541
+ {
542
+ "context": " tend to agree with you, but Nora Dobin is the lawyer who kn",
543
+ "truth": "ows the most about the DealBench system - you sh",
544
+ "suggestion_at_0.2": "ows"
545
+ }
546
+ ],
547
+ "total_seconds": 831.5
548
+ }
train.py ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11,<3.14"
3
+ # dependencies = [
4
+ # "torch>=2.6,<2.8",
5
+ # "datasets>=3.0",
6
+ # "numpy<3",
7
+ # "onnx>=1.17",
8
+ # "onnxruntime>=1.20",
9
+ # "huggingface_hub>=0.30",
10
+ # ]
11
+ # ///
12
+ """Train a character-level GRU for in-browser email autocomplete on Enron."""
13
+
14
+ import argparse
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ import time
20
+ from collections import Counter
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ import onnxruntime as ort
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+ from datasets import load_dataset
29
+ from huggingface_hub import HfApi
30
+ from onnxruntime.quantization import QuantType, quantize_dynamic
31
+
32
+ # Vocabulary: PAD, EOS, newline, then printable ASCII.
33
+ PAD, EOS, NEWLINE, FIRST_PRINTABLE = 0, 1, 2, 3
34
+ VOCAB_SIZE = FIRST_PRINTABLE + (127 - 32)
35
+ IGNORE = -100
36
+
37
+ ENCODE = np.full(256, 255, dtype=np.uint8)
38
+ ENCODE[10] = NEWLINE
39
+ ENCODE[32:127] = np.arange(FIRST_PRINTABLE, VOCAB_SIZE, dtype=np.uint8)
40
+ DECODE = {NEWLINE: "\n", **{FIRST_PRINTABLE + i: chr(32 + i) for i in range(95)}}
41
+
42
+ LETTER = np.zeros(VOCAB_SIZE, dtype=bool)
43
+ for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
44
+ LETTER[ENCODE[ord(char)]] = True
45
+
46
+ KEYBOARD_NEIGHBORS = {
47
+ "q": "wa", "w": "qes", "e": "wrd", "r": "etf", "t": "ryg", "y": "tuh", "u": "yij",
48
+ "i": "uok", "o": "ipl", "p": "ol", "a": "qsz", "s": "awdx", "d": "serf", "f": "drgv",
49
+ "g": "fthb", "h": "gyjn", "j": "hukm", "k": "jil", "l": "kop", "z": "asx", "x": "zsc",
50
+ "c": "xdv", "v": "cfb", "b": "vgn", "n": "bhm", "m": "njk",
51
+ }
52
+ NEIGHBOR_TABLE = np.tile(np.arange(VOCAB_SIZE, dtype=np.uint8)[:, None], (1, 4))
53
+ for key, neighbors in KEYBOARD_NEIGHBORS.items():
54
+ for slot in range(4):
55
+ NEIGHBOR_TABLE[ENCODE[ord(key)], slot] = ENCODE[ord(neighbors[slot % len(neighbors)])]
56
+
57
+ CUT_REPLY = re.compile(
58
+ r"(?im)^\s*(?:-+\s*original message|-+\s*forwarded by|-+\s*inline attachment|from:\s|to:\s|sent:\s"
59
+ r"|subject:\s|cc:\s|.*\bwrote:\s*$|_{5,}|-{5,}|={5,}|\*{5,}|\d{1,2}/\d{1,2}/\d{2,4}\s+\d{1,2}:\d{2})"
60
+ )
61
+ SENSITIVE = re.compile(
62
+ r"(?i)https?://|www\.|\S+@\S+|\(?\b\d{3}\)?[-. ]\d{3}[-. ]\d{4}\b|\b\d{3}-\d{2}-\d{4}\b"
63
+ )
64
+ BULK_SENDER = re.compile(r"(?i)announce|newsletter|mailer|outlook\.team|^all[._]|general\.|chairman|enron\.(?:on|net)")
65
+ SMART_CHARS = str.maketrans({"‘": "'", "’": "'", "“": '"', "”": '"', "–": "-", "—": "-", "\t": " "})
66
+
67
+
68
+ def clean_paragraphs(body: str) -> list[str]:
69
+ text = body.replace("\r\n", "\n").replace("=20", " ").replace("=\n", "").translate(SMART_CHARS)
70
+ text = "\n".join(line for line in text.split("\n") if not line.lstrip().startswith(">"))
71
+ cut = CUT_REPLY.search(text)
72
+ if cut:
73
+ text = text[: cut.start()]
74
+ text = re.sub(r"(?m)^-- *\n[\s\S]*", "", text)
75
+
76
+ paragraphs = []
77
+ for block in re.split(r"\n\s*\n", text):
78
+ lines = [re.sub(r" +", " ", line).strip() for line in block.split("\n") if line.strip()]
79
+ if not lines:
80
+ continue
81
+ # Enron bodies are hard-wrapped near 70 columns; only keep breaks after short lines.
82
+ joined = lines[0]
83
+ for previous, line in zip(lines, lines[1:]):
84
+ joined += (" " if len(previous) >= 50 else "\n") + line
85
+ letters = sum(char.isalpha() for char in joined)
86
+ if SENSITIVE.search(joined) or letters < 0.6 * len(joined):
87
+ continue
88
+ paragraphs.append(joined)
89
+ return paragraphs
90
+
91
+
92
+ def load_emails(limit_frac: float) -> dict[str, list[np.ndarray]]:
93
+ dataset = load_dataset("corbt/enron-emails", split="train").select_columns(["from", "body"])
94
+ if limit_frac < 1:
95
+ dataset = dataset.shuffle(seed=0).select(range(int(len(dataset) * limit_frac)))
96
+
97
+ parsed: list[tuple[str, list[str]]] = []
98
+ for row in dataset:
99
+ sender = (row["from"] or "").lower()
100
+ if "@enron.com" not in sender or BULK_SENDER.search(sender):
101
+ continue
102
+ paragraphs = clean_paragraphs(row["body"] or "")
103
+ if paragraphs:
104
+ parsed.append((sender, paragraphs))
105
+
106
+ counts = Counter(p.lower() for _, paragraphs in parsed for p in paragraphs if len(p) >= 60)
107
+
108
+ seen: set[bytes] = set()
109
+ splits: dict[str, list[np.ndarray]] = {"train": [], "val": [], "test": []}
110
+ for sender, paragraphs in parsed:
111
+ text = "\n\n".join(p for p in paragraphs if counts[p.lower()] < 5)
112
+ if not 30 <= len(text) <= 6000:
113
+ continue
114
+ digest = hashlib.sha1(re.sub(r"\s+", " ", text.lower()).encode()).digest()
115
+ if digest in seen:
116
+ continue
117
+ seen.add(digest)
118
+ bucket = int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100
119
+ split = "test" if bucket < 5 else "val" if bucket < 10 else "train"
120
+ ids = ENCODE[np.frombuffer(text.encode("ascii", "ignore"), dtype=np.uint8)]
121
+ splits[split].append(np.append(ids[ids != 255], EOS).astype(np.uint8))
122
+ return splits
123
+
124
+
125
+ def concat_shuffled(emails: list[np.ndarray], rng: np.random.Generator) -> np.ndarray:
126
+ return np.concatenate([emails[i] for i in rng.permutation(len(emails))])
127
+
128
+
129
+ def add_typos(clean: np.ndarray, word_rate: float, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
130
+ """Corrupt the input stream while the targets keep predicting the clean next character."""
131
+ x = clean[:-1].copy()
132
+ y = clean[1:].astype(np.int16)
133
+ is_letter = LETTER[x]
134
+ starts = np.flatnonzero(is_letter[1:] & ~is_letter[:-1]) + 1
135
+ ends = np.flatnonzero(is_letter[:-1] & ~is_letter[1:]) + 1
136
+ if len(ends) and len(starts) and ends[0] <= starts[0]:
137
+ ends = ends[1:]
138
+ count = min(len(starts), len(ends))
139
+ starts, ends = starts[:count], ends[:count]
140
+
141
+ lengths = ends - starts
142
+ chosen = (lengths >= 3) & (rng.random(count) < word_rate)
143
+ word_starts, word_lengths = starts[chosen], lengths[chosen]
144
+ position = word_starts + 1 + (rng.random(len(word_starts)) * (word_lengths - 2)).astype(np.int64)
145
+ position = position[position + 1 < len(x)]
146
+ ops = rng.integers(0, 4, size=len(position))
147
+ neighbor = NEIGHBOR_TABLE[x[position], rng.integers(0, 4, size=len(position))]
148
+
149
+ substitute, swap, insert, delete = (position[ops == op] for op in range(4))
150
+ x[substitute] = neighbor[ops == 0]
151
+ first, second = x[swap].copy(), x[swap + 1].copy()
152
+ x[swap], x[swap + 1], y[swap] = second, first, IGNORE
153
+
154
+ repeats = np.ones(len(x), dtype=np.int64)
155
+ repeats[delete] = 0
156
+ repeats[insert] = 2
157
+ inserted_at = np.cumsum(repeats)[insert] - 1
158
+ x_out, y_out = np.repeat(x, repeats), np.repeat(y, repeats)
159
+ x_out[inserted_at] = neighbor[ops == 2]
160
+ return x_out, y_out
161
+
162
+
163
+ def make_batches(x, y, batch_size, seq_len, rng):
164
+ offset = int(rng.integers(seq_len))
165
+ starts = rng.permutation(np.arange(offset, len(x) - seq_len, seq_len))
166
+ span = np.arange(seq_len)
167
+ for index in range(0, len(starts) - batch_size + 1, batch_size):
168
+ positions = starts[index : index + batch_size][:, None] + span
169
+ yield torch.from_numpy(x[positions].astype(np.int64)), torch.from_numpy(y[positions].astype(np.int64))
170
+
171
+
172
+ class CharGRU(nn.Module):
173
+ def __init__(self, hidden: int, embedding: int = 64):
174
+ super().__init__()
175
+ self.embedding = nn.Embedding(VOCAB_SIZE, embedding, padding_idx=PAD)
176
+ self.gru = nn.GRU(embedding, hidden, batch_first=True)
177
+ self.head = nn.Linear(hidden, VOCAB_SIZE)
178
+
179
+ def forward(self, ids, state=None):
180
+ output, state = self.gru(self.embedding(ids), state)
181
+ return self.head(output), state
182
+
183
+
184
+ def sequence_loss(logits, targets, burn_in):
185
+ return F.cross_entropy(logits[:, burn_in:].flatten(0, 1), targets[:, burn_in:].flatten(), ignore_index=IGNORE)
186
+
187
+
188
+ @torch.no_grad()
189
+ def evaluate_loss(model, emails, args, device, max_chars=5_000_000):
190
+ rng = np.random.default_rng(1)
191
+ clean = concat_shuffled(emails, rng)[:max_chars]
192
+ x, y = clean[:-1], clean[1:].astype(np.int16)
193
+ total, batches = 0.0, 0
194
+ for inputs, targets in make_batches(x, y, args.batch, args.seq, rng):
195
+ logits, _ = model(inputs.to(device))
196
+ total += sequence_loss(logits, targets.to(device), args.burn_in).item()
197
+ batches += 1
198
+ return total / max(batches, 1)
199
+
200
+
201
+ def decode(ids) -> str:
202
+ return "".join(DECODE.get(int(i), "") for i in ids)
203
+
204
+
205
+ def sample_positions(emails, count, context, horizon, rng):
206
+ contexts, truths = [], []
207
+ attempts = 0
208
+ while len(contexts) < count and attempts < count * 200:
209
+ attempts += 1
210
+ email = emails[int(rng.integers(len(emails)))]
211
+ if len(email) < 64:
212
+ continue
213
+ cursor = int(rng.integers(16, len(email) - 16))
214
+ previous, following = int(email[cursor - 1]), int(email[cursor])
215
+ if not (LETTER[previous] or previous == ENCODE[32]) or not LETTER[following]:
216
+ continue
217
+ window = np.full(context, EOS, dtype=np.int64)
218
+ prefix = email[max(0, cursor - context) : cursor]
219
+ window[-len(prefix):] = prefix
220
+ truth = email[cursor : cursor + horizon]
221
+ ending = np.flatnonzero((truth == EOS) | (truth == NEWLINE))
222
+ truths.append(decode(truth[: ending[0]] if len(ending) else truth))
223
+ contexts.append(window)
224
+ return np.stack(contexts), truths
225
+
226
+
227
+ def build_suggestion(ids, probabilities, threshold, max_words=6):
228
+ text, confidence, best, words = "", 1.0, "", 0
229
+ for token, probability in zip(ids, probabilities):
230
+ token = int(token)
231
+ if token in (EOS, NEWLINE, PAD):
232
+ break
233
+ char = DECODE[token]
234
+ if char == " ":
235
+ if confidence >= threshold:
236
+ best = text
237
+ words += 1
238
+ if words >= max_words:
239
+ break
240
+ confidence *= float(probability)
241
+ if confidence < threshold:
242
+ break
243
+ text += char
244
+ if char in ".,!?":
245
+ best = text
246
+ return best
247
+
248
+
249
+ def score_suggestions(generated, probabilities, truths, thresholds):
250
+ report = {}
251
+ for threshold in thresholds:
252
+ shown, correct, saved = 0, 0, 0
253
+ for ids, probs, truth in zip(generated, probabilities, truths):
254
+ suggestion = build_suggestion(ids, probs, threshold)
255
+ if len(suggestion) < 4:
256
+ continue
257
+ shown += 1
258
+ if truth.startswith(suggestion):
259
+ correct += 1
260
+ saved += len(suggestion)
261
+ report[str(threshold)] = {
262
+ "coverage": round(shown / len(truths), 4),
263
+ "precision": round(correct / shown, 4) if shown else None,
264
+ "avg_chars_when_correct": round(saved / correct, 2) if correct else None,
265
+ "chars_saved_per_position": round(saved / len(truths), 3),
266
+ }
267
+ return report
268
+
269
+
270
+ @torch.no_grad()
271
+ def greedy_torch(model, contexts, steps, device, chunk=2048):
272
+ all_ids, all_probs = [], []
273
+ for index in range(0, len(contexts), chunk):
274
+ ids = torch.from_numpy(contexts[index : index + chunk]).to(device)
275
+ logits, state = model(ids)
276
+ step_logits = logits[:, -1]
277
+ batch_ids, batch_probs = [], []
278
+ for _ in range(steps):
279
+ probabilities = step_logits.softmax(-1)
280
+ confidence, token = probabilities.max(-1)
281
+ batch_ids.append(token.cpu().numpy())
282
+ batch_probs.append(confidence.cpu().numpy())
283
+ logits, state = model(token[:, None], state)
284
+ step_logits = logits[:, -1]
285
+ all_ids.append(np.stack(batch_ids, 1))
286
+ all_probs.append(np.stack(batch_probs, 1))
287
+ return np.concatenate(all_ids), np.concatenate(all_probs)
288
+
289
+
290
+ def greedy_onnx(session, contexts, steps, hidden):
291
+ state = np.zeros((len(contexts), hidden), dtype=np.float32)
292
+ logits = None
293
+ for column in range(contexts.shape[1]):
294
+ logits, state = session.run(None, {"input_id": contexts[:, column], "state": state})
295
+ ids, confidences = [], []
296
+ for _ in range(steps):
297
+ probabilities = np.exp(logits - logits.max(-1, keepdims=True))
298
+ probabilities /= probabilities.sum(-1, keepdims=True)
299
+ token = probabilities.argmax(-1)
300
+ ids.append(token)
301
+ confidences.append(probabilities[np.arange(len(token)), token])
302
+ logits, state = session.run(None, {"input_id": token.astype(np.int64), "state": state})
303
+ return np.stack(ids, 1), np.stack(confidences, 1)
304
+
305
+
306
+ class MatMulLinear(nn.Module):
307
+ """Linear layer that exports as MatMul so ONNX Runtime dynamic quantization applies."""
308
+
309
+ def __init__(self, weight: torch.Tensor, bias: torch.Tensor):
310
+ super().__init__()
311
+ self.weight = nn.Parameter(weight.t().clone())
312
+ self.bias = nn.Parameter(bias.clone())
313
+
314
+ def forward(self, inputs):
315
+ return inputs.matmul(self.weight) + self.bias
316
+
317
+
318
+ class SingleStep(nn.Module):
319
+ """One-character GRU update with weights copied from the trained nn.GRU."""
320
+
321
+ def __init__(self, model: CharGRU, hidden: int):
322
+ super().__init__()
323
+ self.embedding = model.embedding
324
+ input_weight, hidden_weight = model.gru.weight_ih_l0.detach(), model.gru.weight_hh_l0.detach()
325
+ input_bias, hidden_bias = model.gru.bias_ih_l0.detach(), model.gru.bias_hh_l0.detach()
326
+ layers = []
327
+ for gate in range(3):
328
+ row = slice(gate * hidden, (gate + 1) * hidden)
329
+ layers.append(MatMulLinear(input_weight[row], input_bias[row]))
330
+ layers.append(MatMulLinear(hidden_weight[row], hidden_bias[row]))
331
+ self.gates = nn.ModuleList(layers)
332
+ self.head = MatMulLinear(model.head.weight.detach(), model.head.bias.detach())
333
+
334
+ def forward(self, input_id, state):
335
+ embedded = self.embedding(input_id)
336
+ reset = torch.sigmoid(self.gates[0](embedded) + self.gates[1](state))
337
+ update = torch.sigmoid(self.gates[2](embedded) + self.gates[3](state))
338
+ proposal = torch.tanh(self.gates[4](embedded) + reset * self.gates[5](state))
339
+ next_state = (1 - update) * proposal + update * state
340
+ return self.head(next_state), next_state
341
+
342
+
343
+ def verify_single_step(model, step, hidden):
344
+ ids = torch.randint(3, VOCAB_SIZE, (4, 32))
345
+ logits, _ = model(ids)
346
+ state = torch.zeros(4, hidden)
347
+ outputs = []
348
+ for column in range(ids.shape[1]):
349
+ step_logits, state = step(ids[:, column], state)
350
+ outputs.append(step_logits)
351
+ difference = (torch.stack(outputs, 1) - logits).abs().max().item()
352
+ if difference > 1e-3:
353
+ raise RuntimeError(f"Exported single-step GRU does not match nn.GRU: max difference {difference}")
354
+ return difference
355
+
356
+
357
+ def measure_latency(path, hidden, runs=3000):
358
+ options = ort.SessionOptions()
359
+ options.intra_op_num_threads = 1
360
+ session = ort.InferenceSession(str(path), options, providers=["CPUExecutionProvider"])
361
+ state = np.zeros((1, hidden), dtype=np.float32)
362
+ timings = []
363
+ for run in range(runs):
364
+ token = np.array([FIRST_PRINTABLE + run % 90], dtype=np.int64)
365
+ started = time.perf_counter()
366
+ _, state = session.run(None, {"input_id": token, "state": state})
367
+ timings.append((time.perf_counter() - started) * 1000)
368
+ return {"p50_ms": round(float(np.percentile(timings, 50)), 3), "p95_ms": round(float(np.percentile(timings, 95)), 3)}
369
+
370
+
371
+ def export_and_score(model, args, output, contexts, truths, thresholds):
372
+ model = model.cpu().eval()
373
+ step = SingleStep(model, args.hidden).eval()
374
+ difference = verify_single_step(model, step, args.hidden)
375
+ print(f"single-step GRU matches nn.GRU (max difference {difference:.2e})", flush=True)
376
+
377
+ float_path = output / "model.onnx"
378
+ torch.onnx.export(
379
+ step,
380
+ (torch.zeros(1, dtype=torch.long), torch.zeros(1, args.hidden)),
381
+ float_path,
382
+ input_names=["input_id", "state"],
383
+ output_names=["logits", "next_state"],
384
+ dynamic_axes={"input_id": {0: "batch"}, "state": {0: "batch"}, "logits": {0: "batch"}, "next_state": {0: "batch"}},
385
+ opset_version=17,
386
+ dynamo=False,
387
+ )
388
+ quantized_path = output / "model_quint8.onnx"
389
+ quantize_dynamic(float_path, quantized_path, weight_type=QuantType.QUInt8, op_types_to_quantize=["MatMul"])
390
+
391
+ subset = min(1000, len(contexts))
392
+ result = {}
393
+ for name, path in (("float", float_path), ("quint8", quantized_path)):
394
+ session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
395
+ ids, probabilities = greedy_onnx(session, contexts[:subset], args.horizon, args.hidden)
396
+ result[name] = {
397
+ "size_mb": round(path.stat().st_size / 1e6, 2),
398
+ "native_cpu_single_thread_step": measure_latency(path, args.hidden),
399
+ "suggestions_1000_positions": score_suggestions(ids, probabilities, truths[:subset], thresholds),
400
+ }
401
+ return result
402
+
403
+
404
+ def show_samples(model, device):
405
+ prompts = [
406
+ "Hi John,\n\nThanks for ", "Please let me know if ", "I will send you ", "Let me kn", "Thnaks for the ",
407
+ "I look forward to ", "Can we schedule a ", "Sorry for the dela", "Plese find attached ", "We need to discuss ",
408
+ ]
409
+ samples = {}
410
+ for prompt in prompts:
411
+ window = np.full(128, EOS, dtype=np.int64)
412
+ encoded = ENCODE[np.frombuffer(prompt.encode("ascii"), dtype=np.uint8)]
413
+ window[-len(encoded):] = encoded
414
+ ids, probabilities = greedy_torch(model, window[None], 48, device)
415
+ samples[prompt] = {
416
+ "continuation": decode(ids[0]),
417
+ "suggestion_at_0.2": build_suggestion(ids[0], probabilities[0], 0.2),
418
+ }
419
+ print(f"{prompt!r} -> {samples[prompt]}", flush=True)
420
+ return samples
421
+
422
+
423
+ def parse_args():
424
+ parser = argparse.ArgumentParser()
425
+ parser.add_argument("--hidden", type=int, default=512)
426
+ parser.add_argument("--epochs", type=int, default=30)
427
+ parser.add_argument("--batch", type=int, default=256)
428
+ parser.add_argument("--seq", type=int, default=256)
429
+ parser.add_argument("--burn-in", type=int, default=32)
430
+ parser.add_argument("--lr", type=float, default=1.5e-3)
431
+ parser.add_argument("--typo-rate", type=float, default=0.08)
432
+ parser.add_argument("--limit-frac", type=float, default=1.0)
433
+ parser.add_argument("--eval-positions", type=int, default=5000)
434
+ parser.add_argument("--context", type=int, default=128)
435
+ parser.add_argument("--horizon", type=int, default=48)
436
+ parser.add_argument("--repo", default="cfahlgren1/enron-autocomplete")
437
+ parser.add_argument("--no-push", action="store_true")
438
+ return parser.parse_args()
439
+
440
+
441
+ def main():
442
+ args = parse_args()
443
+ torch.manual_seed(0)
444
+ rng = np.random.default_rng(0)
445
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
446
+ print(f"device={device} {torch.cuda.get_device_name(0) if device.type == 'cuda' else ''} args={vars(args)}", flush=True)
447
+ output = Path("outputs")
448
+ output.mkdir(parents=True, exist_ok=True)
449
+
450
+ started = time.time()
451
+ splits = load_emails(args.limit_frac)
452
+ data_stats = {name: {"emails": len(e), "chars": int(sum(map(len, e)))} for name, e in splits.items()}
453
+ print(f"data ready in {time.time() - started:.0f}s: {data_stats}", flush=True)
454
+ if not splits["train"] or not splits["val"] or not splits["test"]:
455
+ raise RuntimeError(f"A split is empty: {data_stats}")
456
+
457
+ model = CharGRU(args.hidden).to(device)
458
+ parameters = sum(p.numel() for p in model.parameters())
459
+ print(f"parameters={parameters:,}", flush=True)
460
+
461
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-5)
462
+ steps_per_epoch = max(data_stats["train"]["chars"] // (args.batch * args.seq), 1)
463
+ total_steps = args.epochs * steps_per_epoch
464
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
465
+ optimizer, lambda step: 0.02 + 0.98 * 0.5 * (1 + np.cos(np.pi * min(step / total_steps, 1)))
466
+ )
467
+
468
+ history = []
469
+ best_loss = float("inf")
470
+ best_path = output / "best.pt"
471
+ for epoch in range(args.epochs):
472
+ model.train()
473
+ epoch_started = time.time()
474
+ x, y = add_typos(concat_shuffled(splits["train"], rng), args.typo_rate, rng)
475
+ running, batches, chars = 0.0, 0, 0
476
+ for inputs, targets in make_batches(x, y, args.batch, args.seq, rng):
477
+ logits, _ = model(inputs.to(device, non_blocking=True))
478
+ loss = sequence_loss(logits, targets.to(device, non_blocking=True), args.burn_in)
479
+ optimizer.zero_grad(set_to_none=True)
480
+ loss.backward()
481
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
482
+ optimizer.step()
483
+ scheduler.step()
484
+ running += loss.item()
485
+ batches += 1
486
+ chars += inputs.numel()
487
+ if batches == 0:
488
+ raise RuntimeError("No training batches; reduce --batch or --seq for this data size")
489
+
490
+ model.eval()
491
+ val_loss = evaluate_loss(model, splits["val"], args, device)
492
+ seconds = time.time() - epoch_started
493
+ record = {
494
+ "epoch": epoch + 1,
495
+ "train_loss": round(running / batches, 4),
496
+ "val_loss": round(val_loss, 4),
497
+ "seconds": round(seconds, 1),
498
+ "chars_per_sec": int(chars / seconds),
499
+ }
500
+ history.append(record)
501
+ print(json.dumps(record), flush=True)
502
+ if val_loss < best_loss:
503
+ best_loss = val_loss
504
+ torch.save({"model": model.state_dict(), "args": vars(args)}, best_path)
505
+
506
+ model.load_state_dict(torch.load(best_path, map_location=device)["model"])
507
+ model.eval()
508
+
509
+ thresholds = [0.05, 0.1, 0.2, 0.35, 0.5, 0.7]
510
+ eval_rng = np.random.default_rng(42)
511
+ contexts, truths = sample_positions(splits["test"], args.eval_positions, args.context, args.horizon, eval_rng)
512
+ ids, probabilities = greedy_torch(model, contexts, args.horizon, device)
513
+ suggestions = score_suggestions(ids, probabilities, truths, thresholds)
514
+ print(json.dumps({"held_out_sender_suggestions": suggestions}, indent=1), flush=True)
515
+
516
+ examples = []
517
+ for index in range(min(25, len(contexts))):
518
+ examples.append({
519
+ "context": decode(contexts[index])[-60:],
520
+ "truth": truths[index],
521
+ "suggestion_at_0.2": build_suggestion(ids[index], probabilities[index], 0.2),
522
+ })
523
+ samples = show_samples(model, device)
524
+ exported = export_and_score(model, args, output, contexts, truths, thresholds)
525
+ print(json.dumps(exported, indent=1), flush=True)
526
+
527
+ report = {
528
+ "args": vars(args),
529
+ "device": str(device),
530
+ "parameters": parameters,
531
+ "data": data_stats,
532
+ "history": history,
533
+ "best_val_loss": round(best_loss, 4),
534
+ "test_positions": len(contexts),
535
+ "held_out_sender_suggestions": suggestions,
536
+ "exports": exported,
537
+ "prompt_samples": samples,
538
+ "held_out_examples": examples,
539
+ "total_seconds": round(time.time() - started, 1),
540
+ }
541
+ (output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
542
+ (output / "vocab.json").write_text(json.dumps({
543
+ "pad": PAD, "eos": EOS, "newline": NEWLINE, "first_printable_id": FIRST_PRINTABLE,
544
+ "first_printable_codepoint": 32, "size": VOCAB_SIZE, "hidden": args.hidden,
545
+ }, indent=2) + "\n")
546
+
547
+ if args.no_push:
548
+ print(f"Skipping upload; artifacts in {output}", flush=True)
549
+ return
550
+ api = HfApi(token=os.environ["HF_TOKEN"])
551
+ api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True)
552
+ api.upload_folder(folder_path=str(output), repo_id=args.repo, repo_type="model")
553
+ print(f"Uploaded to https://huggingface.co/{args.repo}", flush=True)
554
+
555
+
556
+ if __name__ == "__main__":
557
+ main()
vocab.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pad": 0,
3
+ "eos": 1,
4
+ "newline": 2,
5
+ "first_printable_id": 3,
6
+ "first_printable_codepoint": 32,
7
+ "size": 98,
8
+ "hidden": 512
9
+ }