cdotsanghvi commited on
Commit
083b138
·
1 Parent(s): b3112c7

add multi-head demo as 4th-6th tabs; restore Why Liquid + Integration

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Transaction Co-PilotDispute · Collections · Fraud
3
  emoji: 🪙
4
  colorFrom: gray
5
  colorTo: indigo
@@ -9,7 +9,7 @@ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # Transaction Co-Pilot — One Backbone, Three Surfaces
13
 
14
  Reference implementation of the multimodal-encoder recipe applied to
15
  discrete-feature transaction sequences, demonstrating cross-surface
@@ -22,8 +22,14 @@ per-surface LoRA adapts the attention layers.
22
 
23
  ## What this demo shows
24
 
25
- Three surfaces, three independent predictions on the same backbone:
26
 
 
 
 
 
 
 
27
  - **Dispute Co-Pilot** — given a customer's transaction history + a
28
  dispute complaint, classifies the dispute as likely / ambiguous /
29
  unlikely friendly fraud, with per-transaction attribution.
@@ -34,9 +40,10 @@ Three surfaces, three independent predictions on the same backbone:
34
  classifies the attack stage (5-way) AND the underlying type (4-way)
35
  in two independent categorical outputs.
36
 
37
- Each surface ships its own slim checkpoint (~24 MB), but all three
38
- share the LFM2.5-350M base weights the demonstration that the
39
- backbone is the product, and per-surface plumbing rides on top.
 
40
 
41
  ## How it works
42
 
@@ -70,6 +77,7 @@ cited as performance on real payment data.
70
 
71
  ## Cast performance (held-out)
72
 
 
73
  - Dispute v7 (step 2000): 5/6 cast band-match, 6/6 attribution top-5
74
  - Collections v3 (step 4500): 5/6 cast band-match
75
  - Fraud v1 (step 3000): 6/6 stage AND type at 1.00 confidence
 
1
  ---
2
+ title: Transaction EncoderLFM2.5 multi-head + Co-Pilot surfaces
3
  emoji: 🪙
4
  colorFrom: gray
5
  colorTo: indigo
 
9
  pinned: false
10
  ---
11
 
12
+ # Transaction Encoder — One Backbone, Many Surfaces
13
 
14
  Reference implementation of the multimodal-encoder recipe applied to
15
  discrete-feature transaction sequences, demonstrating cross-surface
 
22
 
23
  ## What this demo shows
24
 
25
+ Six tabs over one shared LFM2.5-350M backbone:
26
 
27
+ - **Multi-Head Demo** — the original 4-task-head encoder demo. Live
28
+ inference on curated customer archetypes: fraud probability,
29
+ next-merchant prediction, amount-bucket prediction, MCC prediction.
30
+ - **Why Liquid** — architectural pitch for the encoder-on-frozen-backbone
31
+ recipe.
32
+ - **Integration** — build-it-yourself guide.
33
  - **Dispute Co-Pilot** — given a customer's transaction history + a
34
  dispute complaint, classifies the dispute as likely / ambiguous /
35
  unlikely friendly fraud, with per-transaction attribution.
 
40
  classifies the attack stage (5-way) AND the underlying type (4-way)
41
  in two independent categorical outputs.
42
 
43
+ Each Co-Pilot surface ships its own slim checkpoint (~24 MB) alongside
44
+ the original multi-head checkpoint (~24 MB), but all four share the
45
+ LFM2.5-350M base weights the demonstration that the backbone is the
46
+ product, and per-surface plumbing rides on top.
47
 
48
  ## How it works
49
 
 
77
 
78
  ## Cast performance (held-out)
79
 
80
+ - Multi-Head V3 (step 4999): per-head accuracy reported on the test split.
81
  - Dispute v7 (step 2000): 5/6 cast band-match, 6/6 attribution top-5
82
  - Collections v3 (step 4500): 5/6 cast band-match
83
  - Fraud v1 (step 3000): 6/6 stage AND type at 1.00 confidence
app.py CHANGED
@@ -1,8 +1,16 @@
1
- """Hugging Face Spaces entrypoint for the unified Transaction Co-Pilot.
2
 
3
  Wrapper that invokes encoder.src.demo.copilot_app_unified.main with
4
  paths relative to the Space repo root. The full app code lives at
5
  encoder/src/demo/copilot_app_unified.py.
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import sys
@@ -15,18 +23,25 @@ from encoder.src.demo.copilot_app_unified import main
15
 
16
  sys.argv = [
17
  "app",
 
 
 
 
 
18
  "--dispute-checkpoint", "checkpoints/dispute_legitimacy_v7.pt",
19
  "--dispute-config", "encoder/configs/model_dispute_legitimacy.yaml",
 
20
  "--collections-checkpoint", "checkpoints/collections_v3.pt",
21
  "--collections-config", "encoder/configs/model_collections.yaml",
 
22
  "--fraud-checkpoint", "checkpoints/fraud_pattern_v1.pt",
23
  "--fraud-config", "encoder/configs/model_fraud_pattern.yaml",
24
- "--schema", "data/schema.yaml",
25
- "--histories", "data/synthetic/token_ids.npy",
26
- "--dispute-cast", "encoder/data/demo_cast.json",
27
- "--collections-cast", "encoder/data/collections_cast.json",
28
  "--fraud-cast", "encoder/data/fraud_pattern_cast.json",
 
 
 
29
  "--device", "cpu",
 
30
  "--port", "7860",
31
  ]
32
 
 
1
+ """Hugging Face Spaces entrypoint for the unified Transaction Encoder demo.
2
 
3
  Wrapper that invokes encoder.src.demo.copilot_app_unified.main with
4
  paths relative to the Space repo root. The full app code lives at
5
  encoder/src/demo/copilot_app_unified.py.
6
+
7
+ Six tabs:
8
+ - Multi-Head Demo (original V3 encoder + 4 task heads)
9
+ - Why Liquid (architectural pitch)
10
+ - Integration (build-it-yourself guide)
11
+ - Dispute Co-Pilot (friendly-fraud classifier + attribution)
12
+ - Collections Co-Pilot (treatment-response scoreboard)
13
+ - Fraud Co-Pilot (pattern stage × type classifier)
14
  """
15
 
16
  import sys
 
23
 
24
  sys.argv = [
25
  "app",
26
+ # Multi-head (original) demo
27
+ "--multihead-checkpoint", "checkpoints/multihead_v3.pt",
28
+ "--multihead-config", "encoder/configs/model_nocompress.yaml",
29
+ "--multihead-data-dir", "data/synthetic",
30
+ # Co-Pilot surfaces
31
  "--dispute-checkpoint", "checkpoints/dispute_legitimacy_v7.pt",
32
  "--dispute-config", "encoder/configs/model_dispute_legitimacy.yaml",
33
+ "--dispute-cast", "encoder/data/demo_cast.json",
34
  "--collections-checkpoint", "checkpoints/collections_v3.pt",
35
  "--collections-config", "encoder/configs/model_collections.yaml",
36
+ "--collections-cast", "encoder/data/collections_cast.json",
37
  "--fraud-checkpoint", "checkpoints/fraud_pattern_v1.pt",
38
  "--fraud-config", "encoder/configs/model_fraud_pattern.yaml",
 
 
 
 
39
  "--fraud-cast", "encoder/data/fraud_pattern_cast.json",
40
+ # Shared
41
+ "--schema", "data/schema.yaml",
42
+ "--cast-histories", "data/synthetic/cast_token_ids.npy",
43
  "--device", "cpu",
44
+ "--dtype", "float32",
45
  "--port", "7860",
46
  ]
47
 
checkpoints/multihead_v3.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0385a423ae688ae0b0d6f672bb4230f47d1808462fecb29bd619985070007dfe
3
+ size 188887205
data/synthetic/cast_token_ids.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:96fa931533be3b36bce7b6c7db369a9bdd6d39f06373cead7138efa0bd186f57
3
+ size 34688
data/synthetic/sequence_labels.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:25ac01df0191c2090f1fec782b46a93b3560dd2d42215c929bc18f44519cc443
3
+ size 20128
data/synthetic/split_indices.npz CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9402025636780e3fa0a2ca086a2756fe2f7bb67befcae10e2eca4ffcede91987
3
- size 886
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fab90d8398882dfac2d9660de977b111732deb31ad925f01d29073324b71fa96
3
+ size 160742
data/synthetic/token_ids.npy CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:96fa931533be3b36bce7b6c7db369a9bdd6d39f06373cead7138efa0bd186f57
3
- size 34688
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a1c06df0488e17ebec73db4584b5fc49bcb6e02bfc2aba0f49a561443d036cf0
3
+ size 38400128
data/synthetic/tokenizer_state.json ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "features": [
3
+ {
4
+ "name": "hour",
5
+ "num_values": 24,
6
+ "type": "categorical",
7
+ "vocab_size": 27
8
+ },
9
+ {
10
+ "name": "dow",
11
+ "num_values": 7,
12
+ "type": "categorical",
13
+ "vocab_size": 10
14
+ },
15
+ {
16
+ "boundaries": [
17
+ 0.0,
18
+ 12.166666666666666,
19
+ 24.333333333333332,
20
+ 36.5,
21
+ 48.666666666666664,
22
+ 60.83333333333333,
23
+ 73.0,
24
+ 85.16666666666666,
25
+ 97.33333333333333,
26
+ 109.5,
27
+ 121.66666666666666,
28
+ 133.83333333333331,
29
+ 146.0,
30
+ 158.16666666666666,
31
+ 170.33333333333331,
32
+ 182.5,
33
+ 194.66666666666666,
34
+ 206.83333333333331,
35
+ 219.0,
36
+ 231.16666666666666,
37
+ 243.33333333333331,
38
+ 255.5,
39
+ 267.66666666666663,
40
+ 279.8333333333333,
41
+ 292.0,
42
+ 304.16666666666663,
43
+ 316.3333333333333,
44
+ 328.5,
45
+ 340.66666666666663,
46
+ 352.8333333333333,
47
+ 365.0
48
+ ],
49
+ "name": "days_since_last",
50
+ "num_values": 30,
51
+ "type": "bucketed",
52
+ "vocab_size": 33
53
+ },
54
+ {
55
+ "name": "is_recurring",
56
+ "num_values": 2,
57
+ "type": "binary",
58
+ "vocab_size": 5
59
+ },
60
+ {
61
+ "name": "mcc",
62
+ "num_values": 100,
63
+ "type": "categorical",
64
+ "vocab_size": 103
65
+ },
66
+ {
67
+ "name": "merchant_id",
68
+ "num_values": 10000,
69
+ "type": "categorical",
70
+ "vocab_size": 10003
71
+ },
72
+ {
73
+ "boundaries": [
74
+ 0.0,
75
+ 25.0,
76
+ 50.0,
77
+ 75.0,
78
+ 100.0,
79
+ 125.0,
80
+ 150.0,
81
+ 175.0,
82
+ 200.0,
83
+ 225.0,
84
+ 250.0,
85
+ 275.0,
86
+ 300.0,
87
+ 325.0,
88
+ 350.0,
89
+ 375.0,
90
+ 400.0,
91
+ 425.0,
92
+ 450.0,
93
+ 475.0,
94
+ 500.0
95
+ ],
96
+ "name": "customer_merchant_count",
97
+ "num_values": 20,
98
+ "type": "bucketed",
99
+ "vocab_size": 23
100
+ },
101
+ {
102
+ "name": "entry_mode",
103
+ "num_values": 5,
104
+ "type": "categorical",
105
+ "vocab_size": 8
106
+ },
107
+ {
108
+ "boundaries": [
109
+ 0.01,
110
+ 97.66621093750001,
111
+ 195.322421875,
112
+ 292.9786328125,
113
+ 390.63484375,
114
+ 488.29105468750004,
115
+ 585.947265625,
116
+ 683.6034765625001,
117
+ 781.2596875,
118
+ 878.9158984375,
119
+ 976.5721093750001,
120
+ 1074.2283203125,
121
+ 1171.88453125,
122
+ 1269.5407421875,
123
+ 1367.1969531250002,
124
+ 1464.8531640625001,
125
+ 1562.509375,
126
+ 1660.1655859375,
127
+ 1757.821796875,
128
+ 1855.4780078125002,
129
+ 1953.1342187500002,
130
+ 2050.7904296875004,
131
+ 2148.4466406250003,
132
+ 2246.1028515625003,
133
+ 2343.7590625000003,
134
+ 2441.4152734375,
135
+ 2539.071484375,
136
+ 2636.7276953125006,
137
+ 2734.3839062500006,
138
+ 2832.0401171875005,
139
+ 2929.6963281250005,
140
+ 3027.3525390625005,
141
+ 3125.0087500000004,
142
+ 3222.6649609375004,
143
+ 3320.3211718750003,
144
+ 3417.9773828125003,
145
+ 3515.6335937500003,
146
+ 3613.2898046875002,
147
+ 3710.9460156250007,
148
+ 3808.6022265625006,
149
+ 3906.2584375000006,
150
+ 4003.9146484375005,
151
+ 4101.5708593750005,
152
+ 4199.2270703125005,
153
+ 4296.88328125,
154
+ 4394.5394921875,
155
+ 4492.195703125,
156
+ 4589.8519140625,
157
+ 4687.508125,
158
+ 4785.1643359375,
159
+ 4882.820546875,
160
+ 4980.4767578125,
161
+ 5078.13296875,
162
+ 5175.7891796875,
163
+ 5273.445390625001,
164
+ 5371.101601562501,
165
+ 5468.757812500001,
166
+ 5566.414023437501,
167
+ 5664.070234375001,
168
+ 5761.726445312501,
169
+ 5859.382656250001,
170
+ 5957.038867187501,
171
+ 6054.695078125001,
172
+ 6152.351289062501,
173
+ 6250.007500000001,
174
+ 6347.663710937501,
175
+ 6445.319921875001,
176
+ 6542.9761328125005,
177
+ 6640.6323437500005,
178
+ 6738.2885546875,
179
+ 6835.944765625,
180
+ 6933.6009765625,
181
+ 7031.2571875,
182
+ 7128.9133984375,
183
+ 7226.569609375,
184
+ 7324.225820312501,
185
+ 7421.882031250001,
186
+ 7519.538242187501,
187
+ 7617.194453125001,
188
+ 7714.850664062501,
189
+ 7812.506875000001,
190
+ 7910.163085937501,
191
+ 8007.819296875001,
192
+ 8105.475507812501,
193
+ 8203.13171875,
194
+ 8300.7879296875,
195
+ 8398.444140625,
196
+ 8496.100351562502,
197
+ 8593.7565625,
198
+ 8691.412773437502,
199
+ 8789.068984375,
200
+ 8886.725195312501,
201
+ 8984.38140625,
202
+ 9082.037617187501,
203
+ 9179.693828125,
204
+ 9277.350039062501,
205
+ 9375.00625,
206
+ 9472.662460937501,
207
+ 9570.318671875,
208
+ 9667.974882812501,
209
+ 9765.63109375,
210
+ 9863.287304687501,
211
+ 9960.943515625,
212
+ 10058.599726562501,
213
+ 10156.2559375,
214
+ 10253.912148437501,
215
+ 10351.568359375,
216
+ 10449.2245703125,
217
+ 10546.880781250002,
218
+ 10644.5369921875,
219
+ 10742.193203125002,
220
+ 10839.8494140625,
221
+ 10937.505625000002,
222
+ 11035.1618359375,
223
+ 11132.818046875002,
224
+ 11230.4742578125,
225
+ 11328.130468750001,
226
+ 11425.7866796875,
227
+ 11523.442890625001,
228
+ 11621.0991015625,
229
+ 11718.755312500001,
230
+ 11816.4115234375,
231
+ 11914.067734375001,
232
+ 12011.7239453125,
233
+ 12109.380156250001,
234
+ 12207.0363671875,
235
+ 12304.692578125001,
236
+ 12402.3487890625,
237
+ 12500.005000000001,
238
+ 12597.661210937502,
239
+ 12695.317421875001,
240
+ 12792.973632812502,
241
+ 12890.62984375,
242
+ 12988.286054687502,
243
+ 13085.942265625,
244
+ 13183.598476562502,
245
+ 13281.2546875,
246
+ 13378.910898437502,
247
+ 13476.567109375,
248
+ 13574.223320312502,
249
+ 13671.87953125,
250
+ 13769.535742187501,
251
+ 13867.191953125,
252
+ 13964.848164062501,
253
+ 14062.504375,
254
+ 14160.160585937501,
255
+ 14257.816796875,
256
+ 14355.473007812501,
257
+ 14453.12921875,
258
+ 14550.785429687501,
259
+ 14648.441640625002,
260
+ 14746.097851562501,
261
+ 14843.754062500002,
262
+ 14941.410273437501,
263
+ 15039.066484375002,
264
+ 15136.722695312501,
265
+ 15234.378906250002,
266
+ 15332.0351171875,
267
+ 15429.691328125002,
268
+ 15527.3475390625,
269
+ 15625.003750000002,
270
+ 15722.6599609375,
271
+ 15820.316171875002,
272
+ 15917.9723828125,
273
+ 16015.628593750002,
274
+ 16113.2848046875,
275
+ 16210.941015625001,
276
+ 16308.5972265625,
277
+ 16406.2534375,
278
+ 16503.9096484375,
279
+ 16601.565859374998,
280
+ 16699.2220703125,
281
+ 16796.87828125,
282
+ 16894.5344921875,
283
+ 16992.190703125,
284
+ 17089.8469140625,
285
+ 17187.503125,
286
+ 17285.1593359375,
287
+ 17382.815546875,
288
+ 17480.4717578125,
289
+ 17578.12796875,
290
+ 17675.7841796875,
291
+ 17773.440390625,
292
+ 17871.096601562498,
293
+ 17968.7528125,
294
+ 18066.4090234375,
295
+ 18164.065234375,
296
+ 18261.721445312498,
297
+ 18359.37765625,
298
+ 18457.0338671875,
299
+ 18554.690078125,
300
+ 18652.346289062498,
301
+ 18750.0025,
302
+ 18847.6587109375,
303
+ 18945.314921875,
304
+ 19042.9711328125,
305
+ 19140.62734375,
306
+ 19238.2835546875,
307
+ 19335.939765625,
308
+ 19433.5959765625,
309
+ 19531.2521875,
310
+ 19628.9083984375,
311
+ 19726.564609375,
312
+ 19824.2208203125,
313
+ 19921.87703125,
314
+ 20019.5332421875,
315
+ 20117.189453125,
316
+ 20214.8456640625,
317
+ 20312.501874999998,
318
+ 20410.1580859375,
319
+ 20507.814296875,
320
+ 20605.4705078125,
321
+ 20703.126718749998,
322
+ 20800.7829296875,
323
+ 20898.439140625,
324
+ 20996.0953515625,
325
+ 21093.7515625,
326
+ 21191.4077734375,
327
+ 21289.063984375,
328
+ 21386.7201953125,
329
+ 21484.37640625,
330
+ 21582.0326171875,
331
+ 21679.688828125,
332
+ 21777.3450390625,
333
+ 21875.00125,
334
+ 21972.6574609375,
335
+ 22070.313671875,
336
+ 22167.9698828125,
337
+ 22265.62609375,
338
+ 22363.2823046875,
339
+ 22460.938515625,
340
+ 22558.5947265625,
341
+ 22656.2509375,
342
+ 22753.907148437498,
343
+ 22851.563359375,
344
+ 22949.2195703125,
345
+ 23046.87578125,
346
+ 23144.5319921875,
347
+ 23242.188203125,
348
+ 23339.8444140625,
349
+ 23437.500625,
350
+ 23535.1568359375,
351
+ 23632.813046875,
352
+ 23730.4692578125,
353
+ 23828.12546875,
354
+ 23925.7816796875,
355
+ 24023.437890625,
356
+ 24121.0941015625,
357
+ 24218.7503125,
358
+ 24316.4065234375,
359
+ 24414.062734375,
360
+ 24511.7189453125,
361
+ 24609.37515625,
362
+ 24707.0313671875,
363
+ 24804.687578125,
364
+ 24902.3437890625,
365
+ 25000.0
366
+ ],
367
+ "name": "amount",
368
+ "num_values": 256,
369
+ "type": "bucketed",
370
+ "vocab_size": 259
371
+ },
372
+ {
373
+ "name": "card_product",
374
+ "num_values": 10,
375
+ "type": "categorical",
376
+ "vocab_size": 13
377
+ },
378
+ {
379
+ "name": "country",
380
+ "num_values": 50,
381
+ "type": "categorical",
382
+ "vocab_size": 53
383
+ },
384
+ {
385
+ "name": "avs",
386
+ "num_values": 5,
387
+ "type": "categorical",
388
+ "vocab_size": 8
389
+ },
390
+ {
391
+ "name": "cvv",
392
+ "num_values": 3,
393
+ "type": "categorical",
394
+ "vocab_size": 6
395
+ },
396
+ {
397
+ "name": "device_hash",
398
+ "num_values": 2000,
399
+ "type": "categorical",
400
+ "vocab_size": 2003
401
+ },
402
+ {
403
+ "boundaries": [
404
+ 0.0,
405
+ 12.0,
406
+ 24.0,
407
+ 36.0,
408
+ 48.0,
409
+ 60.0,
410
+ 72.0,
411
+ 84.0,
412
+ 96.0,
413
+ 108.0,
414
+ 120.0
415
+ ],
416
+ "name": "customer_tenure",
417
+ "num_values": 10,
418
+ "type": "bucketed",
419
+ "vocab_size": 13
420
+ }
421
+ ],
422
+ "num_features": 15,
423
+ "num_transactions": 64
424
+ }
encoder/configs/model_nocompress.yaml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Non-compressing encoder configuration (Option A: multi-position pooling).
2
+ #
3
+ # Mirrors parent's StructuredEmbedding pattern at d_lfm=1024: per-feature
4
+ # value tables + feature-type table, summed, expanded to a flat sequence
5
+ # of T_tx * F = 960 tokens. No MLP compression, no separate projector.
6
+ #
7
+ # Categorical heads use `pre_last_tx_mean` to pool the entire tx 62 stripe
8
+ # (positions 930..944) instead of a single position. Restores the
9
+ # holistic per-tx summary that the compress variant got for free.
10
+ #
11
+ # Fraud head keeps `last_tx_mean` — already pools the tx 63 stripe
12
+ # (positions 945..959), no change needed.
13
+
14
+ architecture:
15
+ mode: nocompress # → StructuredEncoder, T=960, no projector
16
+
17
+ backbone:
18
+ hf_path: LiquidAI/LFM2.5-350M-Base
19
+ dtype: bfloat16
20
+ lora:
21
+ enabled: true
22
+ r: 16
23
+ alpha: 32
24
+ dropout: 0.05
25
+ strict_attention_only: false
26
+
27
+ heads:
28
+ fraud:
29
+ output_dim: 1
30
+ loss: bce
31
+ pool: last_tx_mean # mean of positions 945..959 (last tx)
32
+ target: sequence_label
33
+ weight: 1.0
34
+ mlp_hidden: 128
35
+ dropout: 0.1
36
+ next_merchant:
37
+ output_dim: 10003
38
+ loss: ce
39
+ pool: pre_last_tx_mean # mean of positions 930..944 (tx 62 stripe)
40
+ target: "feature:5"
41
+ weight: 0.5
42
+ mlp_hidden: 128
43
+ dropout: 0.1
44
+ amount_range:
45
+ output_dim: 16
46
+ loss: ce
47
+ pool: pre_last_tx_mean
48
+ target: amount_range
49
+ weight: 0.5
50
+ mlp_hidden: 128
51
+ dropout: 0.1
52
+ mcc:
53
+ output_dim: 103
54
+ loss: ce
55
+ pool: pre_last_tx_mean
56
+ target: "feature:4"
57
+ weight: 0.5
58
+ mlp_hidden: 128
59
+ dropout: 0.1
encoder/src/data/loader.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data loader for the encoder subproject.
2
+
3
+ Reuses the parent's `FinetuneDataset` verbatim — the parent's tokenized arrays
4
+ at `data/synthetic/` are already shaped `(N, 64, 15)`, which is exactly what
5
+ the per-transaction encoder needs. This module is a thin orchestrator that
6
+ resolves data paths (via the `encoder/data/synthetic -> ../../data/synthetic`
7
+ symlink), builds train/val/test loaders, and exposes a fingerprint-verification
8
+ helper so accidental data regeneration breaks fast.
9
+
10
+ Why we don't define a new Dataset class: the encoder's input contract is
11
+ identical to the parent's (`(B, 64, 15) int64` plus fraud + amount_range
12
+ labels). The only thing that changes is what the model does with those
13
+ tokens. Keeping the Dataset shared guarantees apples-to-apples comparison.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import torch
22
+ from torch.utils.data import DataLoader
23
+
24
+ from src.training.finetune import FinetuneDataset
25
+
26
+
27
+ def load_data_arrays(
28
+ data_dir: Path | str,
29
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray | None, dict[str, np.ndarray]]:
30
+ """Load raw token arrays + split indices from `data_dir`.
31
+
32
+ Returns:
33
+ token_ids: (N, 64, 15) int16
34
+ sequence_labels: (N,) int8 (fraud)
35
+ ar_targets: (N,) int8 last-transaction amount_range, or None if file absent
36
+ splits: dict with keys 'train' / 'val' / 'test', each int64 indices
37
+ """
38
+ data_dir = Path(data_dir)
39
+ token_ids = np.load(data_dir / "token_ids.npy")
40
+ sequence_labels = np.load(data_dir / "sequence_labels.npy")
41
+ splits = dict(np.load(data_dir / "split_indices.npz"))
42
+
43
+ ar_path = data_dir / "amount_range_labels.npy"
44
+ ar_targets: np.ndarray | None = None
45
+ if ar_path.exists():
46
+ # Parent stores per-transaction amount_range as (N, 64). The head
47
+ # targets the LAST transaction's amount bucket, so we slice [:, -1].
48
+ ar_all = np.load(ar_path)
49
+ ar_targets = ar_all[:, -1]
50
+
51
+ return token_ids, sequence_labels, ar_targets, splits
52
+
53
+
54
+ def verify_fingerprint(data_dir: Path | str, expected: str) -> None:
55
+ """Raise if data fingerprint differs from `expected`.
56
+
57
+ Catches the silent failure where data has been regenerated under us —
58
+ in which case head-to-head comparison numbers against the parent's
59
+ already-published eval.md.json would not be apples-to-apples.
60
+ """
61
+ fp_path = Path(data_dir) / "fingerprint.txt"
62
+ if not fp_path.exists():
63
+ raise FileNotFoundError(
64
+ f"No fingerprint.txt at {fp_path}. Encoder relies on the parent's "
65
+ f"data/synthetic/ for head-to-head; regenerate via parent's "
66
+ f"`python -m scripts.generate` if missing.",
67
+ )
68
+ actual = fp_path.read_text().strip()
69
+ if actual != expected:
70
+ raise ValueError(
71
+ f"Data fingerprint mismatch:\n"
72
+ f" expected: {expected}\n"
73
+ f" actual: {actual}\n"
74
+ f"Data has been regenerated since this config was pinned. Head-to-head "
75
+ f"comparison against the parent's eval.md.json would not be valid.",
76
+ )
77
+
78
+
79
+ def build_loaders(
80
+ data_dir: Path | str,
81
+ batch_size: int = 32,
82
+ label_fraction: float = 1.0,
83
+ seed: int = 42,
84
+ num_workers: int = 4,
85
+ ) -> tuple[DataLoader, DataLoader, DataLoader]:
86
+ """Build train/val/test DataLoaders.
87
+
88
+ Args:
89
+ data_dir: path to the tokenized synthetic arrays (symlink to parent OK).
90
+ batch_size: applied to all three loaders.
91
+ label_fraction: subsample fraction of `train` indices for the
92
+ label-scarcity sweep (1.0 = full, 0.10 = 10%, 0.01 = 1%). Val and
93
+ test are never subsampled.
94
+ seed: RNG seed for the train-subset selection. Same seed as the parent's
95
+ scarcity protocol so the head-to-head selects the same training
96
+ subsets across both architectures.
97
+ num_workers: DataLoader worker count for train. Val/test use half.
98
+
99
+ Returns:
100
+ (train_loader, val_loader, test_loader)
101
+ """
102
+ token_ids, sequence_labels, ar_targets, splits = load_data_arrays(data_dir)
103
+
104
+ train_indices = splits["train"]
105
+ if label_fraction < 1.0:
106
+ # np.random.RandomState (not Generator) to match the parent's
107
+ # subsampling RNG exactly. Same seed -> identical train subset.
108
+ rng = np.random.RandomState(seed)
109
+ n_keep = max(1, int(len(train_indices) * label_fraction))
110
+ train_indices = rng.choice(train_indices, n_keep, replace=False)
111
+
112
+ train_ds = FinetuneDataset(token_ids, sequence_labels, train_indices, ar_targets)
113
+ val_ds = FinetuneDataset(token_ids, sequence_labels, splits["val"], ar_targets)
114
+ test_ds = FinetuneDataset(token_ids, sequence_labels, splits["test"], ar_targets)
115
+
116
+ train_loader = DataLoader(
117
+ train_ds,
118
+ batch_size=batch_size,
119
+ shuffle=True,
120
+ num_workers=num_workers,
121
+ pin_memory=torch.cuda.is_available(),
122
+ drop_last=True,
123
+ )
124
+ eval_workers = max(0, num_workers // 2)
125
+ val_loader = DataLoader(
126
+ val_ds,
127
+ batch_size=batch_size,
128
+ shuffle=False,
129
+ num_workers=eval_workers,
130
+ pin_memory=torch.cuda.is_available(),
131
+ )
132
+ test_loader = DataLoader(
133
+ test_ds,
134
+ batch_size=batch_size,
135
+ shuffle=False,
136
+ num_workers=eval_workers,
137
+ pin_memory=torch.cuda.is_available(),
138
+ )
139
+ return train_loader, val_loader, test_loader
encoder/src/demo/app.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive Gradio demo for the encoder-on-LFM2.5 transaction model.
2
+
3
+ Three-tab structure (Demo / Why Liquid / Integration). Demo is the
4
+ landing tab so a customer immediately sees what the model does; Why
5
+ Liquid is the architectural pitch; Integration is the build-it-yourself
6
+ playbook. Same Gradio theme + CSS as the rest of Liquid's customer-
7
+ facing demos for visual consistency.
8
+
9
+ The demo is intentionally self-contained: no side-by-side comparison
10
+ against an alternative architecture, no per-tab references to other
11
+ work. The argument is the architecture pattern itself — encoder +
12
+ frozen LFM2.5 backbone + LoRA + multi-head — and the reader gets a
13
+ clean read of it.
14
+
15
+ Usage:
16
+ python -m encoder.src.demo.app \\
17
+ --checkpoint encoder/experiments/.../step_004999.pt \\
18
+ --model-config encoder/configs/model_nocompress.yaml \\
19
+ --schema data/schema.yaml \\
20
+ --data-dir data/synthetic \\
21
+ --port 7860
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import time
28
+ from pathlib import Path
29
+
30
+ import gradio as gr
31
+ import torch
32
+
33
+ from src.data.schema import load_schema
34
+ from src.demo.app import DemoData # reuse parent's curated-test-set loader
35
+ from src.demo.decode import TransactionDecoder
36
+ from src.demo.merchant_catalog import DemoMerchantCatalog
37
+ from src.demo.profile_inference import format_profile_html, infer_profile
38
+
39
+ from encoder.src.demo.inference import EncoderDemoModel
40
+ from encoder.src.demo.render import (
41
+ format_amount_predictions,
42
+ format_fraud_score,
43
+ format_mcc_predictions,
44
+ format_merchant_predictions,
45
+ format_timeline,
46
+ render_encoder_integration,
47
+ render_why_encoder,
48
+ )
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Header
53
+ # ---------------------------------------------------------------------------
54
+
55
+ _HEADER_HTML = """
56
+ <div style="text-align: center; margin-bottom: 16px;">
57
+ <h1 style="margin: 0; font-size: 26px; font-weight: 700; color: #171717; letter-spacing: -0.02em;">
58
+ Encoder on LFM2.5 — Transaction Foundation Model
59
+ </h1>
60
+ <p style="color: #737373; margin: 6px 0 0 0; font-size: 13px;
61
+ font-family: JetBrains Mono, ui-monospace, monospace;">
62
+ Liquid AI &middot; LFM2.5-350M base &middot; Encoder + LoRA + multi-head
63
+ </p>
64
+ </div>
65
+ """
66
+
67
+
68
+ # Container width applied to every tab's content so the three surfaces
69
+ # read at the same width. Kept in sync with render.py's _CONTAINER_WIDTH.
70
+ _CONTAINER_WIDTH = "1180px"
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Demo tab intro — standalone, no comparison framing
75
+ # ---------------------------------------------------------------------------
76
+
77
+ _DEMO_INTRO_HTML = f"""
78
+ <div style="max-width: {_CONTAINER_WIDTH}; margin: 16px auto 8px auto; padding: 14px 18px;
79
+ background: #ffffff; border: 1px solid rgba(0,0,0,0.1);
80
+ border-radius: 12px;">
81
+ <div style="font-size: 14px; font-weight: 600; color: #171717; margin-bottom: 6px;">
82
+ Live multi-head inference on a frozen LFM2.5-350M backbone
83
+ </div>
84
+ <div style="font-size: 12px; color: #525252; line-height: 1.55;">
85
+ Pick a curated customer archetype, click <b>Run Inference</b>, watch four task
86
+ heads predict in parallel from one shared backbone — fraud probability,
87
+ next-merchant, amount bucket, and merchant category (MCC). The encoder turns
88
+ the 64-transaction history into 960 pseudo-tokens; the frozen LFM2.5-350M
89
+ backbone with LoRA processes them; per-task heads pool the hidden states.
90
+ For the architectural rationale see <i>Why Liquid</i>; for the build-it-yourself
91
+ playbook see <i>Integration</i>.
92
+ </div>
93
+ </div>
94
+ """
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # CSS — scoped to the encoder demo
99
+ # ---------------------------------------------------------------------------
100
+
101
+ _CSS = f"""
102
+ /* Force light mode regardless of system preference */
103
+ :root, .dark {{ color-scheme: light !important; }}
104
+
105
+ .gradio-container {{
106
+ background: #f5f5f5 !important;
107
+ max-width: 1280px !important;
108
+ margin: auto !important;
109
+ padding: 1.5rem !important;
110
+ }}
111
+
112
+ /* Tabs: pill-style matching the rest of Liquid's customer-facing demos */
113
+ .tabs {{ background: transparent !important; }}
114
+ .tab-nav {{
115
+ background: #f5f5f5 !important;
116
+ border: none !important;
117
+ border-bottom: 1px solid rgba(0,0,0,0.1) !important;
118
+ gap: 4px !important;
119
+ padding: 4px 0 !important;
120
+ }}
121
+ .tab-nav button {{
122
+ font-weight: 500 !important;
123
+ font-size: 14px !important;
124
+ color: #737373 !important;
125
+ background: transparent !important;
126
+ border: none !important;
127
+ border-bottom: 2px solid transparent !important;
128
+ padding: 8px 16px !important;
129
+ border-radius: 0 !important;
130
+ transition: all 0.15s ease !important;
131
+ }}
132
+ .tab-nav button:hover {{
133
+ color: #171717 !important;
134
+ background: rgba(0,0,0,0.03) !important;
135
+ }}
136
+ .tab-nav button.selected {{
137
+ color: #171717 !important;
138
+ font-weight: 600 !important;
139
+ border-bottom: 2px solid #171717 !important;
140
+ background: transparent !important;
141
+ }}
142
+
143
+ /* Run button: pill style, dark fill. Scoped by elem_id so it does not
144
+ bleed into Gradio's internal radio/checkbox <button> wrappers. */
145
+ #run-inference-btn button,
146
+ button#run-inference-btn {{
147
+ background: #171717 !important;
148
+ color: #ffffff !important;
149
+ border: none !important;
150
+ border-radius: 9999px !important;
151
+ padding: 10px 24px !important;
152
+ font-weight: 600 !important;
153
+ letter-spacing: -0.01em !important;
154
+ }}
155
+ #run-inference-btn button:hover,
156
+ button#run-inference-btn:hover {{
157
+ background: #404040 !important;
158
+ }}
159
+
160
+ /* Wrap the Demo tab's inner content at the same width as the HTML tabs
161
+ so all three tabs read at consistent width. */
162
+ #demo-tab-container {{
163
+ max-width: {_CONTAINER_WIDTH} !important;
164
+ margin: 0 auto !important;
165
+ padding: 0 16px !important;
166
+ }}
167
+ """
168
+
169
+
170
+ # Gradio theme matching parent demo's design system (monochrome neutral
171
+ # with Inter + JetBrains Mono fonts). Kept in this file rather than a
172
+ # shared module to keep encoder/ a self-contained directory.
173
+ def _build_theme() -> gr.themes.Soft:
174
+ return gr.themes.Soft(
175
+ primary_hue="neutral",
176
+ secondary_hue="neutral",
177
+ neutral_hue="neutral",
178
+ font=gr.themes.GoogleFont("Inter"),
179
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
180
+ ).set(
181
+ body_background_fill="#f5f5f5",
182
+ body_text_color="#171717",
183
+ body_text_color_subdued="#737373",
184
+ block_background_fill="#ffffff",
185
+ block_border_color="rgba(0,0,0,0.1)",
186
+ block_label_background_fill="#f5f5f5",
187
+ block_label_text_color="#525252",
188
+ block_title_text_color="#171717",
189
+ block_shadow="0 1px 3px rgba(0,0,0,0.04)",
190
+ input_background_fill="#ffffff",
191
+ input_border_color="rgba(0,0,0,0.1)",
192
+ input_border_color_focus="#171717",
193
+ input_placeholder_color="#a3a3a3",
194
+ panel_background_fill="#fafafa",
195
+ panel_border_color="rgba(0,0,0,0.06)",
196
+ border_color_primary="rgba(0,0,0,0.1)",
197
+ button_primary_background_fill="#171717",
198
+ button_primary_background_fill_hover="#404040",
199
+ button_primary_text_color="#ffffff",
200
+ button_secondary_background_fill="#ffffff",
201
+ button_secondary_text_color="#525252",
202
+ button_secondary_border_color="rgba(0,0,0,0.1)",
203
+ slider_color="#171717",
204
+ table_border_color="rgba(0,0,0,0.06)",
205
+ table_even_background_fill="#fafafa",
206
+ table_odd_background_fill="#ffffff",
207
+ shadow_spread="0px",
208
+ color_accent_soft="rgba(0,0,0,0.04)",
209
+ )
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # App builder
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _build_demo_tab_contents(
218
+ model: EncoderDemoModel,
219
+ data: DemoData,
220
+ decoder: TransactionDecoder,
221
+ merchant_catalog: DemoMerchantCatalog,
222
+ app: gr.Blocks,
223
+ ) -> None:
224
+ """Build the original Multi-Head Demo tab into the current Gradio context.
225
+
226
+ Caller owns the outer Blocks/Tab. The `app` argument is the host
227
+ Blocks instance, needed so we can register the auto-run `app.load(...)`
228
+ hook against the right Blocks.
229
+ """
230
+
231
+ def on_customer_select(curated_name: str) -> tuple[str, str, str, str, str, str, str, str]:
232
+ if not curated_name or curated_name not in data.curated_names:
233
+ return ("Select a customer to see predictions.", "", "", "", "", "", "", "")
234
+
235
+ idx = data.get_curated_index(curated_name)
236
+ token_ids = data.token_ids[idx]
237
+ is_fraud = bool(data.labels[idx])
238
+
239
+ summary = decoder.summarize_customer(token_ids, is_fraud)
240
+
241
+ t0 = time.perf_counter()
242
+ preds = model.run_inference(token_ids)
243
+ latency_ms = (time.perf_counter() - t0) * 1000
244
+
245
+ timeline_html = format_timeline(decoder, token_ids)
246
+ fraud_html = format_fraud_score(float(preds["fraud"][0]))
247
+ merchant_html = format_merchant_predictions(
248
+ preds["next_merchant"], merchant_catalog, k=5,
249
+ )
250
+ amount_html = format_amount_predictions(preds["amount_range"], k=5)
251
+ mcc_html = format_mcc_predictions(preds["mcc"], k=5)
252
+
253
+ profile_match = infer_profile(token_ids)
254
+ profile_html = format_profile_html(profile_match)
255
+
256
+ latency_html = (
257
+ f"<div style='font-family: JetBrains Mono, ui-monospace, monospace; "
258
+ f"font-size: 11px; color: #737373; padding: 6px 10px; "
259
+ f"background: #fafafa; border-radius: 6px; display: inline-block;'>"
260
+ f"Inference: <b style='color: #171717;'>{latency_ms:.0f} ms</b> "
261
+ f"({'CPU' if model.device.type == 'cpu' else 'GPU'}) "
262
+ f"&middot; ground truth: "
263
+ f"<b style='color: {'#EF4444' if is_fraud else '#10B981'};'>"
264
+ f"{'FRAUD' if is_fraud else 'LEGITIMATE'}</b>"
265
+ f"</div>"
266
+ )
267
+
268
+ return (
269
+ summary,
270
+ timeline_html,
271
+ profile_html,
272
+ fraud_html,
273
+ merchant_html,
274
+ amount_html,
275
+ mcc_html,
276
+ latency_html,
277
+ )
278
+
279
+ gr.HTML(_DEMO_INTRO_HTML)
280
+
281
+ with gr.Column(elem_id="demo-tab-container"):
282
+ with gr.Row():
283
+ with gr.Column(scale=1):
284
+ gr.HTML(
285
+ "<h3 style='margin: 0 0 8px 0; color: #171717;'>"
286
+ "Select Customer</h3>"
287
+ )
288
+ curated_dropdown = gr.Dropdown(
289
+ choices=data.curated_names,
290
+ value=data.curated_names[0] if data.curated_names else None,
291
+ label="Curated archetypes",
292
+ info="5 legitimate profiles + 3 fraud archetypes",
293
+ )
294
+ run_btn = gr.Button(
295
+ "Run Inference",
296
+ variant="primary",
297
+ size="lg",
298
+ elem_id="run-inference-btn",
299
+ )
300
+ latency_html = gr.HTML("")
301
+ with gr.Column(scale=2):
302
+ summary_html = gr.HTML("")
303
+
304
+ gr.HTML(
305
+ "<h3 style='margin: 20px 0 8px 0; color: #171717;'>"
306
+ "Transaction Timeline</h3>"
307
+ )
308
+ timeline_html = gr.HTML("")
309
+
310
+ with gr.Row():
311
+ with gr.Column():
312
+ gr.HTML(
313
+ "<h3 style='margin: 16px 0 8px 0; color: #171717;'>"
314
+ "Behavioral Profile</h3>"
315
+ )
316
+ profile_html = gr.HTML("")
317
+ with gr.Column():
318
+ gr.HTML(
319
+ "<h3 style='margin: 16px 0 8px 0; color: #171717;'>"
320
+ "Fraud Score</h3>"
321
+ )
322
+ fraud_html = gr.HTML("")
323
+
324
+ gr.HTML(
325
+ "<h3 style='margin: 20px 0 8px 0; color: #171717;'>"
326
+ "Next-Transaction Predictions</h3>"
327
+ )
328
+ with gr.Row():
329
+ with gr.Column():
330
+ gr.HTML(
331
+ "<h4 style='margin: 4px 0; color: #525252; font-size: 13px;'>"
332
+ "Next merchant</h4>"
333
+ )
334
+ merchant_html = gr.HTML("")
335
+ with gr.Column():
336
+ gr.HTML(
337
+ "<h4 style='margin: 4px 0; color: #525252; font-size: 13px;'>"
338
+ "Amount bucket</h4>"
339
+ )
340
+ amount_html = gr.HTML("")
341
+ with gr.Column():
342
+ gr.HTML(
343
+ "<h4 style='margin: 4px 0; color: #525252; font-size: 13px;'>"
344
+ "Merchant category (MCC)</h4>"
345
+ )
346
+ mcc_html = gr.HTML("")
347
+
348
+ outputs = [
349
+ summary_html,
350
+ timeline_html,
351
+ profile_html,
352
+ fraud_html,
353
+ merchant_html,
354
+ amount_html,
355
+ mcc_html,
356
+ latency_html,
357
+ ]
358
+
359
+ run_btn.click(
360
+ fn=on_customer_select,
361
+ inputs=[curated_dropdown],
362
+ outputs=outputs,
363
+ )
364
+
365
+ # Auto-run on first load so the user lands on populated outputs.
366
+ app.load(
367
+ fn=on_customer_select,
368
+ inputs=[curated_dropdown],
369
+ outputs=outputs,
370
+ )
371
+
372
+
373
+ def _build_why_liquid_tab_contents() -> None:
374
+ """Render the Why Liquid tab content (HTML pitch)."""
375
+ gr.HTML(render_why_encoder())
376
+
377
+
378
+ def _build_integration_tab_contents() -> None:
379
+ """Render the Integration tab content (HTML build-it-yourself guide)."""
380
+ gr.HTML(render_encoder_integration())
381
+
382
+
383
+ def create_app(
384
+ model: EncoderDemoModel,
385
+ data: DemoData,
386
+ decoder: TransactionDecoder,
387
+ merchant_catalog: DemoMerchantCatalog,
388
+ ) -> gr.Blocks:
389
+ """Standalone 3-tab Gradio app for the multi-head encoder demo."""
390
+ with gr.Blocks(
391
+ title="Encoder on LFM2.5 — Transaction Foundation Model",
392
+ css=_CSS,
393
+ theme=_build_theme(),
394
+ ) as app:
395
+ gr.HTML(_HEADER_HTML)
396
+ with gr.Tabs():
397
+ with gr.Tab("Demo"):
398
+ _build_demo_tab_contents(model, data, decoder, merchant_catalog, app)
399
+ with gr.Tab("Why Liquid"):
400
+ _build_why_liquid_tab_contents()
401
+ with gr.Tab("Integration"):
402
+ _build_integration_tab_contents()
403
+ return app
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Entrypoint
408
+ # ---------------------------------------------------------------------------
409
+
410
+
411
+ def main() -> None:
412
+ parser = argparse.ArgumentParser(
413
+ description="Encoder + LFM2.5-350M interactive demo",
414
+ )
415
+ parser.add_argument(
416
+ "--checkpoint",
417
+ type=Path,
418
+ default=None,
419
+ help="Trained checkpoint path. Omit to run with random-init (debug only).",
420
+ )
421
+ parser.add_argument(
422
+ "--model-config",
423
+ type=Path,
424
+ default=Path("encoder/configs/model_nocompress.yaml"),
425
+ )
426
+ parser.add_argument(
427
+ "--schema",
428
+ type=Path,
429
+ default=Path("data/schema.yaml"),
430
+ )
431
+ parser.add_argument(
432
+ "--data-dir",
433
+ type=Path,
434
+ default=Path("data/synthetic"),
435
+ help="Test-set data directory (token_ids.npy, sequence_labels.npy, etc.)",
436
+ )
437
+ parser.add_argument(
438
+ "--device",
439
+ type=str,
440
+ default="cpu",
441
+ choices=["cpu", "cuda", "mps"],
442
+ )
443
+ parser.add_argument(
444
+ "--dtype",
445
+ type=str,
446
+ default="float32",
447
+ choices=["float32", "bfloat16"],
448
+ )
449
+ parser.add_argument(
450
+ "--port",
451
+ type=int,
452
+ default=7860,
453
+ )
454
+ args = parser.parse_args()
455
+
456
+ dtype = torch.float32 if args.dtype == "float32" else torch.bfloat16
457
+
458
+ print("Loading schema, data, model...")
459
+ schema = load_schema(args.schema)
460
+ data = DemoData(args.data_dir, schema)
461
+ merchant_catalog = DemoMerchantCatalog(schema)
462
+ decoder = TransactionDecoder(schema, merchant_catalog)
463
+
464
+ model = EncoderDemoModel(
465
+ model_config_path=args.model_config,
466
+ schema_path=args.schema,
467
+ checkpoint_path=args.checkpoint,
468
+ dtype=dtype,
469
+ device=args.device,
470
+ )
471
+ print(f"Model loaded: {model.checkpoint_status}")
472
+ pc = model.num_params()
473
+ print(f"Params — total: {pc['total']:,} / trainable: {pc['trainable']:,}")
474
+
475
+ app = create_app(model, data, decoder, merchant_catalog)
476
+
477
+ # Walk a few ports if 7860 is in use.
478
+ for port in range(args.port, args.port + 10):
479
+ try:
480
+ app.launch(server_port=port, server_name="0.0.0.0")
481
+ break
482
+ except OSError as e:
483
+ if "Cannot find empty port" in str(e) or "Address already in use" in str(e):
484
+ print(f" port {port} in use, trying {port + 1}...")
485
+ continue
486
+ raise
487
+
488
+
489
+ if __name__ == "__main__":
490
+ main()
encoder/src/demo/copilot_app_unified.py CHANGED
@@ -1,24 +1,29 @@
1
- """Unified Co-Pilot: Dispute + Collections + Fraud in one Gradio app.
2
 
3
- Three Tabs on a single Blocks. Each tab is the per-surface content
4
- built by the surface's `_build_tab(model)` function. Models are loaded
5
- once at startup and reused — each surface owns its own checkpoint +
6
- config + encoder marker flags.
7
-
8
- Layout:
9
- [shared header]
10
  Tabs:
11
- - "Dispute" Dispute Co-Pilot tab (cast, complaint, score)
12
- - "Collections" treatment scoreboard tab
13
- - "Fraud" two-distribution stage+type tab
 
 
 
 
14
 
15
- This is the deployment target for the Hugging Face Space.
 
 
 
16
 
17
  CLI:
18
  python -m encoder.src.demo.copilot_app_unified \\
 
 
 
19
  --dispute-checkpoint encoder/experiments/dispute_legitimacy_v7/demo_checkpoint.pt \\
20
  --collections-checkpoint encoder/experiments/collections_v3/demo_checkpoint.pt \\
21
  --fraud-checkpoint encoder/experiments/fraud_pattern_v1/demo_checkpoint.pt \\
 
22
  --port 7860
23
  """
24
 
@@ -30,6 +35,20 @@ from pathlib import Path
30
  import gradio as gr
31
  import torch
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  from encoder.src.demo.copilot_app import _build_tab as _build_dispute_tab
34
  from encoder.src.demo.copilot_app_collections import (
35
  _build_tab as _build_collections_tab,
@@ -44,71 +63,77 @@ from encoder.src.demo.copilot_inference_collections import (
44
  from encoder.src.demo.copilot_inference_fraud_pattern import (
45
  FraudPatternCopilotModel,
46
  )
47
-
48
-
49
- _CONTAINER_WIDTH_PX = 1200
50
-
51
- _INK = "#171717"
52
- _INK_DIM = "#525252"
53
-
54
-
55
- def _render_unified_header() -> str:
56
- """Shared top header for the unified Co-Pilot.
57
-
58
- States: backbone, the three surfaces, the architecture name. Sits
59
- above the Tabs.
60
- """
61
- return f"""
62
- <div style="text-align: center; margin-bottom: 18px;">
63
- <div style="
64
- font-size: 11px;
65
- color: {_INK_DIM};
66
- font-family: 'JetBrains Mono', ui-monospace, monospace;
67
- text-transform: uppercase;
68
- letter-spacing: 0.08em;
69
- margin-bottom: 6px;
70
- ">
71
- Liquid AI &middot; LFM2.5-350M backbone &middot; encoder + LoRA per surface
72
- </div>
73
- <h1 style="
74
- margin: 0;
75
- font-size: 28px;
76
- font-weight: 700;
77
- color: {_INK};
78
- letter-spacing: -0.02em;
79
- ">
80
- Transaction Co-Pilot
81
- </h1>
82
- <div style="
83
- margin-top: 4px;
84
- font-size: 13px;
85
- color: {_INK_DIM};
86
- ">
87
- One backbone, three surfaces: Dispute &middot; Collections &middot; Fraud
88
- </div>
89
- </div>
90
- """
91
 
92
 
93
  def build_unified_ui(
 
 
 
 
94
  dispute_model: CopilotModel,
95
  collections_model: CollectionsCopilotModel,
96
  fraud_model: FraudPatternCopilotModel,
97
  ) -> gr.Blocks:
98
- """Compose the three surface tabs into one Blocks."""
99
- with gr.Blocks(title="Transaction Co-Pilot — Liquid AI") as demo:
100
- gr.HTML(_render_unified_header())
 
 
 
 
 
 
 
101
  with gr.Tabs():
102
- with gr.Tab("Dispute"):
 
 
 
 
 
 
 
 
 
 
 
 
103
  _build_dispute_tab(dispute_model)
104
- with gr.Tab("Collections"):
105
  _build_collections_tab(collections_model)
106
- with gr.Tab("Fraud"):
107
  _build_fraud_tab(fraud_model)
108
- return demo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
- def _load_all(
112
  dispute_checkpoint: Path,
113
  dispute_config: Path,
114
  collections_checkpoint: Path,
@@ -116,39 +141,37 @@ def _load_all(
116
  fraud_checkpoint: Path,
117
  fraud_config: Path,
118
  schema: Path,
119
- histories: Path,
120
  dispute_cast: Path,
121
  collections_cast: Path,
122
  fraud_cast: Path,
123
  device: torch.device,
124
  ) -> tuple[CopilotModel, CollectionsCopilotModel, FraudPatternCopilotModel]:
125
- """Load all three multi-surface models. Each surface has its own
126
- backbone copy in memory; for CPU deployments this is acceptable
127
- (3 × ~700MB fp32 = ~2.1GB)."""
128
- print(f"[1/3] Loading Dispute on {device} ...")
129
  dispute_model = CopilotModel.from_paths(
130
  checkpoint_path=dispute_checkpoint,
131
  model_config_path=dispute_config,
132
  schema_path=schema,
133
- histories_path=histories,
134
  cast_path=dispute_cast,
135
  device=device,
136
  )
137
- print(f"[2/3] Loading Collections on {device} ...")
138
  collections_model = CollectionsCopilotModel.from_paths(
139
  checkpoint_path=collections_checkpoint,
140
  model_config_path=collections_config,
141
  schema_path=schema,
142
- histories_path=histories,
143
  cast_path=collections_cast,
144
  device=device,
145
  )
146
- print(f"[3/3] Loading Fraud on {device} ...")
147
  fraud_model = FraudPatternCopilotModel.from_paths(
148
  checkpoint_path=fraud_checkpoint,
149
  model_config_path=fraud_config,
150
  schema_path=schema,
151
- histories_path=histories,
152
  cast_path=fraud_cast,
153
  device=device,
154
  )
@@ -157,8 +180,26 @@ def _load_all(
157
 
158
  def main() -> None:
159
  parser = argparse.ArgumentParser(
160
- description="Unified Transaction Co-Pilot Gradio app",
 
 
 
 
 
 
 
161
  )
 
 
 
 
 
 
 
 
 
 
 
162
  parser.add_argument(
163
  "--dispute-checkpoint",
164
  type=Path,
@@ -169,6 +210,12 @@ def main() -> None:
169
  type=Path,
170
  default=Path("encoder/configs/model_dispute_legitimacy.yaml"),
171
  )
 
 
 
 
 
 
172
  parser.add_argument(
173
  "--collections-checkpoint",
174
  type=Path,
@@ -179,6 +226,12 @@ def main() -> None:
179
  type=Path,
180
  default=Path("encoder/configs/model_collections.yaml"),
181
  )
 
 
 
 
 
 
182
  parser.add_argument(
183
  "--fraud-checkpoint",
184
  type=Path,
@@ -190,29 +243,21 @@ def main() -> None:
190
  default=Path("encoder/configs/model_fraud_pattern.yaml"),
191
  )
192
  parser.add_argument(
193
- "--schema",
194
- type=Path,
195
- default=Path("data/schema.yaml"),
196
- )
197
- parser.add_argument(
198
- "--histories",
199
- type=Path,
200
- default=Path("data/synthetic/token_ids.npy"),
201
- )
202
- parser.add_argument(
203
- "--dispute-cast",
204
  type=Path,
205
- default=Path("encoder/data/demo_cast.json"),
206
  )
 
207
  parser.add_argument(
208
- "--collections-cast",
209
  type=Path,
210
- default=Path("encoder/data/collections_cast.json"),
211
  )
212
  parser.add_argument(
213
- "--fraud-cast",
214
  type=Path,
215
- default=Path("encoder/data/fraud_pattern_cast.json"),
 
216
  )
217
  parser.add_argument(
218
  "--device",
@@ -220,12 +265,33 @@ def main() -> None:
220
  default="cpu",
221
  choices=["cpu", "cuda", "mps"],
222
  )
 
 
 
 
 
 
223
  parser.add_argument("--port", type=int, default=7860)
224
  parser.add_argument("--share", action="store_true")
225
  args = parser.parse_args()
226
 
227
  device = torch.device(args.device)
228
- dispute_model, collections_model, fraud_model = _load_all(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  dispute_checkpoint=args.dispute_checkpoint,
230
  dispute_config=args.dispute_config,
231
  collections_checkpoint=args.collections_checkpoint,
@@ -233,29 +299,30 @@ def main() -> None:
233
  fraud_checkpoint=args.fraud_checkpoint,
234
  fraud_config=args.fraud_config,
235
  schema=args.schema,
236
- histories=args.histories,
237
  dispute_cast=args.dispute_cast,
238
  collections_cast=args.collections_cast,
239
  fraud_cast=args.fraud_cast,
240
  device=device,
241
  )
242
- print(" all three surfaces loaded.")
243
 
244
- demo = build_unified_ui(dispute_model, collections_model, fraud_model)
 
 
 
 
 
 
 
 
 
 
245
  demo.queue().launch(
246
  server_name="0.0.0.0",
247
  server_port=args.port,
248
  share=args.share,
249
- theme=gr.themes.Default(
250
- font=["Inter", "system-ui", "sans-serif"],
251
- font_mono=["JetBrains Mono", "ui-monospace", "monospace"],
252
- ),
253
- css=f"""
254
- .gradio-container {{
255
- max-width: {_CONTAINER_WIDTH_PX}px !important;
256
- background: #fafafa !important;
257
- }}
258
- """,
259
  )
260
 
261
 
 
1
+ """Unified Co-Pilot: 6 tabs over one Gradio Blocks.
2
 
3
+ Top-level layout:
 
 
 
 
 
 
4
  Tabs:
5
+ - "Multi-Head Demo" original encoder demo (4 task heads, V3 nocompress
6
+ + meanpool checkpoint, curated customer dropdown)
7
+ - "Why Liquid" architectural pitch (original demo content)
8
+ - "Integration" — build-it-yourself guide (original demo content)
9
+ - "Dispute Co-Pilot" — friendly-fraud classifier + attribution
10
+ - "Collections Co-Pilot" — treatment-response scoreboard
11
+ - "Fraud Co-Pilot" — pattern stage + type classifier
12
 
13
+ Each tab's content is a composable `_build_..._tab_contents(...)` helper
14
+ exported from the per-surface app module. Models are loaded once at
15
+ startup (4 model instances total — V3 multi-head + 3 surface-specific
16
+ multi-surface).
17
 
18
  CLI:
19
  python -m encoder.src.demo.copilot_app_unified \\
20
+ --multihead-checkpoint encoder/experiments/.../step_004999_slim.pt \\
21
+ --multihead-config encoder/configs/model_nocompress.yaml \\
22
+ --multihead-data-dir data/synthetic \\
23
  --dispute-checkpoint encoder/experiments/dispute_legitimacy_v7/demo_checkpoint.pt \\
24
  --collections-checkpoint encoder/experiments/collections_v3/demo_checkpoint.pt \\
25
  --fraud-checkpoint encoder/experiments/fraud_pattern_v1/demo_checkpoint.pt \\
26
+ --cast-histories data/synthetic/cast_token_ids.npy \\
27
  --port 7860
28
  """
29
 
 
35
  import gradio as gr
36
  import torch
37
 
38
+ # Original multi-head demo (V3) imports
39
+ from src.data.schema import load_schema
40
+ from src.demo.app import DemoData
41
+ from src.demo.decode import TransactionDecoder
42
+ from src.demo.merchant_catalog import DemoMerchantCatalog
43
+
44
+ from encoder.src.demo.app import (
45
+ _build_demo_tab_contents,
46
+ _build_integration_tab_contents,
47
+ _build_why_liquid_tab_contents,
48
+ _build_theme,
49
+ _CSS,
50
+ _HEADER_HTML,
51
+ )
52
  from encoder.src.demo.copilot_app import _build_tab as _build_dispute_tab
53
  from encoder.src.demo.copilot_app_collections import (
54
  _build_tab as _build_collections_tab,
 
63
  from encoder.src.demo.copilot_inference_fraud_pattern import (
64
  FraudPatternCopilotModel,
65
  )
66
+ from encoder.src.demo.inference import EncoderDemoModel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  def build_unified_ui(
70
+ multihead_model: EncoderDemoModel,
71
+ multihead_data: DemoData,
72
+ multihead_decoder: TransactionDecoder,
73
+ multihead_merchant_catalog: DemoMerchantCatalog,
74
  dispute_model: CopilotModel,
75
  collections_model: CollectionsCopilotModel,
76
  fraud_model: FraudPatternCopilotModel,
77
  ) -> gr.Blocks:
78
+ """Compose the original 3-tab demo + 3 new Co-Pilot tabs into one Blocks.
79
+
80
+ Uses the original demo's theme + CSS so the visual vocabulary stays
81
+ consistent across tabs.
82
+ """
83
+ # Gradio 6.0 moved `css` and `theme` from Blocks() to launch(); the
84
+ # caller passes them in via demo.queue().launch(theme=..., css=...).
85
+ # We stash them on the returned Blocks instance for the caller.
86
+ with gr.Blocks(title="Transaction Encoder — Liquid AI") as app:
87
+ gr.HTML(_HEADER_HTML)
88
  with gr.Tabs():
89
+ with gr.Tab("Multi-Head Demo"):
90
+ _build_demo_tab_contents(
91
+ multihead_model,
92
+ multihead_data,
93
+ multihead_decoder,
94
+ multihead_merchant_catalog,
95
+ app,
96
+ )
97
+ with gr.Tab("Why Liquid"):
98
+ _build_why_liquid_tab_contents()
99
+ with gr.Tab("Integration"):
100
+ _build_integration_tab_contents()
101
+ with gr.Tab("Dispute Co-Pilot"):
102
  _build_dispute_tab(dispute_model)
103
+ with gr.Tab("Collections Co-Pilot"):
104
  _build_collections_tab(collections_model)
105
+ with gr.Tab("Fraud Co-Pilot"):
106
  _build_fraud_tab(fraud_model)
107
+ return app
108
+
109
+
110
+ def _load_multihead(
111
+ checkpoint: Path,
112
+ config: Path,
113
+ schema: Path,
114
+ data_dir: Path,
115
+ dtype: torch.dtype,
116
+ device: str,
117
+ ) -> tuple[EncoderDemoModel, DemoData, TransactionDecoder, DemoMerchantCatalog]:
118
+ """Load the original multi-head V3 model + curated test data."""
119
+ print(f"[multihead] schema + data ...")
120
+ schema_cfg = load_schema(schema)
121
+ data = DemoData(data_dir, schema_cfg)
122
+ merchant_catalog = DemoMerchantCatalog(schema_cfg)
123
+ decoder = TransactionDecoder(schema_cfg, merchant_catalog)
124
+ print(f"[multihead] loading EncoderDemoModel ...")
125
+ model = EncoderDemoModel(
126
+ model_config_path=config,
127
+ schema_path=schema,
128
+ checkpoint_path=checkpoint,
129
+ dtype=dtype,
130
+ device=device,
131
+ )
132
+ print(f"[multihead] checkpoint: {model.checkpoint_status}")
133
+ return model, data, decoder, merchant_catalog
134
 
135
 
136
+ def _load_copilots(
137
  dispute_checkpoint: Path,
138
  dispute_config: Path,
139
  collections_checkpoint: Path,
 
141
  fraud_checkpoint: Path,
142
  fraud_config: Path,
143
  schema: Path,
144
+ cast_histories: Path,
145
  dispute_cast: Path,
146
  collections_cast: Path,
147
  fraud_cast: Path,
148
  device: torch.device,
149
  ) -> tuple[CopilotModel, CollectionsCopilotModel, FraudPatternCopilotModel]:
150
+ """Load the three Co-Pilot surfaces. Each has its own backbone copy."""
151
+ print(f"[copilot 1/3] loading Dispute ...")
 
 
152
  dispute_model = CopilotModel.from_paths(
153
  checkpoint_path=dispute_checkpoint,
154
  model_config_path=dispute_config,
155
  schema_path=schema,
156
+ histories_path=cast_histories,
157
  cast_path=dispute_cast,
158
  device=device,
159
  )
160
+ print(f"[copilot 2/3] loading Collections ...")
161
  collections_model = CollectionsCopilotModel.from_paths(
162
  checkpoint_path=collections_checkpoint,
163
  model_config_path=collections_config,
164
  schema_path=schema,
165
+ histories_path=cast_histories,
166
  cast_path=collections_cast,
167
  device=device,
168
  )
169
+ print(f"[copilot 3/3] loading Fraud ...")
170
  fraud_model = FraudPatternCopilotModel.from_paths(
171
  checkpoint_path=fraud_checkpoint,
172
  model_config_path=fraud_config,
173
  schema_path=schema,
174
+ histories_path=cast_histories,
175
  cast_path=fraud_cast,
176
  device=device,
177
  )
 
180
 
181
  def main() -> None:
182
  parser = argparse.ArgumentParser(
183
+ description="Unified Transaction Encoder Gradio app (6 tabs)",
184
+ )
185
+ # --- multi-head V3 ---
186
+ parser.add_argument(
187
+ "--multihead-checkpoint",
188
+ type=Path,
189
+ default=Path("encoder/experiments/nocompress_meanpool/"
190
+ "encoder_sft_20260519_144916/checkpoints/step_004999_slim.pt"),
191
  )
192
+ parser.add_argument(
193
+ "--multihead-config",
194
+ type=Path,
195
+ default=Path("encoder/configs/model_nocompress.yaml"),
196
+ )
197
+ parser.add_argument(
198
+ "--multihead-data-dir",
199
+ type=Path,
200
+ default=Path("data/synthetic"),
201
+ )
202
+ # --- dispute ---
203
  parser.add_argument(
204
  "--dispute-checkpoint",
205
  type=Path,
 
210
  type=Path,
211
  default=Path("encoder/configs/model_dispute_legitimacy.yaml"),
212
  )
213
+ parser.add_argument(
214
+ "--dispute-cast",
215
+ type=Path,
216
+ default=Path("encoder/data/demo_cast.json"),
217
+ )
218
+ # --- collections ---
219
  parser.add_argument(
220
  "--collections-checkpoint",
221
  type=Path,
 
226
  type=Path,
227
  default=Path("encoder/configs/model_collections.yaml"),
228
  )
229
+ parser.add_argument(
230
+ "--collections-cast",
231
+ type=Path,
232
+ default=Path("encoder/data/collections_cast.json"),
233
+ )
234
+ # --- fraud ---
235
  parser.add_argument(
236
  "--fraud-checkpoint",
237
  type=Path,
 
243
  default=Path("encoder/configs/model_fraud_pattern.yaml"),
244
  )
245
  parser.add_argument(
246
+ "--fraud-cast",
 
 
 
 
 
 
 
 
 
 
247
  type=Path,
248
+ default=Path("encoder/data/fraud_pattern_cast.json"),
249
  )
250
+ # --- shared ---
251
  parser.add_argument(
252
+ "--schema",
253
  type=Path,
254
+ default=Path("data/schema.yaml"),
255
  )
256
  parser.add_argument(
257
+ "--cast-histories",
258
  type=Path,
259
+ default=Path("data/synthetic/token_ids.npy"),
260
+ help="Histories file for the Co-Pilot tabs (subset of 18 cast customers).",
261
  )
262
  parser.add_argument(
263
  "--device",
 
265
  default="cpu",
266
  choices=["cpu", "cuda", "mps"],
267
  )
268
+ parser.add_argument(
269
+ "--dtype",
270
+ type=str,
271
+ default="float32",
272
+ choices=["float32", "bfloat16"],
273
+ )
274
  parser.add_argument("--port", type=int, default=7860)
275
  parser.add_argument("--share", action="store_true")
276
  args = parser.parse_args()
277
 
278
  device = torch.device(args.device)
279
+ multihead_dtype = (
280
+ torch.float32 if args.dtype == "float32" else torch.bfloat16
281
+ )
282
+
283
+ multihead_model, multihead_data, multihead_decoder, multihead_merchant = (
284
+ _load_multihead(
285
+ checkpoint=args.multihead_checkpoint,
286
+ config=args.multihead_config,
287
+ schema=args.schema,
288
+ data_dir=args.multihead_data_dir,
289
+ dtype=multihead_dtype,
290
+ device=args.device,
291
+ )
292
+ )
293
+
294
+ dispute_model, collections_model, fraud_model = _load_copilots(
295
  dispute_checkpoint=args.dispute_checkpoint,
296
  dispute_config=args.dispute_config,
297
  collections_checkpoint=args.collections_checkpoint,
 
299
  fraud_checkpoint=args.fraud_checkpoint,
300
  fraud_config=args.fraud_config,
301
  schema=args.schema,
302
+ cast_histories=args.cast_histories,
303
  dispute_cast=args.dispute_cast,
304
  collections_cast=args.collections_cast,
305
  fraud_cast=args.fraud_cast,
306
  device=device,
307
  )
 
308
 
309
+ print("all four models loaded.")
310
+
311
+ demo = build_unified_ui(
312
+ multihead_model=multihead_model,
313
+ multihead_data=multihead_data,
314
+ multihead_decoder=multihead_decoder,
315
+ multihead_merchant_catalog=multihead_merchant,
316
+ dispute_model=dispute_model,
317
+ collections_model=collections_model,
318
+ fraud_model=fraud_model,
319
+ )
320
  demo.queue().launch(
321
  server_name="0.0.0.0",
322
  server_port=args.port,
323
  share=args.share,
324
+ theme=_build_theme(),
325
+ css=_CSS,
 
 
 
 
 
 
 
 
326
  )
327
 
328
 
encoder/src/demo/inference.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference path for the encoder demo.
2
+
3
+ Loads V3 (Option A: nocompress + mean-pool + attn-LoRA) and exposes a
4
+ single `run_inference(token_ids)` function returning multi-head
5
+ predictions in a UI-friendly shape.
6
+
7
+ Single model, no side-by-side — different from the parent's
8
+ pretrained-vs-random comparison. The encoder demo's narrative is about
9
+ the architecture itself, not the value of pretraining.
10
+
11
+ Runs on CPU (fp32) for HF Spaces basic tier and local Mac smoke tests.
12
+ On the production H100 (192.222.55.165 / demos.liquid.ai surface), the
13
+ caller can pass `dtype=torch.bfloat16, device="cuda"` for the bf16 fast
14
+ path.
15
+
16
+ Performance note: even on CPU, ~64-tx inference is dominated by the
17
+ backbone's 16-layer forward pass over 960 pseudo-tokens. Expect
18
+ ~3-8 seconds per inference on a Mac M-series CPU. For an interactive
19
+ demo we cache the model in memory and accept the latency; the user
20
+ selects a customer, clicks Run, waits ~5 seconds, sees outputs. GPU
21
+ deployment cuts this to <100ms.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn.functional as F
32
+ import yaml
33
+
34
+ from src.data.schema import SchemaConfig, load_schema
35
+ from src.training.trainer_utils import load_checkpoint
36
+
37
+ from encoder.src.model.transaction_fm import build_transaction_fm
38
+
39
+
40
+ class EncoderDemoModel:
41
+ """Wraps the V3 stack with cached inference for the demo.
42
+
43
+ Construction:
44
+ model = EncoderDemoModel(
45
+ model_config_path="encoder/configs/model_nocompress.yaml",
46
+ schema_path="data/schema.yaml",
47
+ checkpoint_path="encoder/experiments/.../step_004999.pt",
48
+ dtype=torch.float32,
49
+ device="cpu",
50
+ )
51
+
52
+ Inference:
53
+ results = model.run_inference(token_ids) # (64, 15) int64
54
+ # results: dict[str, np.ndarray]
55
+ # fraud: shape (1,) — sigmoid probability in [0, 1]
56
+ # next_merchant: shape (10003,) — softmax over merchant_id vocab
57
+ # amount_range: shape (16,) — softmax over 16 amount buckets
58
+ # mcc: shape (103,) — softmax over MCC vocab
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ model_config_path: str | Path,
64
+ schema_path: str | Path,
65
+ checkpoint_path: str | Path | None = None,
66
+ dtype: torch.dtype = torch.float32,
67
+ device: str = "cpu",
68
+ ) -> None:
69
+ self.device = torch.device(device)
70
+ self.dtype = dtype
71
+
72
+ with open(model_config_path) as f:
73
+ mcfg = yaml.safe_load(f)
74
+ self.schema: SchemaConfig = load_schema(schema_path)
75
+ self.head_configs = mcfg["heads"]
76
+
77
+ # device_map=None + manual .to(device) below. We deliberately don't
78
+ # use device_map="auto" because the encoder/projector/heads are
79
+ # constructed separately and would otherwise stay on CPU.
80
+ self.model = build_transaction_fm(
81
+ schema=self.schema,
82
+ head_configs=self.head_configs,
83
+ model_path=mcfg["backbone"]["hf_path"],
84
+ architecture_cfg=mcfg.get("architecture"),
85
+ encoder_cfg=mcfg.get("encoder"),
86
+ projector_cfg=mcfg.get("projector"),
87
+ lora_cfg=mcfg["backbone"]["lora"],
88
+ dtype=dtype,
89
+ device_map=None,
90
+ ).to(self.device)
91
+ self.model.eval()
92
+
93
+ self.checkpoint_status = "no checkpoint (random-init)"
94
+ if checkpoint_path is not None:
95
+ ckpt_path = Path(checkpoint_path)
96
+ if not ckpt_path.exists():
97
+ raise FileNotFoundError(
98
+ f"Checkpoint not found: {ckpt_path}",
99
+ )
100
+ # Peek at the checkpoint to detect slim variant (LFM base
101
+ # stripped). Slim checkpoints load with strict=False because
102
+ # the freshly-loaded LFM base keys are missing from the
103
+ # state_dict — but the runtime values are identical to what
104
+ # was originally in the checkpoint, so the math is unchanged.
105
+ ckpt_peek = torch.load(ckpt_path, map_location="cpu", weights_only=False)
106
+ is_slim = ckpt_peek.get("model_state_dict_slim", False)
107
+ del ckpt_peek # release memory before load_checkpoint re-reads
108
+
109
+ ckpt = load_checkpoint(ckpt_path, self.model, strict=not is_slim)
110
+ step = ckpt.get("step", "?")
111
+ slim_note = " (slim — LFM base from HF cache)" if is_slim else ""
112
+ self.checkpoint_status = f"step {step}{slim_note}"
113
+
114
+ @torch.no_grad()
115
+ def run_inference(self, token_ids: np.ndarray) -> dict[str, np.ndarray]:
116
+ """Run all heads on one customer sequence.
117
+
118
+ Args:
119
+ token_ids: (64, 15) int64 numpy array — one customer's
120
+ 64-transaction history.
121
+
122
+ Returns:
123
+ dict mapping head name to its prediction array. Fraud is a
124
+ sigmoid probability; the rest are softmax-normalized class
125
+ distributions.
126
+ """
127
+ tensor = torch.from_numpy(token_ids).unsqueeze(0).long().to(self.device)
128
+ # tensor: (1, 64, 15)
129
+ predictions = self.model(tensor)
130
+ # predictions: dict[head_name, logits]
131
+ # fraud: (1, 1)
132
+ # next_merchant: (1, 10003)
133
+ # amount_range: (1, 16)
134
+ # mcc: (1, 103)
135
+
136
+ results: dict[str, np.ndarray] = {}
137
+ for name, logits in predictions.items():
138
+ if name == "fraud":
139
+ prob = torch.sigmoid(logits).squeeze().cpu().numpy()
140
+ # Cast to scalar then back to 1-element array for uniform downstream handling
141
+ results[name] = np.array([float(prob)])
142
+ else:
143
+ probs = F.softmax(logits, dim=-1).squeeze(0).cpu().numpy()
144
+ results[name] = probs
145
+
146
+ return results
147
+
148
+ def num_params(self) -> dict[str, int]:
149
+ """Param breakdown for the architecture-display card in the UI."""
150
+ total = sum(p.numel() for p in self.model.parameters())
151
+ trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
152
+ return {"total": total, "trainable": trainable}
encoder/src/demo/render.py ADDED
@@ -0,0 +1,728 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encoder-specific render module.
2
+
3
+ Reuses parent's format functions (fraud bar, top-k predictions, timeline)
4
+ directly via import — those are model-agnostic. The encoder demo only
5
+ adds two pieces of new content:
6
+
7
+ 1. `render_why_encoder()` — the architectural pitch for the encoder pattern
8
+ 2. `render_encoder_integration()` — build-it-yourself integration guide
9
+
10
+ The fraud / merchant / amount / mcc / timeline / profile formatters are
11
+ unchanged from parent. We import them so the encoder demo's prediction
12
+ cards are visually consistent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # Re-export parent's format functions so encoder app.py can do a single
18
+ # import from this module.
19
+ from src.demo.render import ( # noqa: F401
20
+ format_amount_predictions,
21
+ format_fraud_score,
22
+ format_mcc_predictions,
23
+ format_merchant_predictions,
24
+ format_timeline,
25
+ format_topk_predictions,
26
+ )
27
+
28
+ # Liquid design tokens
29
+ _TEXT = "#171717"
30
+ _TEXT_MUTED = "#525252"
31
+ _TEXT_DIM = "#737373"
32
+ _BG_CARD = "#ffffff"
33
+ _BG_CARD_ALT = "#fafafa"
34
+ _BORDER = "rgba(0,0,0,0.1)"
35
+ _BORDER_SUBTLE = "rgba(0,0,0,0.05)"
36
+ _ACCENT_GREEN = "#10B981"
37
+ _ACCENT_BLUE = "#3B82F6"
38
+ _ACCENT_AMBER = "#F59E0B"
39
+ _ACCENT_PURPLE = "#7c3aed"
40
+ _RADIUS_CARD = "16px"
41
+ _RADIUS_SM = "8px"
42
+ _FONT_MONO = "JetBrains Mono, ui-monospace, SFMono-Regular, monospace"
43
+
44
+ # Single max-width applied to every tab's content so the three surfaces
45
+ # read at the same width. Picked to fit the Gradio container (1280px)
46
+ # with a small inset on either side.
47
+ _CONTAINER_WIDTH = "1180px"
48
+
49
+
50
+ def render_why_encoder() -> str:
51
+ """Why Liquid tab content.
52
+
53
+ Opens with the buyer's problem (multi-customer / multi-task
54
+ transaction-FM economics), then the published precedent
55
+ (LFM2.5-Audio / LFM2.5-VL), then the architectural and operational
56
+ properties, then scope-of-claim. Written for an external audience —
57
+ no internal codenames, no design-log register, no "we claim" framing.
58
+ """
59
+
60
+ def _table_header(cols: list[str]) -> str:
61
+ ths = ""
62
+ for i, c in enumerate(cols):
63
+ align = "right" if i > 0 else "left"
64
+ ths += (
65
+ f'<th style="padding: 6px 10px; font-size: 10px; color: {_TEXT_DIM};'
66
+ f' text-transform: uppercase; letter-spacing: 0.05em; text-align: {align};'
67
+ f' font-weight: 600;">{c}</th>'
68
+ )
69
+ return f"<tr style='border-bottom: 1px solid {_BORDER};'>{ths}</tr>"
70
+
71
+ def _table_row(cells: list[str], highlight: bool = False) -> str:
72
+ bg = "background: rgba(16,185,129,0.05);" if highlight else ""
73
+ tds = ""
74
+ for i, c in enumerate(cells):
75
+ align = "right" if i > 0 else "left"
76
+ tds += (
77
+ f'<td style="padding: 6px 10px; font-family: {_FONT_MONO}; font-size: 11px;'
78
+ f' color: {_TEXT}; text-align: {align};">{c}</td>'
79
+ )
80
+ return f"<tr style='border-bottom: 1px solid {_BORDER_SUBTLE}; {bg}'>{tds}</tr>"
81
+
82
+ return f"""
83
+ <div style="max-width: {_CONTAINER_WIDTH}; margin: 0 auto; padding: 16px;
84
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;">
85
+
86
+ <!-- Lead -->
87
+ <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700;
88
+ letter-spacing: -0.02em;">
89
+ Why LFM2.5 for Your Transaction Foundation Model
90
+ </h2>
91
+ <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 24px 0; line-height: 1.5;">
92
+ If you are putting a transaction foundation model into production &mdash;
93
+ especially across more than one business unit, customer, or downstream task &mdash;
94
+ the architectural choice determines per-customer training cost,
95
+ time-to-first-production-task, and the marginal cost of adding the second
96
+ and third tasks. The encoder-on-pretrained-backbone architecture applies
97
+ a recipe Liquid AI already ships in LFM2.5-Audio and LFM2.5-VL to
98
+ discrete-feature payment sequences. Three properties, each with a
99
+ different kind of evidence.
100
+ </p>
101
+
102
+ <!-- 1. Recipe is validated -->
103
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
104
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
105
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
106
+ 1. The Recipe Is Already Shipping for Two Other Modalities
107
+ </h3>
108
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 10px 0;">
109
+ A small per-modality encoder produces continuous embeddings; a projection
110
+ adapter (when needed) maps them into the LFM2.5 text backbone's hidden space;
111
+ LoRA adapts the attention layers per customer. LFM2.5-Audio ingests waveforms
112
+ this way. LFM2.5-VL ingests vision patches this way. This demo applies the
113
+ same shape to discrete transaction tokens.
114
+ </p>
115
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
116
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
117
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_BLUE};
118
+ font-weight: 600; margin-bottom: 4px;">LFM2.5-AUDIO</div>
119
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
120
+ Audio encoder → projection → LFM2.5 backbone. Ships in production.
121
+ </div>
122
+ </div>
123
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
124
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_BLUE};
125
+ font-weight: 600; margin-bottom: 4px;">LFM2.5-VL</div>
126
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
127
+ Vision encoder → projection → LFM2.5 backbone. Ships at multiple sizes.
128
+ </div>
129
+ </div>
130
+ <div style="padding: 10px; background: rgba(16,185,129,0.06);
131
+ border: 1px solid rgba(16,185,129,0.2); border-radius: 8px;">
132
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_GREEN};
133
+ font-weight: 600; margin-bottom: 4px;">TRANSACTIONS (THIS DEMO)</div>
134
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
135
+ Structured encoder → frozen LFM2.5-350M + LoRA → multi-head outputs.
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+
141
+ <!-- 2. Serving cost / backbone speed -->
142
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
143
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
144
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
145
+ 2. The Backbone Serves at Production Latency
146
+ </h3>
147
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 12px 0;">
148
+ LFM2.5's conv-dominant layer stack gives O(N) prefill scaling on most layers
149
+ where a pure-attention model pays O(N&sup2;). Published hardware-in-the-loop
150
+ benchmarks from the LFM2 technical report, S25 / 4K context:
151
+ </p>
152
+ <table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
153
+ {_table_header(["Model", "Prefill (tok/s)", "Decode (tok/s)"])}
154
+ {_table_row(["LFM2-2.6B", "<b>116</b>", "<b>30.0</b>"], highlight=True)}
155
+ {_table_row(["Qwen3-4B", "35", "11.4"])}
156
+ {_table_row(["Llama-3.2-3B", "51", "15.8"])}
157
+ </table>
158
+ <p style="font-size: 11px; color: {_TEXT_DIM}; margin: 0; line-height: 1.5;">
159
+ Your serving path is the published LFM2.5 backbone unchanged — only
160
+ the input side differs from a text deployment. The published latency
161
+ advantage transfers directly.
162
+ Source: <a href="https://arxiv.org/abs/2511.23404" style="color: {_TEXT_DIM};
163
+ text-decoration: underline;">LFM2 Technical Report, arXiv 2511.23404</a>.
164
+ </p>
165
+ </div>
166
+
167
+ <!-- 3. Frozen base + LoRA is the local maximum -->
168
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
169
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
170
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
171
+ 3. Frozen Backbone + LoRA Is the Higher-Quality Configuration at Typical Label Budgets
172
+ </h3>
173
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 10px 0;">
174
+ Freezing the LFM2.5 backbone and adapting it with LoRA produces higher
175
+ quality than unfreezing the full backbone end-to-end at typical finserv
176
+ label budgets. LoRA&rsquo;s low-rank update structure acts as effective
177
+ regularization; lifting that constraint lets the backbone memorize the
178
+ training labels rather than generalize. <b>The frozen-backbone commitment
179
+ is not a quality compromise &mdash; it is the higher-quality operating
180
+ point.</b>
181
+ </p>
182
+ <table style="width: 100%; border-collapse: collapse; margin-bottom: 6px;">
183
+ {_table_header(["Configuration", "Trainable", "Fraud ROC-AUC", "MCC top-1"])}
184
+ {_table_row(["Frozen backbone + LoRA (this demo)", "~16M", "<b>0.951</b>", "<b>40.5%</b>"],
185
+ highlight=True)}
186
+ {_table_row(["Full backbone unfreeze", "~370M", "0.900", "38.1%"])}
187
+ </table>
188
+ <p style="font-size: 11px; color: {_TEXT_DIM}; margin: 0; line-height: 1.5;">
189
+ Measured on 200K synthetic sequences (64 transactions × 15 features each).
190
+ At ~16M trainable parameters (encoder + LoRA + heads), per-customer
191
+ adaptation is small relative to the deployed footprint and completes
192
+ in hours, not days.
193
+ </p>
194
+ </div>
195
+
196
+ <!-- 4. Multi-head, multi-customer -->
197
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px;">
198
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
199
+ border-radius: {_RADIUS_CARD};">
200
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
201
+ One Backbone, Many Heads
202
+ </h3>
203
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0 0 8px 0;">
204
+ A single forward pass through the backbone produces hidden states that
205
+ four task heads pool independently — fraud detection, next-merchant
206
+ prediction, amount-bucket forecasting, MCC classification. New
207
+ use-cases (disputes, authorization optimization, AML) add a head, not
208
+ a foundation model.
209
+ </p>
210
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
211
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;">
212
+ Per-head MLP: ~0.5M params. Add a new task in hours.
213
+ </div>
214
+ </div>
215
+
216
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
217
+ border-radius: {_RADIUS_CARD};">
218
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
219
+ One Backbone, Many Customers
220
+ </h3>
221
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0 0 8px 0;">
222
+ The pretrained LFM2.5 weights ship once. Per-customer training is the
223
+ encoder + LoRA + heads — under 5% of base size in bf16 slim format.
224
+ Adding a customer is loading new artifacts on top of the cached
225
+ backbone, not retraining from scratch.
226
+ </p>
227
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
228
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;">
229
+ Slim per-customer artifact: ~30 MB bf16 at LFM2.5-1.2B scale.
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ <!-- 5. Architectural fit -->
235
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
236
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
237
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
238
+ The Architecture Matches Transaction Data Structure
239
+ </h3>
240
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 8px 0;">
241
+ Transaction data is information-dense locally (within-transaction
242
+ feature correlations, adjacent-transaction continuity) with sparse
243
+ long-range signal (behavioral baselines across the full history).
244
+ LFM2.5 allocates O(N) conv to the dense local patterns and O(N&sup2;)
245
+ attention to the sparse global ones. A pure transformer would spend
246
+ O(N&sup2;) compute uniformly.
247
+ </p>
248
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
249
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
250
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
251
+ font-weight: 600; margin-bottom: 4px;">Within Transaction</div>
252
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
253
+ Merchant determines MCC. Entry mode correlates with amount. Dense,
254
+ local, often deterministic. A 3-wide conv kernel captures this.
255
+ </div>
256
+ </div>
257
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
258
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
259
+ font-weight: 600; margin-bottom: 4px;">Adjacent Transactions</div>
260
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
261
+ Strong temporal continuity. A customer at Starbucks at 8am is
262
+ likely at a similar merchant tomorrow. Local conv handles it.
263
+ </div>
264
+ </div>
265
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
266
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
267
+ font-weight: 600; margin-bottom: 4px;">Distant Transactions</div>
268
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
269
+ Weak but non-zero signal. Behavioral profile matters for fraud
270
+ baseline. This is where attention earns its quadratic cost.
271
+ </div>
272
+ </div>
273
+ </div>
274
+ </div>
275
+
276
+ <!-- 6. Data sovereignty -->
277
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
278
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
279
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
280
+ Your Data, Your Model, Your Infrastructure
281
+ </h3>
282
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0;">
283
+ LFM2.5 base weights are open. Liquid licenses the architecture, training
284
+ recipe, and engineering support. Customers train on their proprietary
285
+ data behind their firewall. No data leaves customer infrastructure.
286
+ No dependency on external model APIs. The result is a foundation model
287
+ the customer owns, adapted to their transaction distribution.
288
+ </p>
289
+ </div>
290
+
291
+ <!-- Scope: what this demo validates / what a POC would establish -->
292
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
293
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
294
+ <h3 style="color: {_TEXT}; margin: 0 0 8px 0; font-size: 14px; font-weight: 600;">
295
+ Scope of Claim
296
+ </h3>
297
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-size: 12px;">
298
+ <div>
299
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_GREEN};
300
+ font-weight: 600; margin-bottom: 6px; text-transform: uppercase;
301
+ letter-spacing: 0.05em;">What this demo validates</div>
302
+ <ul style="margin: 0; padding-left: 14px; color: {_TEXT_MUTED}; line-height: 1.6;">
303
+ <li>The encoder-on-pretrained-backbone architecture used by LFM2.5-Audio
304
+ and LFM2.5-VL applies to discrete-feature transaction sequences
305
+ without modifying the transformers library.</li>
306
+ <li>Per-customer training touches ~2&ndash;5% of the deployed footprint
307
+ and trains in hours rather than days.</li>
308
+ <li>On synthetic data, frozen-backbone-plus-LoRA outperforms
309
+ full-backbone unfreezing on every measured head.</li>
310
+ <li>One pretrained backbone serves all task heads and is identical
311
+ across customer deployments.</li>
312
+ </ul>
313
+ </div>
314
+ <div>
315
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_AMBER};
316
+ font-weight: 600; margin-bottom: 6px; text-transform: uppercase;
317
+ letter-spacing: 0.05em;">What a POC on your data would establish</div>
318
+ <ul style="margin: 0; padding-left: 14px; color: {_TEXT_MUTED}; line-height: 1.6;">
319
+ <li>Whether synthetic-data quality numbers reproduce on your
320
+ transaction distribution.</li>
321
+ <li>Production-scale quality at LFM2.5-1.2B on your hardware and
322
+ sequence lengths (this demo runs at LFM2.5-350M).</li>
323
+ <li>Inference latency against your authorization-decision budget
324
+ at your concurrency.</li>
325
+ <li>Cross-customer or cross-business-unit transfer of the encoder
326
+ and LoRA artifacts.</li>
327
+ </ul>
328
+ </div>
329
+ </div>
330
+ </div>
331
+
332
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;">
333
+ Architecture: <a href="https://arxiv.org/abs/2511.23404" style="color: {_TEXT_DIM};
334
+ text-decoration: underline;">arXiv 2511.23404</a> &middot;
335
+ Weights: <a href="https://huggingface.co/LiquidAI" style="color: {_TEXT_DIM};
336
+ text-decoration: underline;">huggingface.co/LiquidAI</a>
337
+ </div>
338
+ </div>
339
+ """
340
+
341
+
342
+ def render_encoder_integration() -> str:
343
+ """Build-it-yourself integration guide.
344
+
345
+ Walks the reader through every component a customer team would build
346
+ to reproduce this demo on their own data: preprocessing, encoder,
347
+ backbone wiring, heads, postprocessing, training, deployment.
348
+ Includes hyperparameter cards, gotchas, and an engagement timeline.
349
+ """
350
+
351
+ def _phase_card(num: str, title: str, body: str, detail: str) -> str:
352
+ return f"""
353
+ <div style="padding: 14px 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
354
+ border-radius: {_RADIUS_CARD};">
355
+ <div style="display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px;">
356
+ <span style="font-family: {_FONT_MONO}; font-size: 11px; color: {_TEXT_DIM};
357
+ font-weight: 600;">{num}</span>
358
+ <span style="font-size: 14px; font-weight: 600; color: {_TEXT};">{title}</span>
359
+ </div>
360
+ <p style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5; margin: 0 0 8px 0;">
361
+ {body}</p>
362
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
363
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;
364
+ line-height: 1.5;">
365
+ {detail}</div>
366
+ </div>"""
367
+
368
+ def _gotcha(num: str, title: str, desc: str) -> str:
369
+ return f"""
370
+ <div style="display: flex; gap: 8px; padding: 5px 0;
371
+ border-bottom: 1px solid {_BORDER_SUBTLE};">
372
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
373
+ font-weight: 600; min-width: 18px;">{num}.</div>
374
+ <div>
375
+ <span style="font-size: 12px; font-weight: 600; color: {_TEXT};">{title}</span>
376
+ <span style="font-size: 12px; color: {_TEXT_MUTED};"> — {desc}</span>
377
+ </div>
378
+ </div>"""
379
+
380
+ def _pill(text: str) -> str:
381
+ return (
382
+ f'<span style="padding: 5px 12px; background: {_TEXT}; color: #fff;'
383
+ f' border-radius: 9999px; font-family: {_FONT_MONO};'
384
+ f' font-size: 10px; font-weight: 600;">{text}</span>'
385
+ )
386
+
387
+ arrow = f'<span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>'
388
+
389
+ return f"""
390
+ <div style="max-width: {_CONTAINER_WIDTH}; margin: 0 auto; padding: 16px;
391
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;">
392
+
393
+ <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700;
394
+ letter-spacing: -0.02em;">
395
+ Integration Architecture
396
+ </h2>
397
+ <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 20px 0; line-height: 1.5;">
398
+ How a customer team builds this stack end to end. Six components, three
399
+ ship from Liquid (LFM2.5 base weights, training recipes, architecture
400
+ support); three are customer-bespoke (schema, encoder, task heads).
401
+ Per-customer adaptation is one ML engineer for a few weeks, not a
402
+ research project.
403
+ </p>
404
+
405
+ <!-- Pipeline flow -->
406
+ <div style="display: flex; align-items: center; justify-content: center; gap: 6px;
407
+ margin-bottom: 24px; padding: 10px 0; flex-wrap: wrap;">
408
+ {_pill("Preprocess")}{arrow}
409
+ {_pill("Encode")}{arrow}
410
+ {_pill("Backbone + LoRA")}{arrow}
411
+ {_pill("Heads")}{arrow}
412
+ {_pill("Postprocess")}{arrow}
413
+ {_pill("Deploy")}
414
+ </div>
415
+
416
+ <!-- Phase cards -->
417
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;">
418
+
419
+ {_phase_card(
420
+ "1",
421
+ "Schema & Preprocessing",
422
+ "Define the discrete feature schema first — features, vocab sizes, ordering. "
423
+ "Categorical features (merchant_id, MCC, country) map directly to integer IDs. "
424
+ "Continuous features (amount, days-since-last) get quantile-bucketed into N bins. "
425
+ "High-cardinality features (10K+ merchants) have their long tail bucketed or "
426
+ "factored. Reserve 3 token IDs per feature for MASK / OOV / NULL. "
427
+ "Final per-customer batch shape: (B, T_tx, F).",
428
+ "Sequence: 64 tx &times; 15 feat = 960 tokens &nbsp;|&nbsp; "
429
+ "amount → 16 quantile bins &nbsp;|&nbsp; "
430
+ "merchant_id top-10K + frequency bucketing for the tail &nbsp;|&nbsp; "
431
+ "unseen values at inference → OOV (ID 1)"
432
+ )}
433
+
434
+ {_phase_card(
435
+ "2",
436
+ "Structured Encoder",
437
+ "One embedding table per feature (sized to its vocab) plus a shared "
438
+ "feature-type table. Value + type embeddings are summed to identify "
439
+ "which feature each token represents. The 15 per-tx feature embeddings "
440
+ "are kept as separate positions in the sequence — compressing them to "
441
+ "one token per transaction collapses fraud quality (fraud ROC-AUC drops "
442
+ "to 0.535 on this demo's data), because the per-tx MLP averages away the "
443
+ "intra-tx feature combinations fraud depends on. This is the same shape "
444
+ "as the audio and vision encoders' input embedding step.",
445
+ "Output shape: (B, T_tx*F, d_lfm) = (B, 960, 1024) at LFM2.5-350M &nbsp;|&nbsp; "
446
+ "value_tables[f](token) + type_table(f) &nbsp;|&nbsp; "
447
+ "Encoder params dominated by high-cardinality value tables (~14M at 350M)"
448
+ )}
449
+
450
+ {_phase_card(
451
+ "3",
452
+ "Projection Adapter (When Needed)",
453
+ "When the encoder's output dimension matches d_lfm directly, no adapter "
454
+ "is needed — the encoder outputs flow straight into the backbone. When "
455
+ "d_encoder &lt; d_lfm (typical at LFM2.5-1.2B where d_lfm=2048), a single "
456
+ "linear projection lifts the encoder output into the backbone hidden space, "
457
+ "exactly mirroring the audio/VL projection adapter. Layer init: identity "
458
+ "for d_encoder=d_lfm, Xavier for the projection case.",
459
+ "350M: d_lfm=1024, d_encoder=1024, no adapter (identity) &nbsp;|&nbsp; "
460
+ "1.2B: d_lfm=2048, project from d_encoder=512-1024 → 2048 &nbsp;|&nbsp; "
461
+ "Adds ~2M params at 1.2B scale"
462
+ )}
463
+
464
+ {_phase_card(
465
+ "4",
466
+ "Backbone + LoRA",
467
+ "Load the pretrained LFM2.5 base from Hugging Face. The backbone&rsquo;s "
468
+ "parameters are excluded from the optimizer&rsquo;s parameter set during "
469
+ "training — gradients flow through the backbone to update the upstream "
470
+ "encoder and downstream heads, but the backbone&rsquo;s own weights are "
471
+ "never modified. Forward pass executes through all 354M backbone "
472
+ "parameters at full capacity, at both training and inference time. "
473
+ "Customer-distribution adaptation enters through (i) LoRA&rsquo;s low-rank "
474
+ "delta on the attention projections (q_proj / k_proj / v_proj / out_proj) "
475
+ "and (ii) the per-feature encoder, both trained from scratch on customer "
476
+ "labels. Encoder outputs are injected via the published "
477
+ "<code>inputs_embeds</code> hook in <code>Lfm2Model.forward</code>. "
478
+ "Adding LoRA to the conv layers does not improve quality enough to justify the ~50% increase in training cost; attention-only LoRA is the recommended starting configuration.",
479
+ "Backbone params excluded from optimizer; backbone forward at full capacity &nbsp;|&nbsp; "
480
+ "LoRA r=16, &alpha;=32, dropout 0.05 on q_proj / k_proj / v_proj / out_proj &nbsp;|&nbsp; "
481
+ "PEFT wraps the leaf modules &nbsp;|&nbsp; "
482
+ "~1M LoRA params at 350M, ~2M at 1.2B"
483
+ )}
484
+
485
+ {_phase_card(
486
+ "5",
487
+ "Task Heads",
488
+ "Per-task downstream heads pool backbone hidden states and predict via "
489
+ "small MLPs. Fraud (BCE loss) pools the last-transaction stripe — "
490
+ "mean of positions T-F..T (positions 945..959). Categorical heads "
491
+ "(next-merchant, amount-bucket, MCC) use cross-entropy and pool the "
492
+ "<i>pre-last</i> transaction stripe (positions 930..944) to avoid "
493
+ "leaking the prediction target. New tasks add a head, backbone "
494
+ "untouched.",
495
+ "Per-head MLP: 128 hidden, dropout 0.1 &nbsp;|&nbsp; "
496
+ "Pool: <code>last_tx_mean</code> for sequence tasks &nbsp;|&nbsp; "
497
+ "Pool: <code>pre_last_tx_mean</code> for next-tx tasks &nbsp;|&nbsp; "
498
+ "~0.5M params per head"
499
+ )}
500
+
501
+ {_phase_card(
502
+ "6",
503
+ "Postprocessing",
504
+ "Fraud logits → sigmoid → probability in [0, 1]; calibrate against the "
505
+ "customer's operational threshold (typical: 70% precision @ 60% recall "
506
+ "for review-queue handoff). Categorical logits → softmax → top-k "
507
+ "distribution. Use the predicted distribution for downstream "
508
+ "decisioning, not just argmax — the runner-up matters when the top-1 "
509
+ "is uncertain. Behavioral attribution: gradient-based saliency on the "
510
+ "per-feature embeddings identifies which input features drove the score.",
511
+ "Fraud: sigmoid(logits) → operational threshold &nbsp;|&nbsp; "
512
+ "Categorical: softmax(logits) → top-k + calibration &nbsp;|&nbsp; "
513
+ "Saliency: ∂loss/∂value_embed identifies driving features"
514
+ )}
515
+ </div>
516
+
517
+ <!-- Training recipe (full-width card) -->
518
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
519
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
520
+ <h3 style="color: {_TEXT}; margin: 0 0 8px 0; font-size: 15px; font-weight: 600;">
521
+ Training Recipe
522
+ </h3>
523
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 12px 0;">
524
+ Single-stage supervised fine-tune on the customer&rsquo;s labelled data — no
525
+ separate pretraining stage. Three trainable parameter groups (LoRA delta,
526
+ per-feature encoder, task heads), three learning rates, because each group
527
+ differs in initialization, parameter scale, and gradient-norm profile.
528
+ </p>
529
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;
530
+ margin-bottom: 12px;">
531
+ <div style="padding: 12px; background: {_BG_CARD_ALT}; border-radius: 8px;">
532
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_BLUE};
533
+ font-weight: 600; margin-bottom: 6px; letter-spacing: 0.05em;">
534
+ LORA GROUP</div>
535
+ <div style="font-family: {_FONT_MONO}; font-size: 11px; color: {_TEXT};
536
+ margin-bottom: 4px;">
537
+ lr = 1e-3 &middot; ~1M params
538
+ </div>
539
+ <div style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5;">
540
+ Low-rank adapters on the backbone&rsquo;s attention projections.
541
+ Initialized so the LoRA path contributes zero at step 0, then steers
542
+ attention behavior toward the customer&rsquo;s distribution. Higher LR
543
+ than the encoder group is fine — the low-rank constraint regularizes
544
+ the update by construction.
545
+ </div>
546
+ </div>
547
+ <div style="padding: 12px; background: {_BG_CARD_ALT}; border-radius: 8px;">
548
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_BLUE};
549
+ font-weight: 600; margin-bottom: 6px; letter-spacing: 0.05em;">
550
+ ENCODER GROUP</div>
551
+ <div style="font-family: {_FONT_MONO}; font-size: 11px; color: {_TEXT};
552
+ margin-bottom: 4px;">
553
+ lr = 3e-4 &middot; ~14M params
554
+ </div>
555
+ <div style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5;">
556
+ Per-feature value tables + feature-type table, from random init on the
557
+ customer&rsquo;s tokenized vocabulary. Lower LR than LoRA because
558
+ random-init embedding matrices destabilize at higher rates;
559
+ high-cardinality tables (10K-vocab merchant) dominate gradient norm if
560
+ not damped.
561
+ </div>
562
+ </div>
563
+ <div style="padding: 12px; background: {_BG_CARD_ALT}; border-radius: 8px;">
564
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_ACCENT_BLUE};
565
+ font-weight: 600; margin-bottom: 6px; letter-spacing: 0.05em;">
566
+ HEADS GROUP</div>
567
+ <div style="font-family: {_FONT_MONO}; font-size: 11px; color: {_TEXT};
568
+ margin-bottom: 4px;">
569
+ lr = 1e-3 &middot; ~2M params
570
+ </div>
571
+ <div style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5;">
572
+ Per-task MLPs (fraud, next-merchant, amount-bucket, MCC), from random
573
+ init. Higher LR is fine — small per-head parameter count, well-conditioned
574
+ loss surface. New downstream tasks attach as additional heads without
575
+ retraining the backbone or the encoder.
576
+ </div>
577
+ </div>
578
+ </div>
579
+ <div style="display: grid; grid-template-columns: auto 1fr; gap: 6px 16px;
580
+ font-size: 12px; padding: 10px 12px; background: {_BG_CARD_ALT};
581
+ border-radius: 8px;">
582
+ <div style="font-family: {_FONT_MONO}; color: {_TEXT_DIM};">Optimizer</div>
583
+ <div style="color: {_TEXT_MUTED};">
584
+ AdamW, &beta; = (0.9, 0.95), weight decay 0.1
585
+ </div>
586
+ <div style="font-family: {_FONT_MONO}; color: {_TEXT_DIM};">Schedule</div>
587
+ <div style="color: {_TEXT_MUTED};">
588
+ 200-step linear warmup, cosine decay to 10% of peak over ~5K steps
589
+ </div>
590
+ <div style="font-family: {_FONT_MONO}; color: {_TEXT_DIM};">Precision</div>
591
+ <div style="color: {_TEXT_MUTED};">
592
+ bf16 forward and backward, fp32 loss accumulation
593
+ </div>
594
+ <div style="font-family: {_FONT_MONO}; color: {_TEXT_DIM};">Multi-task</div>
595
+ <div style="color: {_TEXT_MUTED};">
596
+ fraud 1.0, categorical heads 0.5 each — chosen to match per-task
597
+ gradient norm in the first 200 warmup steps
598
+ </div>
599
+ <div style="font-family: {_FONT_MONO}; color: {_TEXT_DIM};">Compute</div>
600
+ <div style="color: {_TEXT_MUTED};">
601
+ ~2 hours end-to-end on a single A100 at LFM2.5-350M scale
602
+ </div>
603
+ </div>
604
+ </div>
605
+
606
+ <!-- Deployment card (full-width) -->
607
+ <div style="margin-bottom: 16px;">
608
+ {_phase_card(
609
+ "Deploy",
610
+ "Per-Customer Adapter on a Shared Backbone",
611
+ "The deployable per-customer artifact is the trained LoRA delta + "
612
+ "per-feature encoder + task heads. The LFM2.5 base is not included; "
613
+ "it is loaded once per serving GPU from the public weights. At "
614
+ "LFM2.5-350M the artifact is ~190 MB in bf16; the unstripped version "
615
+ "including the base would be ~900 MB. Multi-tenant serving keeps "
616
+ "one backbone resident on the GPU and switches the active LoRA "
617
+ "delta + encoder per request, so a new customer adds a small "
618
+ "adapter rather than a second foundation model. The conv-dominant "
619
+ "backbone quantizes cleanly to INT8 for cost-sensitive deployments. "
620
+ "Same code path runs CPU or GPU.",
621
+ "Per-customer artifact: ~190 MB bf16 at LFM2.5-350M (LoRA + encoder + heads) &nbsp;|&nbsp; "
622
+ "Multi-tenant: shared backbone forward + per-request LoRA switching &nbsp;|&nbsp; "
623
+ "CPU (~5s/inference) or H100 (&lt;100ms), same code &nbsp;|&nbsp; "
624
+ "INT8 quantization clean for conv layers"
625
+ )}
626
+ </div>
627
+
628
+ <!-- Gotchas -->
629
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
630
+ border-radius: {_RADIUS_CARD}; margin-bottom: 20px;">
631
+ <div style="font-size: 14px; font-weight: 600; color: {_TEXT}; margin-bottom: 8px;">
632
+ Configuration Choices That Look Right and Aren&rsquo;t
633
+ </div>
634
+ {_gotcha("1", "Do not compress each transaction to one token",
635
+ "an MLP that averages the 15 feature embeddings into a single "
636
+ "vector destroys the intra-tx fraud signal. Fraud ROC-AUC "
637
+ "collapsed to 0.535. Keep the full T_tx*F stripe.")}
638
+ {_gotcha("2", "Pool the pre-last transaction, not the last, for next-tx heads",
639
+ "pooling the last-tx stripe for next-merchant prediction "
640
+ "leaks the prediction target into the input. Use the prior tx.")}
641
+ {_gotcha("3", "Don't unfreeze the backbone at typical label budgets",
642
+ "full-backbone unfreezing produces lower quality than frozen-plus-LoRA "
643
+ "(fraud ROC-AUC 0.951 → 0.900 on this demo's data). LoRA acts as "
644
+ "effective regularization; lifting it forces overfitting.")}
645
+ {_gotcha("4", "Tied embedding heads need SSL-pretrained value tables",
646
+ "tying the next-merchant head to the encoder's merchant value table "
647
+ "without self-supervised pretraining of those tables reduces "
648
+ "next-merchant top-1 from 7.78% to 3.74%. Use a fresh MLP head "
649
+ "until SSL pretraining anchors the value tables.")}
650
+ {_gotcha("5", "Average per-feature losses; do not sum",
651
+ "summing CE losses across features makes high-cardinality "
652
+ "features (10K-vocab merchant) dominate the gradient. The "
653
+ "low-cardinality features stop training.")}
654
+ {_gotcha("6", "Match the schema fingerprint between training and inference",
655
+ "if the tokenizer's vocab changes between training and "
656
+ "deployment, the encoder's value tables index into a different "
657
+ "semantic space. Embed the fingerprint in checkpoint metadata.")}
658
+ </div>
659
+
660
+ <!-- Engagement model -->
661
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
662
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
663
+ <div style="font-size: 14px; font-weight: 600; color: {_TEXT}; margin-bottom: 10px;">
664
+ Typical Engagement
665
+ </div>
666
+ <table style="width: 100%; border-collapse: collapse;">
667
+ <tr style="border-bottom: 1px solid {_BORDER};">
668
+ <th style="padding: 6px 10px; text-align: left; font-size: 10px;
669
+ color: {_TEXT_DIM}; text-transform: uppercase;
670
+ letter-spacing: 0.05em; font-weight: 600;">Phase</th>
671
+ <th style="padding: 6px 10px; text-align: left; font-size: 10px;
672
+ color: {_TEXT_DIM}; text-transform: uppercase; font-weight: 600;">
673
+ Duration</th>
674
+ <th style="padding: 6px 10px; text-align: left; font-size: 10px;
675
+ color: {_TEXT_DIM}; text-transform: uppercase; font-weight: 600;">
676
+ What Happens</th>
677
+ </tr>
678
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE};">
679
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
680
+ color: {_TEXT};">Discovery</td>
681
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
682
+ color: {_TEXT_MUTED};">1-2 weeks</td>
683
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
684
+ Schema design, data sample (~100K-1M sequences), compliance review,
685
+ architectural fit assessment.
686
+ </td>
687
+ </tr>
688
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE};">
689
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
690
+ color: {_TEXT};">POC</td>
691
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
692
+ color: {_TEXT_MUTED};">1 week</td>
693
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
694
+ Fine-tune encoder + LoRA + heads on customer sample, measurement
695
+ report, go/no-go recommendation.
696
+ </td>
697
+ </tr>
698
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE};">
699
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
700
+ color: {_TEXT};">Production</td>
701
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
702
+ color: {_TEXT_MUTED};">2-3 months</td>
703
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
704
+ Customer engineering team builds with Liquid architectural support,
705
+ weekly design review, scale-up to LFM2.5-1.2B.
706
+ </td>
707
+ </tr>
708
+ <tr>
709
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
710
+ color: {_TEXT};">Scale</td>
711
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
712
+ color: {_TEXT_MUTED};">Ongoing</td>
713
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
714
+ Multi-task expansion (add heads), multi-tenant serving, retraining
715
+ cadence, architecture evolution.
716
+ </td>
717
+ </tr>
718
+ </table>
719
+ </div>
720
+
721
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;">
722
+ Architecture: <a href="https://arxiv.org/abs/2511.23404" style="color: {_TEXT_DIM};
723
+ text-decoration: underline;">arXiv 2511.23404</a> &middot;
724
+ Base weights: <a href="https://huggingface.co/LiquidAI" style="color: {_TEXT_DIM};
725
+ text-decoration: underline;">huggingface.co/LiquidAI</a>
726
+ </div>
727
+ </div>
728
+ """
encoder/src/model/encoder_heads.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encoder-specific downstream head subclass with multi-position pooling.
2
+
3
+ Extends parent's `DownstreamHead` with one new pool strategy:
4
+
5
+ "pre_last_tx_mean" — pool the entire tx_(T-1) stripe (last num_features
6
+ positions BEFORE tx_T), then mean across positions.
7
+
8
+ Why this exists:
9
+
10
+ The encoder non-compress mode emits 15 pseudo-tokens per transaction.
11
+ The parent's `pre_last_tx` strategy reads a SINGLE position
12
+ `hidden[:, -(num_features+1), :]` — for our T=960, num_features=15
13
+ layout that's position 944 = the last feature of tx 62
14
+ (`customer_tenure` per schema ordering).
15
+
16
+ That single position has:
17
+ - Full causal context through tx 62 (good — attention has propagated
18
+ everything relevant).
19
+ - Direct semantics around `customer_tenure` (its value table and
20
+ type-embedding offset).
21
+
22
+ For predicting tx 63's mcc / merchant / amount, the
23
+ `customer_tenure`-anchored representation is suboptimal. The first
24
+ nocompress run showed exactly this: fraud ROC-AUC 0.96 (great), but
25
+ auxiliary head top-1 noticeably below the compress run's holistic-tx
26
+ summary. The information is in the stripe; we're just reading it
27
+ through the wrong feature.
28
+
29
+ `pre_last_tx_mean` pools across all 15 positions of tx 62 — a
30
+ holistic tx-62 summary, much closer to what the compress run's
31
+ single per-tx pseudo-token represented.
32
+
33
+ When `pre_last_tx` collapses to the same thing:
34
+
35
+ For `num_features=1` (compress mode), `pre_last_tx` reads
36
+ `hidden[:, -2, :]` and `pre_last_tx_mean` reads
37
+ `mean(hidden[:, -2:-1, :])` = same single position. So compress mode
38
+ can use either strategy without behavior change; we still default to
39
+ `pre_last_tx` there for clarity.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import torch
45
+ import torch.nn as nn
46
+
47
+ from src.model.task_heads import DownstreamHead, HeadConfig, TiedEmbeddingHead
48
+
49
+
50
+ class EncoderDownstreamHead(DownstreamHead):
51
+ """DownstreamHead with `pre_last_tx_mean` pool strategy added.
52
+
53
+ Forward + extract_targets + compute_loss are all inherited unchanged —
54
+ only `pool()` is overridden to handle the new strategy.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ config: HeadConfig,
60
+ hidden_dim: int,
61
+ num_features: int,
62
+ ) -> None:
63
+ super().__init__(config, hidden_dim, num_features)
64
+
65
+ def pool(self, hidden_states: torch.Tensor) -> torch.Tensor:
66
+ """(B, S, D) → (B, D) via head-specific pooling.
67
+
68
+ Adds `pre_last_tx_mean` to parent's strategies. Falls through to
69
+ parent for `last_tx_mean` and `pre_last_tx`.
70
+ """
71
+ nf = self.num_features
72
+ if self.config.pool_strategy == "pre_last_tx_mean":
73
+ # tx_(T-1) stripe = positions [-2*nf, -nf). For T=960 nf=15:
74
+ # positions 930..944 inclusive (the 15 features of tx 62).
75
+ # For T=64 nf=1: position 62 only (single-element mean).
76
+ stripe = hidden_states[:, -(2 * nf):-nf, :]
77
+ return stripe.mean(dim=1)
78
+ return super().pool(hidden_states)
79
+
80
+
81
+ class EncoderTiedEmbeddingHead(TiedEmbeddingHead):
82
+ """TiedEmbeddingHead with `pre_last_tx_mean` pool strategy added.
83
+
84
+ Why this exists for the encoder:
85
+
86
+ The encoder's per-feature value tables (e.g. merchant_id at
87
+ `vocab_size=10003, dim=d_lfm=1024`) are exactly the right
88
+ projection matrix for a high-cardinality classifier head. Instead
89
+ of learning a fresh `Linear(128, 10003)` from a 128-dim
90
+ bottleneck — which gave only 7.78% top-1 — we share weights with
91
+ the encoder's merchant_id table. This recovers the pattern parent
92
+ observed: a tied embedding head on merchant_id gave 20.8% top-1
93
+ vs 13.8% for the non-tied head.
94
+
95
+ What this changes mechanically:
96
+
97
+ - The classifier matrix `weight` is the encoder's value table
98
+ (shape `(vocab_size, d_lfm)`).
99
+ - Forward pass: pool hidden → adapter MLP (d_lfm → d_lfm → d_lfm)
100
+ → matmul through the value table → logits over that feature's
101
+ vocab.
102
+ - Gradients flow back into the value table from BOTH the input-
103
+ embedding side (during forward of every transaction) AND this
104
+ head (during loss), keeping the table consistent.
105
+
106
+ Only differs from parent's TiedEmbeddingHead in the supported pool
107
+ strategies — adds `pre_last_tx_mean` for the encoder's 15-token-per-tx
108
+ layout.
109
+
110
+ Constraints inherited from parent:
111
+ - `target_type` must start with "feature:N" (the value-table
112
+ dimension this head is tied to).
113
+ - `output_dim` is implicitly the vocab_size of that feature; the
114
+ head config's `output_dim` is unused.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ config: HeadConfig,
120
+ hidden_dim: int,
121
+ num_features: int,
122
+ value_tables: nn.ModuleList,
123
+ ) -> None:
124
+ super().__init__(config, hidden_dim, num_features, value_tables)
125
+
126
+ def pool(self, hidden_states: torch.Tensor) -> torch.Tensor:
127
+ nf = self.num_features
128
+ if self.config.pool_strategy == "pre_last_tx_mean":
129
+ stripe = hidden_states[:, -(2 * nf):-nf, :]
130
+ return stripe.mean(dim=1)
131
+ return super().pool(hidden_states)
encoder/src/model/lfm_pseudo_token_wrapper.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LFM2.5 backbone wrapper that consumes pre-computed pseudo-token embeddings.
2
+
3
+ This is the load-bearing module. It wraps `transformers.Lfm2Model` so the
4
+ pretrained LFM2.5 backbone processes our 64 per-transaction pseudo-tokens as
5
+ if they were 64 text tokens — the same hook LFM2.5-Audio and LFM2.5-VL use
6
+ to feed their continuous-embedding streams into the LM.
7
+
8
+ Why `Lfm2Model` and not `Lfm2ForCausalLM`:
9
+ We don't use the LM head — downstream task heads pool the hidden states
10
+ instead. `Lfm2Model` returns `last_hidden_state` without the vocab
11
+ projection, which is exactly what we want and skips ~67M unused params
12
+ (vocab_size 65536 * d_lfm 1024).
13
+
14
+ The injection hook (verified against transformers/models/lfm2/modeling_lfm2.py
15
+ lines 523-558):
16
+
17
+ Lfm2Model.forward(input_ids=None, inputs_embeds=<our_tensor>) skips
18
+ embed_tokens entirely, applies RoPE to whatever hidden states arrive,
19
+ and runs the full conv+attention stack. The XOR guard at line 533
20
+ requires us to pass exactly one of input_ids / inputs_embeds, so we
21
+ explicitly pass input_ids=None.
22
+
23
+ Frozen-base invariant:
24
+ `freeze_base()` sets requires_grad=False on every parameter at load
25
+ time. LoRA layers, attached AFTER freezing, have requires_grad=True
26
+ by default. The unit tests verify this invariant — if any base
27
+ parameter accumulates a gradient, the test fails.
28
+
29
+ LoRA target modules:
30
+ LFM2's attention modules use `q_proj`, `k_proj`, `v_proj`, `out_proj`
31
+ (NOT LLaMA's `o_proj`). The conv block uses its own `in_proj` and
32
+ `out_proj`; the SwiGLU MLP uses `w1`, `w2`, `w3`. POC default is
33
+ attention-only ("q_proj", "k_proj", "v_proj", "out_proj") at r=16,
34
+ α=32. Escalate to the Spotify-full set at r=64, α=128 if quality is
35
+ capacity-limited.
36
+
37
+ Note on `out_proj` collision:
38
+ `target_modules=["out_proj"]` will match BOTH attention's `out_proj`
39
+ AND the conv block's `out_proj`. For attention-only LoRA, this is
40
+ not what we want. We use a regex or explicit attention-scoped names
41
+ via `target_modules=["q_proj", "k_proj", "v_proj"]` and add
42
+ `out_proj` only when conv-LoRA is intentional. The current default
43
+ is `q_proj/k_proj/v_proj/out_proj`; the test asserts that
44
+ attention-only LoRA does NOT touch conv layers.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from pathlib import Path
50
+
51
+ import torch
52
+ import torch.nn as nn
53
+ from peft import LoraConfig, get_peft_model
54
+ from transformers import Lfm2Model
55
+
56
+ # Canonical LFM2 LoRA target sets. The attention-only POC default targets only
57
+ # the attention proj modules; the Spotify-full set (from v8 production) adds
58
+ # conv `in_proj` and SwiGLU `w1/w2/w3`. Both sets include `out_proj`, which
59
+ # matches both attention `out_proj` AND conv `out_proj` — see Lfm2Attention
60
+ # vs Lfm2ShortConv. We constrain target_modules with the LAYER_PREFIX_REGEX
61
+ # fallback below if attention-only-strict is required.
62
+ ATTENTION_ONLY_TARGETS = ["q_proj", "k_proj", "v_proj"] # + out_proj added below
63
+ SPOTIFY_FULL_TARGETS = ["q_proj", "k_proj", "v_proj", "out_proj", "in_proj", "w1", "w2", "w3"]
64
+
65
+
66
+ class LfmPseudoTokenBackbone(nn.Module):
67
+ """Wraps Lfm2Model to consume pre-computed pseudo-token embeddings.
68
+
69
+ Forward:
70
+ pseudo_tokens: (B, T, d_lfm) float — output of the projection adapter
71
+ attention_mask: (B, T) int or None — padding mask. For our use case
72
+ of fully-populated 64-tx sequences with no padding, None is the
73
+ normal call. Pass attention_mask explicitly if any pseudo-token
74
+ position is logically a "padding" slot.
75
+
76
+ Returns:
77
+ (B, T, d_lfm) float — `last_hidden_state` from the LFM2.5 stack
78
+ after 16 layers (10 conv + 6 attention).
79
+
80
+ Args:
81
+ model_path: HF-format directory containing config.json + safetensors.
82
+ For local-only POC we use ~/Projects/_models/LFM25-350M-Base; for
83
+ HF-hosted models pass a repo ID string.
84
+ lora: LoraConfig instance, or None for frozen-base-only (no LoRA).
85
+ dtype: bfloat16 for training (matches LFM2's pretraining dtype) or
86
+ float32 for CPU smoke tests.
87
+ device_map: "auto" for GPU runs, None for CPU/MPS local smoke runs.
88
+ trust_remote_code: required True for LiquidAI checkpoints that ship
89
+ modeling code alongside weights. Safe for LiquidAI/* paths.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ model_path: str | Path,
95
+ lora: LoraConfig | None = None,
96
+ dtype: torch.dtype = torch.bfloat16,
97
+ device_map: str | None = "auto",
98
+ trust_remote_code: bool = True,
99
+ freeze_base: bool = True,
100
+ ) -> None:
101
+ super().__init__()
102
+ load_kwargs: dict = {"torch_dtype": dtype, "trust_remote_code": trust_remote_code}
103
+ if device_map is not None:
104
+ load_kwargs["device_map"] = device_map
105
+ self.base = Lfm2Model.from_pretrained(str(model_path), **load_kwargs)
106
+
107
+ # Capture d_lfm before freezing for downstream consumers.
108
+ self.d_lfm: int = self.base.config.hidden_size
109
+
110
+ # Freeze controls. The default (freeze_base=True) is the "encoder
111
+ # pattern with a frozen base" — what makes the shared-base-across-
112
+ # customers story possible. Setting freeze_base=False is the
113
+ # stage-2 VL recipe ("unfreeze the base and fine-tune end-to-end"),
114
+ # used as a diagnostic upper-bound experiment.
115
+ if freeze_base:
116
+ self.freeze_base()
117
+
118
+ if lora is not None:
119
+ # get_peft_model wraps `self.base` and registers LoRA layers as
120
+ # trainable. The XOR guard inside Lfm2Model.forward still
121
+ # accepts inputs_embeds + input_ids=None — PEFT does not modify
122
+ # the forward signature, only the attention/MLP module weights.
123
+ self.base = get_peft_model(self.base, lora)
124
+
125
+ def freeze_base(self) -> None:
126
+ """Set requires_grad=False on every base parameter."""
127
+ for p in self.base.parameters():
128
+ p.requires_grad = False
129
+
130
+ def forward(
131
+ self,
132
+ pseudo_tokens: torch.Tensor,
133
+ attention_mask: torch.Tensor | None = None,
134
+ ) -> torch.Tensor:
135
+ # pseudo_tokens: (B, T, d_lfm) — must already be in the base's dtype
136
+ # (caller's responsibility; the projector should produce bf16 on GPU).
137
+ outputs = self.base(
138
+ input_ids=None,
139
+ inputs_embeds=pseudo_tokens,
140
+ attention_mask=attention_mask,
141
+ use_cache=False,
142
+ )
143
+ return outputs.last_hidden_state
144
+ # → (B, T, d_lfm)
145
+
146
+ def trainable_parameters(self) -> int:
147
+ """Number of trainable params (should be LoRA-only when LoRA is on)."""
148
+ return sum(p.numel() for p in self.base.parameters() if p.requires_grad)
149
+
150
+ def total_parameters(self) -> int:
151
+ return sum(p.numel() for p in self.base.parameters())
152
+
153
+
154
+ # Regex matching attention modules only (strict mode). LFM2 attention modules
155
+ # live under `layers.{i}.self_attn.{q_proj,k_proj,v_proj,out_proj}`; conv
156
+ # `out_proj` lives under `layers.{i}.conv.out_proj`. The regex includes
157
+ # `self_attn` in the path to disambiguate.
158
+ STRICT_ATTENTION_REGEX = r".*self_attn\.(q_proj|k_proj|v_proj|out_proj)$"
159
+
160
+
161
+ def build_lora_config(
162
+ r: int = 16,
163
+ alpha: int = 32,
164
+ dropout: float = 0.05,
165
+ target_modules: list[str] | str | None = None,
166
+ strict_attention_only: bool = False,
167
+ ) -> LoraConfig:
168
+ """Build a LoraConfig tuned for the LFM2 attention-LoRA POC default.
169
+
170
+ Default behavior (`target_modules=None`, `strict_attention_only=False`):
171
+ target_modules = ["q_proj", "k_proj", "v_proj", "out_proj"]
172
+ PEFT matches by leaf-module name, so `out_proj` matches BOTH
173
+ attention.out_proj (6 occurrences) AND conv.out_proj (10 occurrences).
174
+ Net LoRA count for LFM2.5-350M: ~1.01M params.
175
+
176
+ We accept this conv.out_proj collision because:
177
+ - The conv layers do local sequence mixing — having them slightly
178
+ adaptable helps with our pseudo-token input distribution.
179
+ - It's 0.22M extra trainable params, dwarfed by the projector (~2.6M)
180
+ and downstream heads.
181
+ - Spotify production uses an even larger set (`SPOTIFY_FULL_TARGETS`).
182
+
183
+ To opt out of conv adaptation, pass `strict_attention_only=True` — this
184
+ selects target modules by regex so only attention paths match.
185
+
186
+ Args:
187
+ r: LoRA rank. 16 for POC, escalate to 64 for production.
188
+ alpha: LoRA scaling. Conventionally 2*r.
189
+ dropout: LoRA dropout. 0.05 keeps regularization light at POC scale.
190
+ target_modules: override the default. Pass `SPOTIFY_FULL_TARGETS`
191
+ (list) to escalate, or a regex string for custom matching.
192
+ strict_attention_only: if True and `target_modules` is None, use
193
+ the `STRICT_ATTENTION_REGEX` so only attention modules get LoRA.
194
+
195
+ Returns:
196
+ LoraConfig ready for `get_peft_model(base, config)`.
197
+ """
198
+ if target_modules is None:
199
+ if strict_attention_only:
200
+ target_modules = STRICT_ATTENTION_REGEX
201
+ else:
202
+ target_modules = ["q_proj", "k_proj", "v_proj", "out_proj"]
203
+ # FEATURE_EXTRACTION task type — we use Lfm2Model (no LM head), and
204
+ # downstream task heads handle the loss. CAUSAL_LM would try to save
205
+ # an lm_head that does not exist on Lfm2Model.
206
+ return LoraConfig(
207
+ r=r,
208
+ lora_alpha=alpha,
209
+ lora_dropout=dropout,
210
+ target_modules=target_modules,
211
+ bias="none",
212
+ task_type="FEATURE_EXTRACTION",
213
+ )
encoder/src/model/transaction_encoder_nocompress.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Non-compressing transaction encoder.
2
+
3
+ Mirrors the parent repo's `StructuredEmbedding` pattern exactly:
4
+ per-feature value tables + feature-type table, summed, expanded to a
5
+ flat sequence of length T_tx * F. The only difference is the output
6
+ dimension — we project directly to `d_lfm` (1024 for LFM2.5-350M)
7
+ instead of parent's `hidden_dim=256`.
8
+
9
+ Why this exists:
10
+ The compressing encoder (TransactionEncoder) outputs 1 pseudo-token
11
+ per transaction (sequence length 64 from 64 transactions). That is
12
+ "encoder-style" — a small MLP collapses 15 features per tx into a
13
+ single 256-dim vector, then a projector lifts to 1024-dim. Fraud
14
+ quality is poor (ROC-AUC 0.53 at 100% labels) — likely because
15
+ intra-transaction feature combinations get averaged into the
16
+ 256-dim bottleneck.
17
+
18
+ This non-compressing variant keeps the full 64*15 = 960-token
19
+ stream. No MLP-level compression. The LFM2.5 base sees the same
20
+ sequence shape parent's structured-feature backbone sees — but on
21
+ its own 1024-dim hidden space, with frozen text-pretrained weights
22
+ plus LoRA. Tests whether the compression was the binding constraint
23
+ on fraud quality.
24
+
25
+ What we GIVE UP relative to the compressing variant:
26
+ - The 15× sequence-length latency advantage
27
+ - The "modality-token" framing (now we're really doing per-feature-
28
+ token, like parent — the recipe is closer to "rebuild parent's
29
+ embedding layer on a bigger backbone" than to LFM2-VL).
30
+
31
+ What we keep:
32
+ - Frozen base (cross-customer shared base story)
33
+ - LoRA on attention
34
+ - The pretrained text backbone
35
+
36
+ Shape contract:
37
+ (B, T_tx, F) int64 → (B, T_tx * F, d_lfm) float
38
+ For default (T_tx=64, F=15): (B, 64, 15) → (B, 960, 1024)
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import torch
44
+ import torch.nn as nn
45
+
46
+ from src.data.schema import SchemaConfig
47
+
48
+
49
+ class StructuredEncoder(nn.Module):
50
+ """Per-feature value embeddings + feature-type embeddings, summed.
51
+
52
+ This is the parent repo's `StructuredEmbedding` at `hidden_dim=d_lfm`.
53
+ Drop-in input-side replacement for the LFM2's `embed_tokens` table —
54
+ same shape contract.
55
+
56
+ Args:
57
+ schema: parent's SchemaConfig.
58
+ d_lfm: LFM2 backbone hidden size. 1024 for LFM2.5-350M, 2048 for
59
+ LFM2.5-1.2B. Must match the LFM wrapper's d_lfm.
60
+
61
+ Forward:
62
+ token_ids: (B, T_tx, F) int64
63
+ returns: (B, T_tx * F, d_lfm) float, dtype matching the
64
+ embedding tables (fp32 by default).
65
+ """
66
+
67
+ def __init__(self, schema: SchemaConfig, d_lfm: int) -> None:
68
+ super().__init__()
69
+ self.num_features = schema.num_features
70
+ self.num_transactions = schema.num_transactions
71
+ self.d_lfm = d_lfm
72
+
73
+ # Per-feature value tables, each sized to that feature's full vocab.
74
+ # No padding_idx — reserved tokens (MASK/OOV/NULL) get learned
75
+ # embeddings like everything else. Total params dominated by
76
+ # merchant_id (10003 * d_lfm = 10.2M for d_lfm=1024).
77
+ self.value_tables = nn.ModuleList(
78
+ [nn.Embedding(f.vocab_size, d_lfm) for f in schema.features],
79
+ )
80
+
81
+ # Feature-type table. Distinguishes "the 5th token is an mcc"
82
+ # from "the 5th token is a merchant_id" — RoPE alone can't carry
83
+ # this since intra-transaction position is arbitrary. Mirrors
84
+ # parent's pattern exactly.
85
+ self.type_table = nn.Embedding(schema.num_features, d_lfm)
86
+
87
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
88
+ # token_ids: (B, T_tx, F) int64
89
+ B, T_tx, F = token_ids.shape
90
+ assert F == self.num_features, (
91
+ f"Expected {self.num_features} features, got {F}"
92
+ )
93
+
94
+ # Type embeddings: (F, d_lfm). Same per-feature offsets added to
95
+ # every transaction's value embedding for that feature.
96
+ type_indices = torch.arange(F, device=token_ids.device)
97
+ type_emb = self.type_table(type_indices)
98
+ # type_emb: (F, d_lfm)
99
+
100
+ # Embed each feature column with its own table, add the type
101
+ # offset, stack into (B, T_tx, F, d_lfm).
102
+ feature_embeddings: list[torch.Tensor] = []
103
+ for f_idx in range(F):
104
+ feat_tokens = token_ids[:, :, f_idx]
105
+ val_emb = self.value_tables[f_idx](feat_tokens)
106
+ # val_emb: (B, T_tx, d_lfm)
107
+ feature_embeddings.append(val_emb + type_emb[f_idx])
108
+
109
+ stacked = torch.stack(feature_embeddings, dim=2)
110
+ # stacked: (B, T_tx, F, d_lfm)
111
+ return stacked.reshape(B, T_tx * F, self.d_lfm)
112
+ # → (B, T_tx * F, d_lfm) — flat sequence the LFM can consume
113
+
114
+ def num_embedding_params(self) -> int:
115
+ """Total params in value + type tables (sanity check)."""
116
+ val = sum(e.weight.numel() for e in self.value_tables)
117
+ typ = self.type_table.weight.numel()
118
+ return val + typ
encoder/src/model/transaction_fm.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator: encoder + projector + LFM2.5 wrapper → unified backbone.
2
+
3
+ This module produces a `MultiHeadModel`-compatible object so the parent
4
+ repo's `validate()`, `compute_losses`, and downstream-head infrastructure
5
+ work without any rewriting. The encoder approach plugs into the parent
6
+ harness as a different "backbone" — same downstream-head contract.
7
+
8
+ The naming dance:
9
+ - `EncoderBackbone` is what `MultiHeadModel.backbone` expects. It exposes
10
+ `backbone_forward(token_ids) → (B, T, d_lfm)`.
11
+ - `build_transaction_fm()` constructs the full
12
+ `MultiHeadModel(backbone=EncoderBackbone, heads=…)` stack from configs.
13
+
14
+ Head pool semantics with T=64, num_features=1:
15
+ - `last_tx_mean` (num_features=1) ⇒ `hidden[:, -1:, :].mean(1)` = last
16
+ pseudo-token = "tx 63's hidden state". Used by fraud (sequence-level).
17
+ - `pre_last_tx` (num_features=1) ⇒ `hidden[:, -2, :]` = "tx 62's hidden
18
+ state". Used by next_merchant / amount_range / mcc — predicts tx 63's
19
+ features from the tx 62 representation (which has causally seen tx 0..62).
20
+
21
+ The semantics line up with the parent's `T=960, num_features=15` layout:
22
+ - Parent last_tx_mean reads positions 945..959 (tx 63's 15 features).
23
+ - Parent pre_last_tx reads position 944 (end of tx 62).
24
+ Encoder version reads the same logical positions at compressed sequence
25
+ length.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ import torch
34
+ import torch.nn as nn
35
+
36
+ from src.data.schema import SchemaConfig
37
+ from src.model.task_heads import (
38
+ AnyHead,
39
+ DownstreamHead,
40
+ HeadConfig,
41
+ MultiHeadModel,
42
+ )
43
+ from encoder.src.model.encoder_heads import EncoderDownstreamHead, EncoderTiedEmbeddingHead
44
+ from encoder.src.model.lfm_pseudo_token_wrapper import (
45
+ LfmPseudoTokenBackbone,
46
+ build_lora_config,
47
+ )
48
+ from encoder.src.model.projection_adapter import ProjectionAdapter
49
+ from encoder.src.model.transaction_encoder import TransactionEncoder
50
+ from encoder.src.model.transaction_encoder_nocompress import StructuredEncoder
51
+
52
+
53
+ class EncoderBackbone(nn.Module):
54
+ """Encoder (+ optional projector) + LFM2.5 wrapper as a single backbone.
55
+
56
+ Implements the `backbone_forward(token_ids: Tensor) -> Tensor` contract
57
+ that the parent's `MultiHeadModel` expects.
58
+
59
+ Two modes, dispatched by the encoder type:
60
+ - **Compress** (TransactionEncoder, default): encoder outputs (B, 64,
61
+ d_encoder=256). Projector lifts to (B, 64, d_lfm=1024). Sequence
62
+ length 64.
63
+ - **Non-compress** (StructuredEncoder): encoder outputs (B, 960,
64
+ d_lfm) directly. No projector. Sequence length 960. Matches the
65
+ parent's structured-feature input shape.
66
+
67
+ The projector is None in non-compress mode. The branch is selected at
68
+ construction time by `build_transaction_fm`.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ encoder: nn.Module,
74
+ projector: ProjectionAdapter | None,
75
+ lfm_wrapper: LfmPseudoTokenBackbone,
76
+ ) -> None:
77
+ super().__init__()
78
+ self.encoder = encoder
79
+ # nn.Module attribute, may be None in non-compress mode.
80
+ self.projector = projector
81
+ self.lfm = lfm_wrapper
82
+ self.d_lfm = lfm_wrapper.d_lfm
83
+
84
+ def backbone_forward(self, token_ids: torch.Tensor) -> torch.Tensor:
85
+ # token_ids: (B, 64, 15) int64
86
+ # encoder output shape depends on mode:
87
+ # compress → (B, 64, d_encoder)
88
+ # nocompress → (B, 960, d_lfm)
89
+ x = self.encoder(token_ids)
90
+
91
+ if self.projector is not None:
92
+ # Compress mode: lift d_encoder → d_lfm.
93
+ x = self.projector(x)
94
+ # x: (B, T, d_lfm) where T = 64 (compress) or 960 (nocompress)
95
+
96
+ # Cast to the LFM base's dtype before injection. The LFM was loaded
97
+ # in bf16 (GPU) or fp32 (CPU); upstream modules produce whatever
98
+ # they want (default fp32). This cast bridges the boundary.
99
+ target_dtype = next(self.lfm.base.parameters()).dtype
100
+ if x.dtype != target_dtype:
101
+ x = x.to(target_dtype)
102
+
103
+ hidden = self.lfm(x)
104
+ # → (B, T, d_lfm) in target_dtype
105
+ return hidden
106
+
107
+ # `MultiHeadModel.forward` calls `self.backbone.backbone_forward`, but
108
+ # for debugging / standalone use we also expose `forward` as an alias.
109
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
110
+ return self.backbone_forward(token_ids)
111
+
112
+
113
+ def build_heads_encoder(
114
+ head_configs: dict[str, dict[str, Any]],
115
+ hidden_dim: int,
116
+ num_features: int,
117
+ value_tables: nn.ModuleList | None = None,
118
+ ) -> dict[str, AnyHead]:
119
+ """Instantiate downstream heads from a config dict.
120
+
121
+ `num_features` is the per-tx token count and shapes the pool strategy:
122
+ - **compress mode**: `num_features=1` (one pseudo-token per tx)
123
+ → `last_tx_mean` pools position -1, `pre_last_tx` pools position -2.
124
+ - **nocompress mode**: `num_features=15` (matches parent's structured
125
+ layout) → `last_tx_mean` pools last 15 positions, `pre_last_tx`
126
+ pools position -(15+1) = -16 = end of tx 62.
127
+
128
+ Two head implementations are dispatched:
129
+ - **EncoderDownstreamHead** (default): fresh MLP from pool → output.
130
+ - **EncoderTiedEmbeddingHead**: shares its classifier matrix with one
131
+ of the encoder's per-feature value tables. Selected by
132
+ `tied: true` on a head's config. Requires `value_tables` to be
133
+ passed (only available in `nocompress` mode where the encoder is a
134
+ `StructuredEncoder`); `compress` mode's TransactionEncoder uses
135
+ d_feat=32 feature embeddings that can't be tied at d_lfm=1024.
136
+
137
+ Heads with `tied: true` MUST have `target_type: "feature:N"` matching
138
+ the value table they share weights with. The head's `output_dim` is
139
+ ignored in tied mode (implicitly the feature's vocab_size).
140
+ """
141
+ heads: dict[str, AnyHead] = {}
142
+ for name, hcfg in head_configs.items():
143
+ hc = HeadConfig(
144
+ name=name,
145
+ output_dim=hcfg["output_dim"],
146
+ loss_type=hcfg["loss"],
147
+ pool_strategy=hcfg["pool"],
148
+ target_type=hcfg["target"],
149
+ weight=hcfg.get("weight", 1.0),
150
+ mlp_hidden=hcfg.get("mlp_hidden", 128),
151
+ dropout=hcfg.get("dropout", 0.1),
152
+ )
153
+ if hcfg.get("tied", False):
154
+ if value_tables is None:
155
+ raise ValueError(
156
+ f"Head '{name}' has tied=true but no value_tables were "
157
+ f"passed. Tied heads are only supported in nocompress "
158
+ f"mode (where the encoder is StructuredEncoder).",
159
+ )
160
+ heads[name] = EncoderTiedEmbeddingHead(
161
+ hc, hidden_dim, num_features=num_features,
162
+ value_tables=value_tables,
163
+ )
164
+ else:
165
+ heads[name] = EncoderDownstreamHead(
166
+ hc, hidden_dim, num_features=num_features,
167
+ )
168
+ return heads
169
+
170
+
171
+ def build_transaction_fm(
172
+ schema: SchemaConfig,
173
+ head_configs: dict[str, dict[str, Any]],
174
+ model_path: str | Path,
175
+ architecture_cfg: dict[str, Any] | None = None,
176
+ encoder_cfg: dict[str, Any] | None = None,
177
+ projector_cfg: dict[str, Any] | None = None,
178
+ lora_cfg: dict[str, Any] | None = None,
179
+ dtype: torch.dtype = torch.bfloat16,
180
+ device_map: str | None = "auto",
181
+ ) -> MultiHeadModel:
182
+ """Construct the full encoder + LFM + heads stack.
183
+
184
+ The architecture mode is selected by
185
+ `architecture_cfg["mode"] ∈ {"compress", "nocompress"}`:
186
+
187
+ - **compress** (default): TransactionEncoder → ProjectionAdapter →
188
+ LFM. One pseudo-token per transaction; sequence length 64.
189
+ - **nocompress**: StructuredEncoder → LFM directly. 15 pseudo-tokens
190
+ per transaction (one per feature); sequence length 960. Mirrors
191
+ parent's structured-feature input shape.
192
+
193
+ Returns a `MultiHeadModel` so it's drop-in compatible with the parent's
194
+ `validate()`, `compute_losses`, and training utilities regardless of mode.
195
+ """
196
+ architecture_cfg = architecture_cfg or {}
197
+ encoder_cfg = encoder_cfg or {}
198
+ projector_cfg = projector_cfg or {}
199
+ lora_cfg = lora_cfg or {}
200
+ mode = architecture_cfg.get("mode", "compress")
201
+
202
+ # Build the LFM wrapper first so we know d_lfm before constructing the
203
+ # encoder/projector. (Compress mode needs d_lfm for the projector;
204
+ # nocompress mode needs it for the StructuredEncoder.)
205
+ lora = None
206
+ if lora_cfg.get("enabled", True):
207
+ # Allow config to override the default target module list. Passing
208
+ # an explicit list (e.g. ["q_proj", ..., "in_proj"]) opts into
209
+ # conv-LoRA. None falls through to build_lora_config's default
210
+ # (attention names with conv.out_proj collision).
211
+ lora = build_lora_config(
212
+ r=lora_cfg.get("r", 16),
213
+ alpha=lora_cfg.get("alpha", 32),
214
+ dropout=lora_cfg.get("dropout", 0.05),
215
+ target_modules=lora_cfg.get("target_modules"),
216
+ strict_attention_only=lora_cfg.get("strict_attention_only", False),
217
+ )
218
+ lfm_wrapper = LfmPseudoTokenBackbone(
219
+ model_path,
220
+ lora=lora,
221
+ dtype=dtype,
222
+ device_map=device_map,
223
+ # `freeze_base=True` is the encoder-pattern default. The stage-2
224
+ # diagnostic (architecture.unfreeze_backbone=true) flips this so
225
+ # the full 354M backbone is end-to-end trainable.
226
+ freeze_base=not architecture_cfg.get("unfreeze_backbone", False),
227
+ )
228
+
229
+ if mode == "compress":
230
+ encoder = TransactionEncoder(
231
+ schema,
232
+ d_feat=encoder_cfg.get("d_feat", 32),
233
+ d_encoder=encoder_cfg.get("d_encoder", 256),
234
+ mlp_hidden=encoder_cfg.get("mlp_hidden", 384),
235
+ )
236
+ projector: ProjectionAdapter | None = ProjectionAdapter(
237
+ d_encoder=encoder_cfg.get("d_encoder", 256),
238
+ d_lfm=lfm_wrapper.d_lfm,
239
+ hidden=projector_cfg.get("hidden", 2 * lfm_wrapper.d_lfm),
240
+ use_layernorm=projector_cfg.get("use_layernorm", True),
241
+ )
242
+ num_features_for_heads = 1
243
+ elif mode == "nocompress":
244
+ encoder = StructuredEncoder(schema, d_lfm=lfm_wrapper.d_lfm)
245
+ projector = None
246
+ # Matches parent: heads pool over 15-feature transaction stripes.
247
+ num_features_for_heads = schema.num_features
248
+ else:
249
+ raise ValueError(
250
+ f"Unknown architecture mode: {mode!r}. Expected 'compress' or 'nocompress'.",
251
+ )
252
+
253
+ backbone = EncoderBackbone(encoder, projector, lfm_wrapper)
254
+ # Tied heads need access to the encoder's per-feature value tables.
255
+ # Only StructuredEncoder (nocompress mode) exposes them at d_lfm; the
256
+ # compress mode's TransactionEncoder has d_feat=32 feature embeddings
257
+ # that can't share weights with a d_lfm-dim classifier.
258
+ value_tables_for_heads = (
259
+ encoder.value_tables if mode == "nocompress" else None
260
+ )
261
+ heads = build_heads_encoder(
262
+ head_configs,
263
+ hidden_dim=lfm_wrapper.d_lfm,
264
+ num_features=num_features_for_heads,
265
+ value_tables=value_tables_for_heads,
266
+ )
267
+
268
+ return MultiHeadModel(backbone=backbone, heads=heads)
requirements.txt CHANGED
@@ -5,3 +5,4 @@ peft>=0.13.0
5
  accelerate>=1.0.0
6
  numpy>=1.26
7
  pyyaml>=6.0
 
 
5
  accelerate>=1.0.0
6
  numpy>=1.26
7
  pyyaml>=6.0
8
+ scikit-learn>=1.5
src/data/generator.py ADDED
@@ -0,0 +1,1022 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic transaction data generator.
2
+
3
+ Design features:
4
+ 1. Merchant catalog: 10K merchants with fixed MCC assignments and Zipf
5
+ popularity. Creates realistic merchant-MCC correlation the model can learn.
6
+ 2. MCC-conditional amounts: transaction amounts are sampled from MCC-specific
7
+ distributions. Restaurants produce $15-80, electronics $200-2000, etc.
8
+ 3. Profile blending: each customer is a mixture of primary + secondary
9
+ profile, not a pure archetype. Continuous behavioral diversity.
10
+ 4. MCC-conditional entry_mode: online merchants -> CNP, restaurants -> tap/chip.
11
+ 5. MCC-conditional hour: restaurants peak at lunch/dinner, transit at commute.
12
+ 6. MCC-conditional is_recurring: subscriptions/streaming are high, restaurants low.
13
+ 7. Customer_tenure-card_product correlation: new customers get basic cards.
14
+ 8. Country-amount correlation: international transactions tend higher value.
15
+
16
+ Output format: token_ids.npy (N,T,F), sequence_labels.npy,
17
+ transaction_labels.npy, split_indices.npz, fingerprint.txt. Drives the
18
+ training pipeline via schema.yaml and model.yaml.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import logging
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+ import yaml
32
+
33
+ from src.data.schema import SchemaConfig, load_schema, NULL_TOKEN, VALUES_START
34
+ from src.data.tokenizer import TransactionTokenizer
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Amount range bins: 16 coarse ranges over the 256 fine-grained amount buckets.
41
+ # Each range covers 16 consecutive buckets. Used as the prediction target for
42
+ # the amount_range fine-tuning head (replaces the 259-class amount head).
43
+ # Dollar labels are approximate (quantile buckets are non-linear).
44
+ # ---------------------------------------------------------------------------
45
+
46
+ NUM_AMOUNT_RANGES: int = 16
47
+ AMOUNT_RANGE_WIDTH: int = 16 # 256 buckets / 16 ranges
48
+
49
+ AMOUNT_RANGE_LABELS: dict[int, str] = {
50
+ 0: "$0-5",
51
+ 1: "$5-15",
52
+ 2: "$15-30",
53
+ 3: "$30-50",
54
+ 4: "$50-80",
55
+ 5: "$80-120",
56
+ 6: "$120-175",
57
+ 7: "$175-250",
58
+ 8: "$250-375",
59
+ 9: "$375-550",
60
+ 10: "$550-800",
61
+ 11: "$800-1.2K",
62
+ 12: "$1.2K-2K",
63
+ 13: "$2K-3.5K",
64
+ 14: "$3.5K-7.5K",
65
+ 15: "$7.5K+",
66
+ }
67
+
68
+
69
+ def amount_bucket_to_range(bucket: np.ndarray) -> np.ndarray:
70
+ """Map 256 fine-grained amount buckets (0-255) to 16 coarse ranges (0-15)."""
71
+ return np.clip(bucket // AMOUNT_RANGE_WIDTH, 0, NUM_AMOUNT_RANGES - 1)
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # MCC-conditional amount distributions (mean_bucket, std_bucket).
76
+ # 256 amount buckets spanning $0.01-$25K in quantile space. These bucket-index
77
+ # ranges are calibrated for behavioral plausibility across MCC categories.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ MCC_AMOUNT_MAP: dict[tuple[int, int], tuple[float, float]] = {
81
+ (0, 3): (25.0, 12.0), # transit, coffee, gas: $3-20
82
+ (4, 9): (45.0, 20.0), # convenience, misc low: $8-50
83
+ (10, 14): (160.0, 55.0), # airlines, hotels, car rental: $80-800
84
+ (15, 19): (50.0, 30.0), # other transport: $10-100
85
+ (20, 24): (80.0, 45.0), # online retail general: $15-200
86
+ (25, 29): (55.0, 30.0), # online retail niche: $10-120
87
+ (30, 34): (55.0, 25.0), # restaurants, bars: $15-100
88
+ (35, 39): (40.0, 20.0), # other food service: $8-60
89
+ (40, 44): (185.0, 55.0), # luxury, dept stores, jewelry: $80-2000
90
+ (45, 49): (70.0, 35.0), # fashion retail: $20-200
91
+ (50, 54): (110.0, 50.0), # office supplies, wholesale: $30-500
92
+ (55, 59): (90.0, 50.0), # professional services: $30-400
93
+ (60, 64): (50.0, 22.0), # grocery, pharmacy: $12-120
94
+ (65, 69): (55.0, 30.0), # healthcare, fitness: $15-150
95
+ (70, 74): (22.0, 12.0), # fast food, entertainment: $4-25
96
+ (75, 79): (35.0, 20.0), # streaming, digital goods: $5-50
97
+ (80, 84): (30.0, 22.0), # subscriptions, SaaS: $5-80
98
+ (85, 89): (100.0, 55.0), # automotive: $25-500
99
+ (90, 94): (95.0, 55.0), # home improvement: $20-600
100
+ (95, 99): (75.0, 50.0), # education, government: $15-500
101
+ }
102
+
103
+
104
+ def _mcc_to_amount_params(mcc_value: int) -> tuple[float, float]:
105
+ """Look up amount distribution params for an MCC value."""
106
+ for (lo, hi), params in MCC_AMOUNT_MAP.items():
107
+ if lo <= mcc_value <= hi:
108
+ return params
109
+ return (60.0, 40.0)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # MCC-conditional entry_mode weights.
114
+ # Indices: 0=card_present, 1=card_not_present, 2=contactless, 3=chip, 4=manual
115
+ # ---------------------------------------------------------------------------
116
+
117
+ MCC_ENTRY_MODE: dict[tuple[int, int], list[float]] = {
118
+ (0, 3): [2, 1, 5, 2, 0], # transit/coffee: contactless heavy
119
+ (10, 14): [1, 6, 1, 1, 0], # travel: mostly online bookings
120
+ (20, 29): [0, 8, 0, 0, 1], # online retail: card-not-present
121
+ (30, 39): [3, 0, 4, 3, 0], # restaurants: contactless/chip
122
+ (40, 49): [3, 2, 3, 2, 0], # fashion/luxury: mixed
123
+ (50, 59): [2, 4, 1, 1, 1], # business: mix of online + in-person
124
+ (60, 69): [3, 1, 4, 3, 0], # grocery/pharmacy: in-person
125
+ (70, 74): [2, 1, 5, 2, 0], # fast food: contactless
126
+ (75, 84): [0, 9, 0, 0, 0], # digital/subscriptions: all online
127
+ (85, 99): [3, 2, 2, 3, 0], # auto/home/edu: mixed
128
+ }
129
+
130
+
131
+ def _mcc_to_entry_mode_weights(mcc_value: int) -> list[float]:
132
+ """Look up entry_mode distribution for an MCC value."""
133
+ for (lo, hi), weights in MCC_ENTRY_MODE.items():
134
+ if lo <= mcc_value <= hi:
135
+ return weights
136
+ return [2, 3, 2, 2, 1]
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # MCC-conditional hour-of-day distributions.
141
+ # Peak hours where a category has higher probability. Applied as a 50% blend
142
+ # with the profile-sampled hour to keep per-customer variation.
143
+ # Format: (peak_hours, peak_weight) -- applied on a uniform-24 base.
144
+ # ---------------------------------------------------------------------------
145
+
146
+ MCC_HOUR_PEAKS: dict[tuple[int, int], tuple[list[int], float]] = {
147
+ (0, 3): ([7, 8, 9, 17, 18], 5.0), # transit/coffee: commute hours
148
+ (10, 14): ([9, 10, 14, 15, 20], 2.5), # travel: business hours + evening
149
+ (20, 29): ([10, 11, 20, 21, 22], 3.0), # online: late morning + evening
150
+ (30, 31): ([12, 13, 18, 19, 20], 6.0), # casual dining: lunch + dinner
151
+ (32, 34): ([19, 20, 21, 22, 23], 5.0), # fine dining/bars: evening + late
152
+ (35, 39): ([11, 12, 13, 17, 18], 4.0), # other food: lunch + early dinner
153
+ (60, 64): ([10, 11, 16, 17, 18], 4.0), # grocery/pharmacy: mid-morning + after work
154
+ (70, 71): ([12, 13, 18, 19, 21], 4.5), # fast food: lunch + dinner + late
155
+ (75, 84): ([0, 1, 6, 7], 2.0), # digital/subs: auto-renew any hour
156
+ }
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # MCC-conditional is_recurring probability.
161
+ # ---------------------------------------------------------------------------
162
+
163
+ MCC_RECURRING_RATE: dict[tuple[int, int], float] = {
164
+ (0, 3): 0.20, # transit passes
165
+ (10, 14): 0.05, # travel: rarely recurring
166
+ (20, 29): 0.12, # online retail: occasional subscriptions
167
+ (30, 39): 0.03, # restaurants: very rarely recurring
168
+ (40, 49): 0.02, # luxury/fashion: almost never
169
+ (50, 54): 0.15, # office supplies: recurring orders
170
+ (60, 64): 0.08, # grocery: delivery subscriptions
171
+ (65, 69): 0.25, # fitness/health: gym memberships
172
+ (70, 74): 0.05, # fast food: rarely
173
+ (75, 79): 0.60, # streaming: almost always recurring
174
+ (80, 84): 0.70, # SaaS/subscriptions: by definition
175
+ (85, 94): 0.05, # auto/home: rarely
176
+ (95, 99): 0.10, # education/gov: tuition installments
177
+ }
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Customer tenure -> card_product correlation.
182
+ # New customers (low tenure bucket) skew toward basic cards.
183
+ # Established customers (high tenure bucket) skew toward premium/rewards.
184
+ # Format: tenure_bucket_threshold -> card_product weights (10 products).
185
+ # ---------------------------------------------------------------------------
186
+
187
+ TENURE_CARD_PRODUCT: list[tuple[int, list[float]]] = [
188
+ (2, [6, 3, 1, 0, 0, 0, 0, 2, 1, 0]), # 0-2: new -> basic/prepaid/virtual
189
+ (5, [3, 4, 3, 1, 0, 1, 1, 1, 1, 0]), # 3-5: moderate -> basic/rewards
190
+ (7, [2, 3, 4, 2, 1, 1, 2, 0, 1, 1]), # 6-7: established -> rewards/business
191
+ (99, [1, 2, 3, 3, 2, 1, 2, 0, 0, 1]), # 8+: long-tenure -> premium/platinum
192
+ ]
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Merchant catalog
197
+ # ---------------------------------------------------------------------------
198
+
199
+ @dataclass
200
+ class MerchantCatalog:
201
+ """10K merchants with MCC, popularity, and characteristic amount range."""
202
+
203
+ merchant_ids: np.ndarray # (num_merchants,) int32
204
+ mcc_assignments: np.ndarray # (num_merchants,) int32
205
+ popularity: np.ndarray # (num_merchants,) float64, sums to 1
206
+ amount_mean: np.ndarray # (num_merchants,) float64, amount bucket mean
207
+ amount_std: np.ndarray # (num_merchants,) float64, amount bucket std
208
+ mcc_to_merchants: dict[int, np.ndarray] # MCC -> array of merchant indices
209
+
210
+ @staticmethod
211
+ def build(
212
+ num_merchants: int,
213
+ num_mccs: int,
214
+ rng: np.random.Generator,
215
+ zipf_exponent: float = 1.2,
216
+ ) -> MerchantCatalog:
217
+ """Build a merchant catalog with MCC assignments, Zipf popularity,
218
+ and per-merchant amount distributions.
219
+
220
+ Each merchant gets a characteristic amount (mean, std) derived from
221
+ its MCC's range with per-merchant variation. Std is kept tight (4-8
222
+ buckets) so the model can learn merchant -> amount.
223
+ """
224
+ merchant_ids = np.arange(num_merchants, dtype=np.int32)
225
+
226
+ mcc_merchant_counts = _sample_mcc_merchant_counts(
227
+ num_merchants, num_mccs, rng,
228
+ )
229
+
230
+ mcc_assignments = np.zeros(num_merchants, dtype=np.int32)
231
+ offset = 0
232
+ for mcc, count in enumerate(mcc_merchant_counts):
233
+ mcc_assignments[offset : offset + count] = mcc
234
+ offset += count
235
+
236
+ rng.shuffle(mcc_assignments)
237
+
238
+ raw_pop = 1.0 / np.arange(1, num_merchants + 1, dtype=np.float64) ** zipf_exponent
239
+ rng.shuffle(raw_pop)
240
+ popularity = raw_pop / raw_pop.sum()
241
+
242
+ amount_mean = np.zeros(num_merchants, dtype=np.float64)
243
+ amount_std = np.zeros(num_merchants, dtype=np.float64)
244
+ for i in range(num_merchants):
245
+ mcc_mean, mcc_std = _mcc_to_amount_params(int(mcc_assignments[i]))
246
+ amount_mean[i] = np.clip(
247
+ mcc_mean + rng.normal(0, mcc_std * 0.3), 1.0, 254.0,
248
+ )
249
+ amount_std[i] = np.clip(rng.uniform(3.0, 7.0), 2.0, 10.0)
250
+
251
+ mcc_to_merchants: dict[int, np.ndarray] = {}
252
+ for mcc in range(num_mccs):
253
+ mask = mcc_assignments == mcc
254
+ mcc_to_merchants[mcc] = np.where(mask)[0].astype(np.int32)
255
+
256
+ return MerchantCatalog(
257
+ merchant_ids=merchant_ids,
258
+ mcc_assignments=mcc_assignments,
259
+ popularity=popularity,
260
+ amount_mean=amount_mean,
261
+ amount_std=amount_std,
262
+ mcc_to_merchants=mcc_to_merchants,
263
+ )
264
+
265
+ def sample_merchants_for_mccs(
266
+ self,
267
+ mcc_values: np.ndarray,
268
+ rng: np.random.Generator,
269
+ ) -> np.ndarray:
270
+ """Given MCC values (any shape), return merchant IDs from that MCC.
271
+
272
+ Falls back to popularity-weighted global sampling when an MCC has
273
+ no assigned merchants (shouldn't happen with proper catalog).
274
+ """
275
+ flat_mccs = mcc_values.ravel()
276
+ result = np.zeros(len(flat_mccs), dtype=np.int32)
277
+
278
+ unique_mccs = np.unique(flat_mccs)
279
+ for mcc in unique_mccs:
280
+ positions = np.where(flat_mccs == mcc)[0]
281
+ merchants = self.mcc_to_merchants.get(int(mcc))
282
+ if merchants is None or len(merchants) == 0:
283
+ result[positions] = rng.integers(0, len(self.merchant_ids), size=len(positions))
284
+ continue
285
+ pop = self.popularity[merchants]
286
+ pop = pop / pop.sum()
287
+ chosen = rng.choice(merchants, size=len(positions), p=pop)
288
+ result[positions] = chosen
289
+
290
+ return result.reshape(mcc_values.shape)
291
+
292
+
293
+ def _sample_mcc_merchant_counts(
294
+ num_merchants: int, num_mccs: int, rng: np.random.Generator,
295
+ ) -> np.ndarray:
296
+ """Distribute merchants across MCCs with realistic category sizes.
297
+
298
+ Restaurants/retail get more merchants (fragmented industries).
299
+ Airlines/utilities get fewer (concentrated industries).
300
+ """
301
+ base_weights = np.ones(num_mccs, dtype=np.float64)
302
+
303
+ category_weights = {
304
+ (0, 3): 0.6, # transit/coffee: moderate
305
+ (4, 9): 0.8,
306
+ (10, 14): 0.3, # airlines/hotels: concentrated
307
+ (15, 19): 0.5,
308
+ (20, 24): 1.5, # online retail: many merchants
309
+ (25, 29): 1.0,
310
+ (30, 34): 2.0, # restaurants: very fragmented
311
+ (35, 39): 1.2,
312
+ (40, 44): 0.5, # luxury/dept: fewer
313
+ (45, 49): 1.3, # fashion: many
314
+ (50, 54): 0.8,
315
+ (55, 59): 0.7,
316
+ (60, 64): 1.0, # grocery: moderate
317
+ (65, 69): 0.8,
318
+ (70, 74): 1.5, # fast food: many
319
+ (75, 79): 0.6,
320
+ (80, 84): 0.5, # subscriptions: concentrated
321
+ (85, 89): 0.7,
322
+ (90, 94): 0.9,
323
+ (95, 99): 0.4, # education/gov: concentrated
324
+ }
325
+
326
+ for (lo, hi), w in category_weights.items():
327
+ lo_clamped = min(lo, num_mccs - 1)
328
+ hi_clamped = min(hi, num_mccs - 1)
329
+ base_weights[lo_clamped : hi_clamped + 1] = w
330
+
331
+ noise = rng.uniform(0.8, 1.2, size=num_mccs)
332
+ weights = base_weights * noise
333
+ weights /= weights.sum()
334
+
335
+ counts = (weights * num_merchants).astype(np.int32)
336
+ remainder = num_merchants - counts.sum()
337
+ if remainder > 0:
338
+ top_idx = np.argsort(weights)[-remainder:]
339
+ counts[top_idx] += 1
340
+ elif remainder < 0:
341
+ top_idx = np.argsort(counts)[remainder:]
342
+ counts[top_idx] -= 1
343
+
344
+ assert counts.sum() == num_merchants
345
+ return counts
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Generated dataset
350
+ # ---------------------------------------------------------------------------
351
+
352
+ @dataclass
353
+ class GeneratedDataset:
354
+ """Output of the generation pipeline."""
355
+
356
+ token_ids: np.ndarray
357
+ sequence_labels: np.ndarray
358
+ transaction_labels: np.ndarray
359
+ amount_range_labels: np.ndarray
360
+ split_indices: dict[str, np.ndarray]
361
+ fingerprint: str
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # Generator
366
+ # ---------------------------------------------------------------------------
367
+
368
+ class DataGenerator:
369
+ """Enhanced data generator with merchant catalog and correlated features."""
370
+
371
+ def __init__(
372
+ self,
373
+ schema_path: str | Path = "data/schema.yaml",
374
+ profiles_path: str | Path = "data/profiles.yaml",
375
+ fraud_path: str | Path = "data/fraud_patterns.yaml",
376
+ null_rates_path: str | Path = "data/null_rates.yaml",
377
+ seed: int = 42,
378
+ blend_fraction: float = 0.25,
379
+ ) -> None:
380
+ self.schema = load_schema(schema_path)
381
+ self._profiles_raw = self._load_yaml(profiles_path)
382
+ self._fraud_raw = self._load_yaml(fraud_path)
383
+ self._null_raw = self._load_yaml(null_rates_path)
384
+ self._config_paths = [
385
+ Path(p) for p in [schema_path, profiles_path, fraud_path, null_rates_path]
386
+ ]
387
+ self.rng = np.random.default_rng(seed)
388
+ self._seed = seed
389
+ self._blend_fraction = blend_fraction
390
+
391
+ em = self.schema.get_feature("entry_mode")
392
+ self._entry_mode_name_to_val: dict[str, int] = {}
393
+ if em.values:
394
+ self._entry_mode_name_to_val = {v: k for k, v in em.values.items()}
395
+
396
+ n_merchants = self.schema.get_feature("merchant_id").num_values
397
+ n_mccs = self.schema.get_feature("mcc").num_values
398
+ self.catalog = MerchantCatalog.build(n_merchants, n_mccs, self.rng)
399
+ logger.info(
400
+ "Built merchant catalog: %d merchants across %d MCCs",
401
+ n_merchants, n_mccs,
402
+ )
403
+
404
+ @staticmethod
405
+ def _load_yaml(path: str | Path) -> dict[str, Any]:
406
+ with open(path) as fh:
407
+ return yaml.safe_load(fh)
408
+
409
+ # --- Distribution sampling ---
410
+
411
+ def _sample_feature(
412
+ self, config: dict[str, Any], num_values: int, shape: tuple[int, int],
413
+ ) -> np.ndarray:
414
+ dist = config["distribution"]
415
+
416
+ if dist == "uniform":
417
+ return self.rng.integers(0, num_values, size=shape, dtype=np.int32)
418
+
419
+ if dist == "weights":
420
+ w = np.array(config["weights"], dtype=np.float64)
421
+ w /= w.sum()
422
+ return self.rng.choice(len(w), size=shape, p=w).astype(np.int32)
423
+
424
+ if dist == "peaks":
425
+ w = np.ones(num_values, dtype=np.float64)
426
+ for p in config["peaks"]:
427
+ if 0 <= p < num_values:
428
+ w[p] = config["peak_weight"]
429
+ w /= w.sum()
430
+ return self.rng.choice(num_values, size=shape, p=w).astype(np.int32)
431
+
432
+ if dist == "normal":
433
+ mean = float(config["mean_bucket"])
434
+ std = max(float(config["std_bucket"]), 0.1)
435
+ vals = self.rng.normal(mean, std, size=shape)
436
+ return np.clip(np.round(vals), 0, num_values - 1).astype(np.int32)
437
+
438
+ if dist == "bernoulli":
439
+ return (self.rng.random(size=shape) < config["p_true"]).astype(np.int32)
440
+
441
+ if dist == "concentrated":
442
+ return self._sample_concentrated(config, num_values, shape)
443
+
444
+ raise ValueError(f"Unknown distribution type: {dist}")
445
+
446
+ def _sample_concentrated(
447
+ self, config: dict[str, Any], num_values: int, shape: tuple[int, int],
448
+ ) -> np.ndarray:
449
+ """Sample from a per-customer concentrated distribution with Zipf skew.
450
+
451
+ Each customer has a fixed set of preferred values. Within the preferred
452
+ set, frequency follows Zipf: the top item gets ~30% of traffic, the
453
+ second ~18%, etc. This creates a learnable frequency ranking the model
454
+ can exploit from sequence history.
455
+ """
456
+ top_n = min(config["top_n"], num_values)
457
+ concentration = config["concentration"]
458
+ zipf_exp = config.get("zipf_exponent", 1.0)
459
+ n_seq, n_tx = shape
460
+
461
+ preferred = np.stack([
462
+ self.rng.choice(num_values, size=top_n, replace=False)
463
+ for _ in range(n_seq)
464
+ ])
465
+
466
+ zipf_weights = 1.0 / np.arange(1, top_n + 1, dtype=np.float64) ** zipf_exp
467
+ zipf_weights /= zipf_weights.sum()
468
+
469
+ use_preferred = self.rng.random(shape) < concentration
470
+ pref_idx = self.rng.choice(top_n, size=shape, p=zipf_weights).astype(np.int32)
471
+ pref_values = preferred[np.arange(n_seq)[:, None], pref_idx]
472
+ unif_values = self.rng.integers(0, num_values, size=shape, dtype=np.int32)
473
+
474
+ return np.where(use_preferred, pref_values, unif_values).astype(np.int32)
475
+
476
+ # --- Core generation ---
477
+
478
+ def generate(self, num_sequences: int = 100_000) -> GeneratedDataset:
479
+ """Generate the full dataset with correlated features."""
480
+ N = num_sequences
481
+ T = self.schema.num_transactions
482
+ F = self.schema.num_features
483
+
484
+ raw_values = np.zeros((N, T, F), dtype=np.int32)
485
+ profiles = self._profiles_raw["profiles"]
486
+ profile_weights = np.array([p["weight"] for p in profiles])
487
+ profile_weights /= profile_weights.sum()
488
+
489
+ primary_indices = self.rng.choice(len(profiles), size=N, p=profile_weights)
490
+ secondary_indices = self.rng.choice(len(profiles), size=N, p=profile_weights)
491
+ blend_mask = self.rng.random((N, T)) < self._blend_fraction
492
+
493
+ mcc_idx = self.schema.feature_index("mcc")
494
+ merchant_idx = self.schema.feature_index("merchant_id")
495
+ amount_idx = self.schema.feature_index("amount")
496
+ entry_mode_idx = self.schema.feature_index("entry_mode")
497
+
498
+ for prof_idx, profile in enumerate(profiles):
499
+ primary_mask = primary_indices == prof_idx
500
+ n_primary = int(primary_mask.sum())
501
+ if n_primary == 0:
502
+ continue
503
+
504
+ logger.debug(
505
+ "Profile '%s': %d primary sequences", profile["name"], n_primary,
506
+ )
507
+
508
+ for feat_idx, feature in enumerate(self.schema.features):
509
+ feat_dist = profile["features"][feature.name]
510
+ values = self._sample_feature(feat_dist, feature.num_values, (n_primary, T))
511
+ raw_values[primary_mask, :, feat_idx] = values
512
+
513
+ for prof_idx, profile in enumerate(profiles):
514
+ secondary_mask = secondary_indices == prof_idx
515
+ n_secondary = int(secondary_mask.sum())
516
+ if n_secondary == 0:
517
+ continue
518
+
519
+ seq_indices = np.where(secondary_mask)[0]
520
+ for feat_idx, feature in enumerate(self.schema.features):
521
+ feat_dist = profile["features"][feature.name]
522
+ alt_values = self._sample_feature(
523
+ feat_dist, feature.num_values, (n_secondary, T),
524
+ )
525
+ for local_i, seq_i in enumerate(seq_indices):
526
+ tx_blend = blend_mask[seq_i]
527
+ raw_values[seq_i, tx_blend, feat_idx] = alt_values[local_i, tx_blend]
528
+
529
+ logger.info("Base features sampled with profile blending (%.0f%%)", self._blend_fraction * 100)
530
+
531
+ # --- Per-customer preferred merchant set + MCC-aligned assignment ---
532
+ mcc_values = raw_values[:, :, mcc_idx]
533
+ merch_params = np.zeros((N, 3), dtype=np.float64)
534
+ for i in range(N):
535
+ prof = profiles[primary_indices[i]]
536
+ md = prof["features"]["merchant_id"]
537
+ merch_params[i, 0] = md.get("top_n", 8)
538
+ merch_params[i, 1] = md.get("concentration", 0.88)
539
+ merch_params[i, 2] = md.get("zipf_exponent", 1.0)
540
+ self._assign_customer_merchants(
541
+ raw_values, mcc_values, merchant_idx, merch_params,
542
+ )
543
+
544
+ # --- Per-merchant amount distributions (tight std 4-7 buckets) ---
545
+ self._apply_merchant_amounts(raw_values, merchant_idx, amount_idx)
546
+
547
+ # --- MCC-conditional entry_mode (50% blend with profile) ---
548
+ self._apply_mcc_conditional_entry_mode(raw_values, mcc_values, entry_mode_idx)
549
+
550
+ # --- MCC-conditional hour-of-day ---
551
+ hour_idx = self.schema.feature_index("hour")
552
+ self._apply_mcc_conditional_hour(raw_values, mcc_values, hour_idx)
553
+
554
+ # --- MCC-conditional is_recurring ---
555
+ recurring_idx = self.schema.feature_index("is_recurring")
556
+ self._apply_mcc_conditional_recurring(raw_values, mcc_values, recurring_idx)
557
+
558
+ # --- Customer_tenure -> card_product correlation ---
559
+ tenure_idx = self.schema.feature_index("customer_tenure")
560
+ card_idx = self.schema.feature_index("card_product")
561
+ self._apply_tenure_card_correlation(raw_values, tenure_idx, card_idx)
562
+
563
+ # --- Country -> amount boost for international ---
564
+ country_idx = self.schema.feature_index("country")
565
+ self._apply_international_amount_boost(raw_values, country_idx, amount_idx)
566
+
567
+ # --- Temporal autocorrelation (MCC and merchant repeat) ---
568
+ self._apply_temporal_autocorrelation(
569
+ raw_values, mcc_idx, merchant_idx, amount_idx,
570
+ )
571
+
572
+ logger.info(
573
+ "Applied feature correlations: merchant-amount, MCC-entry_mode, "
574
+ "MCC-hour, MCC-recurring, tenure-card, country-amount, temporal-repeat",
575
+ )
576
+
577
+ # --- Tokenize ---
578
+ token_ids = (raw_values + VALUES_START).astype(np.int16)
579
+
580
+ # --- Fraud injection ---
581
+ tx_labels = np.zeros((N, T), dtype=np.int8)
582
+ self._inject_fraud(token_ids, tx_labels, primary_indices)
583
+
584
+ # --- Amount range labels (derived after fraud, before NULL) ---
585
+ amount_buckets = token_ids[:, :, amount_idx].astype(np.int32) - VALUES_START
586
+ amount_range_labels = amount_bucket_to_range(amount_buckets).astype(np.int8)
587
+
588
+ # --- NULL injection ---
589
+ self._apply_nulls(token_ids)
590
+
591
+ seq_labels = tx_labels.any(axis=1).astype(np.int8)
592
+ fraud_rate = seq_labels.mean()
593
+ logger.info("Generated %d sequences, fraud rate: %.3f", N, fraud_rate)
594
+
595
+ splits = self._compute_splits(N)
596
+ tokenizer = self._build_tokenizer()
597
+ fingerprint = self._compute_fingerprint(tokenizer, splits)
598
+
599
+ return GeneratedDataset(
600
+ token_ids=token_ids,
601
+ sequence_labels=seq_labels,
602
+ transaction_labels=tx_labels,
603
+ amount_range_labels=amount_range_labels,
604
+ split_indices=splits,
605
+ fingerprint=fingerprint,
606
+ )
607
+
608
+ def _assign_customer_merchants(
609
+ self,
610
+ raw_values: np.ndarray,
611
+ mcc_values: np.ndarray,
612
+ merchant_idx: int,
613
+ per_customer_params: np.ndarray,
614
+ ) -> None:
615
+ """Assign per-customer preferred merchant sets aligned with their MCCs.
616
+
617
+ Each customer gets a profile-specific number of preferred merchants
618
+ drawn from their most-visited MCCs. Concentration and Zipf exponent
619
+ also come from the customer's profile, so a retiree (8 merchants,
620
+ 0.92 concentration) behaves differently from a digital nomad
621
+ (18 merchants, 0.72 concentration).
622
+
623
+ Args:
624
+ per_customer_params: (N, 3) array of [preferred_count, concentration, zipf_exp]
625
+ """
626
+ N, T, _ = raw_values.shape
627
+ mcc_idx_col = self.schema.feature_index("mcc")
628
+
629
+ for i in range(N):
630
+ pref_count = int(per_customer_params[i, 0])
631
+ concentration = float(per_customer_params[i, 1])
632
+ zipf_exp = float(per_customer_params[i, 2])
633
+
634
+ zipf_w = 1.0 / np.arange(1, pref_count + 1, dtype=np.float64) ** zipf_exp
635
+ zipf_w /= zipf_w.sum()
636
+
637
+ customer_mccs = mcc_values[i]
638
+ mcc_counts = np.bincount(customer_mccs[customer_mccs >= 0], minlength=100)
639
+ top_mccs = np.argsort(mcc_counts)[-pref_count:][::-1]
640
+ top_mccs = top_mccs[mcc_counts[top_mccs] > 0]
641
+
642
+ preferred_merchants: list[int] = []
643
+ for mcc_val in top_mccs:
644
+ candidates = self.catalog.mcc_to_merchants.get(int(mcc_val), np.array([]))
645
+ if len(candidates) == 0:
646
+ continue
647
+ pop = self.catalog.popularity[candidates]
648
+ pop = pop / pop.sum()
649
+ n_from_mcc = max(1, pref_count // len(top_mccs))
650
+ n_from_mcc = min(n_from_mcc, len(candidates))
651
+ chosen = self.rng.choice(candidates, size=n_from_mcc, replace=False, p=pop)
652
+ preferred_merchants.extend(chosen.tolist())
653
+
654
+ if len(preferred_merchants) == 0:
655
+ raw_values[i, :, merchant_idx] = self.catalog.sample_merchants_for_mccs(
656
+ customer_mccs, self.rng,
657
+ )
658
+ continue
659
+
660
+ pref_arr = np.array(preferred_merchants[:pref_count], dtype=np.int32)
661
+ n_pref = len(pref_arr)
662
+ pref_mccs = self.catalog.mcc_assignments[pref_arr]
663
+
664
+ pref_zipf = zipf_w[:n_pref].copy()
665
+ pref_zipf /= pref_zipf.sum()
666
+
667
+ for t in range(T):
668
+ tx_mcc = int(customer_mccs[t])
669
+ if self.rng.random() < concentration:
670
+ same_mcc_mask = pref_mccs == tx_mcc
671
+ if same_mcc_mask.any():
672
+ candidates_idx = np.where(same_mcc_mask)[0]
673
+ w = pref_zipf[candidates_idx]
674
+ w /= w.sum()
675
+ chosen = self.rng.choice(candidates_idx, p=w)
676
+ raw_values[i, t, merchant_idx] = pref_arr[chosen]
677
+ else:
678
+ chosen = self.rng.choice(n_pref, p=pref_zipf)
679
+ raw_values[i, t, merchant_idx] = pref_arr[chosen]
680
+ raw_values[i, t, mcc_idx_col] = int(pref_mccs[chosen])
681
+ else:
682
+ candidates = self.catalog.mcc_to_merchants.get(tx_mcc, np.array([]))
683
+ if len(candidates) > 0:
684
+ pop = self.catalog.popularity[candidates]
685
+ pop = pop / pop.sum()
686
+ raw_values[i, t, merchant_idx] = self.rng.choice(candidates, p=pop)
687
+ else:
688
+ raw_values[i, t, merchant_idx] = self.rng.integers(0, len(self.catalog.merchant_ids))
689
+
690
+ def _apply_merchant_amounts(
691
+ self,
692
+ raw_values: np.ndarray,
693
+ merchant_idx: int,
694
+ amount_idx: int,
695
+ ) -> None:
696
+ """Sample amounts from per-merchant distributions (tight std 4-7 buckets).
697
+
698
+ Each merchant has a characteristic (mean, std) derived from its MCC
699
+ category with per-merchant variation. This creates a strong
700
+ merchant->amount signal the model can learn.
701
+ """
702
+ N, T, _ = raw_values.shape
703
+ num_amount = self.schema.features[amount_idx].num_values
704
+ flat_merchants = raw_values[:, :, merchant_idx].ravel()
705
+
706
+ means = self.catalog.amount_mean[flat_merchants]
707
+ stds = self.catalog.amount_std[flat_merchants]
708
+ vals = self.rng.normal(means, stds)
709
+ vals = np.clip(np.round(vals), 0, num_amount - 1).astype(np.int32)
710
+ raw_values[:, :, amount_idx] = vals.reshape(N, T)
711
+
712
+ def _apply_mcc_conditional_entry_mode(
713
+ self,
714
+ raw_values: np.ndarray,
715
+ mcc_values: np.ndarray,
716
+ entry_mode_idx: int,
717
+ ) -> None:
718
+ """Blend MCC-conditional entry_mode with profile-sampled values (50/50)."""
719
+ N, T, _ = raw_values.shape
720
+ num_em = self.schema.features[entry_mode_idx].num_values
721
+ use_mcc = self.rng.random((N, T)) < 0.5
722
+
723
+ for (lo, hi), weights in MCC_ENTRY_MODE.items():
724
+ mask = (mcc_values >= lo) & (mcc_values <= hi) & use_mcc
725
+ n_matching = int(mask.sum())
726
+ if n_matching == 0:
727
+ continue
728
+ w = np.array(weights, dtype=np.float64)
729
+ w /= w.sum()
730
+ vals = self.rng.choice(len(w), size=n_matching, p=w).astype(np.int32)
731
+ raw_values[:, :, entry_mode_idx][mask] = vals
732
+
733
+ def _apply_mcc_conditional_hour(
734
+ self,
735
+ raw_values: np.ndarray,
736
+ mcc_values: np.ndarray,
737
+ hour_idx: int,
738
+ ) -> None:
739
+ """Blend MCC-conditional hour peaks with profile-sampled hours (50/50)."""
740
+ N, T, _ = raw_values.shape
741
+ use_mcc = self.rng.random((N, T)) < 0.5
742
+
743
+ for (lo, hi), (peak_hours, peak_weight) in MCC_HOUR_PEAKS.items():
744
+ mask = (mcc_values >= lo) & (mcc_values <= hi) & use_mcc
745
+ n_matching = int(mask.sum())
746
+ if n_matching == 0:
747
+ continue
748
+ w = np.ones(24, dtype=np.float64)
749
+ for h in peak_hours:
750
+ w[h] = peak_weight
751
+ w /= w.sum()
752
+ vals = self.rng.choice(24, size=n_matching, p=w).astype(np.int32)
753
+ raw_values[:, :, hour_idx][mask] = vals
754
+
755
+ def _apply_mcc_conditional_recurring(
756
+ self,
757
+ raw_values: np.ndarray,
758
+ mcc_values: np.ndarray,
759
+ recurring_idx: int,
760
+ ) -> None:
761
+ """Override is_recurring based on MCC category (70% weight)."""
762
+ N, T, _ = raw_values.shape
763
+ use_mcc = self.rng.random((N, T)) < 0.7
764
+
765
+ for (lo, hi), rate in MCC_RECURRING_RATE.items():
766
+ mask = (mcc_values >= lo) & (mcc_values <= hi) & use_mcc
767
+ n_matching = int(mask.sum())
768
+ if n_matching == 0:
769
+ continue
770
+ vals = (self.rng.random(n_matching) < rate).astype(np.int32)
771
+ raw_values[:, :, recurring_idx][mask] = vals
772
+
773
+ def _apply_tenure_card_correlation(
774
+ self,
775
+ raw_values: np.ndarray,
776
+ tenure_idx: int,
777
+ card_idx: int,
778
+ ) -> None:
779
+ """Correlate card_product with customer_tenure (60% blend)."""
780
+ N, T, _ = raw_values.shape
781
+ tenure_values = raw_values[:, :, tenure_idx]
782
+ use_corr = self.rng.random((N, T)) < 0.6
783
+ num_cards = self.schema.features[card_idx].num_values
784
+
785
+ for threshold, weights in TENURE_CARD_PRODUCT:
786
+ prev_threshold = 0
787
+ for prev_t, _ in TENURE_CARD_PRODUCT:
788
+ if prev_t < threshold:
789
+ prev_threshold = prev_t + 1
790
+ break
791
+
792
+ if threshold == TENURE_CARD_PRODUCT[0][0]:
793
+ mask = (tenure_values <= threshold) & use_corr
794
+ else:
795
+ lower = 0
796
+ for i, (t, _) in enumerate(TENURE_CARD_PRODUCT):
797
+ if t == threshold and i > 0:
798
+ lower = TENURE_CARD_PRODUCT[i - 1][0] + 1
799
+ break
800
+ mask = (tenure_values >= lower) & (tenure_values <= threshold) & use_corr
801
+
802
+ n_matching = int(mask.sum())
803
+ if n_matching == 0:
804
+ continue
805
+ w = np.array(weights[:num_cards], dtype=np.float64)
806
+ w /= w.sum()
807
+ vals = self.rng.choice(num_cards, size=n_matching, p=w).astype(np.int32)
808
+ raw_values[:, :, card_idx][mask] = vals
809
+
810
+ def _apply_international_amount_boost(
811
+ self,
812
+ raw_values: np.ndarray,
813
+ country_idx: int,
814
+ amount_idx: int,
815
+ ) -> None:
816
+ """International transactions (country > 0) get a 30% amount boost."""
817
+ country_values = raw_values[:, :, country_idx]
818
+ num_amount = self.schema.features[amount_idx].num_values
819
+ international = country_values > 0
820
+ n_intl = int(international.sum())
821
+ if n_intl == 0:
822
+ return
823
+ current = raw_values[:, :, amount_idx][international].astype(np.float64)
824
+ boosted = current * 1.3 + self.rng.normal(0, 5, size=n_intl)
825
+ boosted = np.clip(np.round(boosted), 0, num_amount - 1).astype(np.int32)
826
+ raw_values[:, :, amount_idx][international] = boosted
827
+
828
+ def _apply_temporal_autocorrelation(
829
+ self,
830
+ raw_values: np.ndarray,
831
+ mcc_idx: int,
832
+ merchant_idx: int,
833
+ amount_idx: int,
834
+ ) -> None:
835
+ """Apply Markov-style temporal dependencies within each sequence.
836
+
837
+ For each transaction t > 0:
838
+ - P(repeat merchant+MCC from t-1) = 0.20 (same store again)
839
+ - P(repeat MCC only from t-1) = 0.10 (same category, maybe same store)
840
+ - If repeating, amount stays close to previous (std 3 buckets)
841
+ """
842
+ N, T, _ = raw_values.shape
843
+ num_amount = self.schema.features[amount_idx].num_values
844
+
845
+ for t in range(1, T):
846
+ merchant_repeat = self.rng.random(N) < 0.20
847
+ raw_values[merchant_repeat, t, merchant_idx] = raw_values[merchant_repeat, t - 1, merchant_idx]
848
+ raw_values[merchant_repeat, t, mcc_idx] = raw_values[merchant_repeat, t - 1, mcc_idx]
849
+
850
+ mcc_only = (self.rng.random(N) < 0.10) & ~merchant_repeat
851
+ raw_values[mcc_only, t, mcc_idx] = raw_values[mcc_only, t - 1, mcc_idx]
852
+
853
+ repeat_mask = merchant_repeat | mcc_only
854
+ n_repeating = int(repeat_mask.sum())
855
+ if n_repeating > 0:
856
+ prev_amt = raw_values[repeat_mask, t - 1, amount_idx].astype(np.float64)
857
+ new_amt = prev_amt + self.rng.normal(0, 3.0, size=n_repeating)
858
+ new_amt = np.clip(np.round(new_amt), 0, num_amount - 1).astype(np.int32)
859
+ raw_values[repeat_mask, t, amount_idx] = new_amt
860
+
861
+ # --- Fraud injection ---
862
+
863
+ def _inject_fraud(
864
+ self,
865
+ token_ids: np.ndarray,
866
+ tx_labels: np.ndarray,
867
+ profile_indices: np.ndarray,
868
+ ) -> None:
869
+ N = token_ids.shape[0]
870
+ fraud_rate = self._fraud_raw["overall_fraud_rate"]
871
+ patterns = self._fraud_raw["patterns"]
872
+ pattern_weights = np.array([p["weight"] for p in patterns])
873
+ pattern_weights /= pattern_weights.sum()
874
+
875
+ fraud_seq_mask = self.rng.random(N) < fraud_rate
876
+ fraud_indices = np.where(fraud_seq_mask)[0]
877
+
878
+ if len(fraud_indices) == 0:
879
+ return
880
+
881
+ pattern_assignments = self.rng.choice(
882
+ len(patterns), size=len(fraud_indices), p=pattern_weights,
883
+ )
884
+
885
+ for i, seq_idx in enumerate(fraud_indices):
886
+ pattern = patterns[pattern_assignments[i]]
887
+ self._inject_single_fraud(
888
+ token_ids[seq_idx], tx_labels[seq_idx], pattern,
889
+ )
890
+
891
+ def _inject_single_fraud(
892
+ self,
893
+ seq_tokens: np.ndarray,
894
+ seq_tx_labels: np.ndarray,
895
+ pattern: dict[str, Any],
896
+ ) -> None:
897
+ T = seq_tokens.shape[0]
898
+ inj = pattern["injection"]
899
+ lo, hi = inj["num_transactions"]
900
+ num_fraud_tx = self.rng.integers(lo, hi + 1)
901
+ num_fraud_tx = min(num_fraud_tx, T)
902
+
903
+ if inj["position"] == "late":
904
+ max_start = max(T // 2, T - num_fraud_tx)
905
+ start = self.rng.integers(T // 2, max_start + 1)
906
+ else:
907
+ start = self.rng.integers(0, max(T - num_fraud_tx + 1, 1))
908
+
909
+ end = min(start + num_fraud_tx, T)
910
+ seq_tx_labels[start:end] = 1
911
+
912
+ overrides = pattern.get("feature_overrides", {})
913
+ blend = overrides.pop("blend_with_profile", False) if "blend_with_profile" in overrides else False
914
+ span_len = end - start
915
+
916
+ for feat_name, feat_dist in overrides.items():
917
+ feat_idx = self.schema.feature_index(feat_name)
918
+ feat = self.schema.features[feat_idx]
919
+ fraud_values = self._sample_feature(feat_dist, feat.num_values, (1, span_len))
920
+ fraud_tokens = (fraud_values[0] + VALUES_START).astype(np.int16)
921
+
922
+ if blend:
923
+ keep_mask = self.rng.random(span_len) < 0.5
924
+ fraud_tokens[keep_mask] = seq_tokens[start:end, feat_idx][keep_mask]
925
+
926
+ seq_tokens[start:end, feat_idx] = fraud_tokens
927
+
928
+ # --- NULL injection ---
929
+
930
+ def _apply_nulls(self, token_ids: np.ndarray) -> None:
931
+ N, T, _ = token_ids.shape
932
+ null_rates = self._null_raw["null_rates"]
933
+ entry_mode_idx = self.schema.feature_index("entry_mode")
934
+ entry_mode_vals = token_ids[:, :, entry_mode_idx].astype(np.int32) - VALUES_START
935
+
936
+ for feat_idx, feature in enumerate(self.schema.features):
937
+ rate_config = null_rates.get(feature.name, 0.0)
938
+
939
+ if isinstance(rate_config, (int, float)):
940
+ if rate_config > 0:
941
+ null_mask = self.rng.random((N, T)) < rate_config
942
+ token_ids[:, :, feat_idx][null_mask] = NULL_TOKEN
943
+ elif isinstance(rate_config, dict):
944
+ null_mask = self.rng.random((N, T)) < rate_config.get("default", 0.0)
945
+
946
+ for cond in rate_config.get("conditional", []):
947
+ when_modes = cond["when"]["entry_mode"]
948
+ if isinstance(when_modes, str):
949
+ when_modes = [when_modes]
950
+ mode_vals = [self._entry_mode_name_to_val[m] for m in when_modes]
951
+ cond_positions = np.isin(entry_mode_vals, mode_vals)
952
+ cond_rand = self.rng.random((N, T))
953
+ null_mask[cond_positions] = cond_rand[cond_positions] < cond["rate"]
954
+
955
+ token_ids[:, :, feat_idx][null_mask] = NULL_TOKEN
956
+
957
+ # --- Splits and fingerprint ---
958
+
959
+ def _compute_splits(self, num_sequences: int) -> dict[str, np.ndarray]:
960
+ perm = self.rng.permutation(num_sequences)
961
+ n_test = int(num_sequences * 0.10)
962
+ n_val = int(num_sequences * 0.05)
963
+
964
+ test_idx = np.sort(perm[:n_test])
965
+ val_idx = np.sort(perm[n_test : n_test + n_val])
966
+ train_idx = np.sort(perm[n_test + n_val :])
967
+
968
+ assert len(test_idx) + len(val_idx) + len(train_idx) == num_sequences
969
+ return {"train": train_idx, "val": val_idx, "test": test_idx}
970
+
971
+ def _build_tokenizer(self) -> TransactionTokenizer:
972
+ tokenizer = TransactionTokenizer(self.schema)
973
+ for feature in self.schema.features:
974
+ if feature.type == "bucketed":
975
+ tokenizer.get_feature_tokenizer(feature.name).fit_uniform_from_range()
976
+ return tokenizer
977
+
978
+ def _compute_fingerprint(
979
+ self, tokenizer: TransactionTokenizer, splits: dict[str, np.ndarray],
980
+ ) -> str:
981
+ hasher = hashlib.sha256()
982
+ for path in sorted(self._config_paths, key=str):
983
+ with open(path, "rb") as fh:
984
+ hasher.update(fh.read())
985
+ state_bytes = json.dumps(tokenizer.get_state(), sort_keys=True).encode("utf-8")
986
+ hasher.update(state_bytes)
987
+ for key in sorted(splits.keys()):
988
+ hasher.update(splits[key].tobytes())
989
+ hasher.update(str(self._seed).encode("utf-8"))
990
+ return hasher.hexdigest()
991
+
992
+ # --- Persistence ---
993
+
994
+ def save_dataset(
995
+ self, dataset: GeneratedDataset, output_dir: str | Path = "data/synthetic",
996
+ ) -> None:
997
+ out = Path(output_dir)
998
+ out.mkdir(parents=True, exist_ok=True)
999
+
1000
+ np.save(out / "token_ids.npy", dataset.token_ids)
1001
+ np.save(out / "sequence_labels.npy", dataset.sequence_labels)
1002
+ np.save(out / "transaction_labels.npy", dataset.transaction_labels)
1003
+ np.save(out / "amount_range_labels.npy", dataset.amount_range_labels)
1004
+
1005
+ np.savez(
1006
+ out / "split_indices.npz",
1007
+ train=dataset.split_indices["train"],
1008
+ val=dataset.split_indices["val"],
1009
+ test=dataset.split_indices["test"],
1010
+ )
1011
+
1012
+ tokenizer = self._build_tokenizer()
1013
+ tokenizer.save_state(out / "tokenizer_state.json")
1014
+
1015
+ with open(out / "fingerprint.txt", "w") as fh:
1016
+ fh.write(dataset.fingerprint + "\n")
1017
+
1018
+ token_mb = dataset.token_ids.nbytes / 1024 / 1024
1019
+ logger.info(
1020
+ "Saved dataset to %s: token_ids=%.1f MB, fingerprint=%s",
1021
+ out, token_mb, dataset.fingerprint[:16],
1022
+ )
src/data/tokenizer.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic value-to-token mapping for structured transaction features.
2
+
3
+ Each feature has its own tokenizer that maps raw values to token IDs using
4
+ the reserved token convention (D6): 0=MASK, 1=OOV, 2=NULL, 3+=real values.
5
+ Categorical features map directly; bucketed features use quantile or uniform
6
+ boundaries computed during fit().
7
+
8
+ OOV handling (D3): values outside the known range map to OOV token (1) and
9
+ increment a per-feature counter logged during eval.
10
+ """
11
+
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from src.data.schema import (
20
+ FeatureSchema,
21
+ SchemaConfig,
22
+ MASK_TOKEN,
23
+ NULL_TOKEN,
24
+ OOV_TOKEN,
25
+ VALUES_START,
26
+ load_schema,
27
+ )
28
+
29
+
30
+ class FeatureTokenizer:
31
+ """Tokenizer for a single feature. Handles encode, decode, and OOV tracking."""
32
+
33
+ def __init__(self, schema: FeatureSchema) -> None:
34
+ self._schema = schema
35
+ self._boundaries: np.ndarray | None = None
36
+ self._oov_count: int = 0
37
+ self._fitted: bool = schema.type != "bucketed"
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ return self._schema.name
42
+
43
+ @property
44
+ def vocab_size(self) -> int:
45
+ return self._schema.vocab_size
46
+
47
+ @property
48
+ def is_fitted(self) -> bool:
49
+ return self._fitted
50
+
51
+ @property
52
+ def oov_count(self) -> int:
53
+ return self._oov_count
54
+
55
+ def reset_oov_count(self) -> None:
56
+ self._oov_count = 0
57
+
58
+ def fit(self, values: np.ndarray) -> None:
59
+ """Compute bucket boundaries from data. Only valid for bucketed features.
60
+
61
+ Args:
62
+ values: 1-D array of raw continuous values to compute boundaries from.
63
+ """
64
+ assert self._schema.type == "bucketed", (
65
+ f"fit() only applies to bucketed features, not {self._schema.type}"
66
+ )
67
+ assert self._schema.bucket_range is not None
68
+ assert self._schema.bucket_method is not None
69
+
70
+ num_buckets = self._schema.num_values
71
+
72
+ if self._schema.bucket_method == "quantile":
73
+ quantiles = np.linspace(0.0, 1.0, num_buckets + 1)
74
+ self._boundaries = np.quantile(values, quantiles).astype(np.float64)
75
+ elif self._schema.bucket_method == "uniform":
76
+ lo, hi = self._schema.bucket_range
77
+ self._boundaries = np.linspace(lo, hi, num_buckets + 1, dtype=np.float64)
78
+ else:
79
+ raise ValueError(f"Unknown bucket_method: {self._schema.bucket_method}")
80
+
81
+ self._fitted = True
82
+
83
+ def fit_uniform_from_range(self) -> None:
84
+ """Compute uniform boundaries directly from the schema's bucket_range."""
85
+ assert self._schema.type == "bucketed"
86
+ assert self._schema.bucket_range is not None
87
+ lo, hi = self._schema.bucket_range
88
+ self._boundaries = np.linspace(
89
+ lo, hi, self._schema.num_values + 1, dtype=np.float64
90
+ )
91
+ self._fitted = True
92
+
93
+ def encode(self, values: np.ndarray) -> np.ndarray:
94
+ """Map raw values to token IDs.
95
+
96
+ Args:
97
+ values: array of raw feature values (any shape).
98
+
99
+ Returns:
100
+ int16 array of token IDs, same shape as input.
101
+ """
102
+ assert self._fitted, f"Feature '{self.name}' not fitted. Call fit() first."
103
+ original_shape = values.shape
104
+ flat = values.ravel()
105
+
106
+ if self._schema.type == "bucketed":
107
+ token_ids, oov_count = self._encode_bucketed(flat)
108
+ else:
109
+ token_ids, oov_count = self._encode_categorical(flat)
110
+
111
+ self._oov_count += oov_count
112
+ return token_ids.reshape(original_shape)
113
+
114
+ def decode(self, token_ids: np.ndarray) -> np.ndarray:
115
+ """Map token IDs back to values. Bucketed features return bucket centers.
116
+
117
+ Special tokens (MASK, OOV, NULL) decode to NaN.
118
+
119
+ Args:
120
+ token_ids: array of token IDs (any shape).
121
+
122
+ Returns:
123
+ float64 array of decoded values, same shape as input.
124
+ """
125
+ original_shape = token_ids.shape
126
+ flat = token_ids.ravel().astype(np.int64)
127
+ result = np.full(len(flat), np.nan, dtype=np.float64)
128
+
129
+ value_mask = flat >= VALUES_START
130
+ value_indices = flat[value_mask] - VALUES_START
131
+
132
+ if self._schema.type == "bucketed" and self._boundaries is not None:
133
+ centers = (self._boundaries[:-1] + self._boundaries[1:]) / 2.0
134
+ valid = value_indices < len(centers)
135
+ result_positions = np.where(value_mask)[0]
136
+ result[result_positions[valid]] = centers[value_indices[valid]]
137
+ else:
138
+ result[value_mask] = value_indices.astype(np.float64)
139
+
140
+ return result.reshape(original_shape)
141
+
142
+ def _encode_categorical(self, values: np.ndarray) -> tuple[np.ndarray, int]:
143
+ """Encode categorical/binary values. Returns (token_ids, oov_count)."""
144
+ int_values = values.astype(np.int64)
145
+ oov_mask = (int_values < 0) | (int_values >= self._schema.num_values)
146
+ token_ids = (int_values + VALUES_START).astype(np.int16)
147
+ token_ids[oov_mask] = OOV_TOKEN
148
+ return token_ids, int(oov_mask.sum())
149
+
150
+ def _encode_bucketed(self, values: np.ndarray) -> tuple[np.ndarray, int]:
151
+ """Encode bucketed values using pre-computed boundaries.
152
+
153
+ Returns (token_ids, oov_count). Uses np.digitize on internal boundaries
154
+ so bucket index i covers [boundaries[i], boundaries[i+1]).
155
+ The last bucket includes its upper bound: [boundaries[-2], boundaries[-1]].
156
+ """
157
+ assert self._boundaries is not None
158
+ internal_bins = self._boundaries[1:-1]
159
+ bucket_idx = np.digitize(values, internal_bins)
160
+ # bucket_idx in [0, num_values - 1] for values within boundary range.
161
+ # Can be num_values for values above the last internal bin, but np.digitize
162
+ # with N-1 internal bins returns at most N-1 for values < boundaries[-1].
163
+ # Values exactly at boundaries[-1] get num_values - 1 (last bucket).
164
+ bucket_idx = np.clip(bucket_idx, 0, self._schema.num_values - 1)
165
+
166
+ oov_mask = (values < self._boundaries[0]) | (values > self._boundaries[-1])
167
+ token_ids = (bucket_idx + VALUES_START).astype(np.int16)
168
+ token_ids[oov_mask] = OOV_TOKEN
169
+ return token_ids, int(oov_mask.sum())
170
+
171
+ def get_state(self) -> dict[str, Any]:
172
+ """Serializable state for fingerprinting and persistence."""
173
+ state: dict[str, Any] = {
174
+ "name": self._schema.name,
175
+ "type": self._schema.type,
176
+ "num_values": self._schema.num_values,
177
+ "vocab_size": self._schema.vocab_size,
178
+ }
179
+ if self._boundaries is not None:
180
+ state["boundaries"] = self._boundaries.tolist()
181
+ return state
182
+
183
+
184
+ class TransactionTokenizer:
185
+ """Orchestrates tokenization across all features in the schema.
186
+
187
+ Usage:
188
+ schema = load_schema("data/schema.yaml")
189
+ tokenizer = TransactionTokenizer(schema)
190
+
191
+ # Fit bucketed features from raw data
192
+ tokenizer.fit_feature("amount", raw_amounts)
193
+ tokenizer.fit_feature("days_since_last", raw_days)
194
+
195
+ # Encode
196
+ token_ids = tokenizer.encode_feature("amount", raw_values)
197
+
198
+ # Save state and compute fingerprint
199
+ tokenizer.save_state("data/synthetic/tokenizer_state.json")
200
+ fp = tokenizer.compute_fingerprint("data/schema.yaml")
201
+ """
202
+
203
+ def __init__(self, schema: SchemaConfig) -> None:
204
+ self._schema = schema
205
+ self._tokenizers: dict[str, FeatureTokenizer] = {}
206
+ for feature in schema.features:
207
+ self._tokenizers[feature.name] = FeatureTokenizer(feature)
208
+
209
+ @property
210
+ def feature_names(self) -> list[str]:
211
+ return self._schema.feature_names()
212
+
213
+ @property
214
+ def num_features(self) -> int:
215
+ return self._schema.num_features
216
+
217
+ def get_feature_tokenizer(self, name: str) -> FeatureTokenizer:
218
+ return self._tokenizers[name]
219
+
220
+ def fit_feature(self, name: str, values: np.ndarray) -> None:
221
+ """Fit bucket boundaries for a single bucketed feature."""
222
+ self._tokenizers[name].fit(values)
223
+
224
+ def is_all_fitted(self) -> bool:
225
+ return all(t.is_fitted for t in self._tokenizers.values())
226
+
227
+ def encode_feature(self, name: str, values: np.ndarray) -> np.ndarray:
228
+ """Encode raw values for one feature. Returns int16 token IDs."""
229
+ return self._tokenizers[name].encode(values)
230
+
231
+ def decode_feature(self, name: str, token_ids: np.ndarray) -> np.ndarray:
232
+ """Decode token IDs for one feature. Returns float64 values."""
233
+ return self._tokenizers[name].decode(token_ids)
234
+
235
+ def inject_nulls(
236
+ self, token_ids: np.ndarray, null_mask: np.ndarray
237
+ ) -> np.ndarray:
238
+ """Replace positions where null_mask is True with NULL token.
239
+
240
+ Args:
241
+ token_ids: int16 array of token IDs.
242
+ null_mask: boolean array, same shape as token_ids.
243
+
244
+ Returns:
245
+ Copy of token_ids with NULLs injected.
246
+ """
247
+ result = token_ids.copy()
248
+ result[null_mask] = NULL_TOKEN
249
+ return result
250
+
251
+ @property
252
+ def oov_counts(self) -> dict[str, int]:
253
+ return {name: t.oov_count for name, t in self._tokenizers.items()}
254
+
255
+ def reset_oov_counts(self) -> None:
256
+ for t in self._tokenizers.values():
257
+ t.reset_oov_count()
258
+
259
+ def get_state(self) -> dict[str, Any]:
260
+ """Full tokenizer state for persistence and fingerprinting."""
261
+ return {
262
+ "num_features": self._schema.num_features,
263
+ "num_transactions": self._schema.num_transactions,
264
+ "features": [
265
+ self._tokenizers[name].get_state()
266
+ for name in self._schema.feature_names()
267
+ ],
268
+ }
269
+
270
+ def save_state(self, path: str | Path) -> None:
271
+ """Save tokenizer state to JSON for fingerprint computation (D4)."""
272
+ path = Path(path)
273
+ path.parent.mkdir(parents=True, exist_ok=True)
274
+ state = self.get_state()
275
+ with open(path, "w") as fh:
276
+ json.dump(state, fh, indent=2, sort_keys=True)
277
+
278
+ @classmethod
279
+ def from_state(cls, state_path: str | Path, schema: SchemaConfig) -> "TransactionTokenizer":
280
+ """Reconstruct tokenizer from saved state."""
281
+ with open(state_path) as fh:
282
+ state = json.load(fh)
283
+
284
+ tokenizer = cls(schema)
285
+ for feat_state in state["features"]:
286
+ name = feat_state["name"]
287
+ ft = tokenizer._tokenizers[name]
288
+ if "boundaries" in feat_state:
289
+ ft._boundaries = np.array(feat_state["boundaries"], dtype=np.float64)
290
+ ft._fitted = True
291
+ return tokenizer
292
+
293
+ def compute_fingerprint(self, *config_paths: str | Path) -> str:
294
+ """SHA256 fingerprint of tokenizer state plus config files (D4).
295
+
296
+ The fingerprint includes the tokenizer's bucket boundaries and vocab
297
+ sizes, plus the raw bytes of any additional config files (schema,
298
+ generator config, split indices). Eval refuses to run if fingerprints
299
+ don't match.
300
+
301
+ Args:
302
+ config_paths: paths to config files to include in the hash.
303
+
304
+ Returns:
305
+ Hex-encoded SHA256 digest.
306
+ """
307
+ hasher = hashlib.sha256()
308
+
309
+ state_bytes = json.dumps(self.get_state(), sort_keys=True).encode("utf-8")
310
+ hasher.update(state_bytes)
311
+
312
+ for path in sorted(str(p) for p in config_paths):
313
+ with open(path, "rb") as fh:
314
+ hasher.update(fh.read())
315
+
316
+ return hasher.hexdigest()
src/demo/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Interactive inference demo for the LFM2 transaction foundation model."""
src/demo/app.py ADDED
@@ -0,0 +1,828 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interactive inference demo for the LFM2 Transaction Foundation Model.
2
+
3
+ Demonstrates multi-head predictions (fraud, next merchant, amount range, MCC)
4
+ on synthetic payment sequences. Includes side-by-side pretrained-vs-random-init
5
+ comparison showing the value of self-supervised pretraining.
6
+
7
+ Usage:
8
+ python -m src.demo.app --checkpoint PATH [--data-dir PATH] [--port PORT]
9
+
10
+ Runs on CPU. No GPU or internet required. Inference < 100ms per customer.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import gradio as gr
21
+ import numpy as np
22
+ import torch
23
+ import torch.nn.functional as F
24
+
25
+ from src.data.schema import SchemaConfig, load_schema
26
+ from src.demo.decode import TransactionDecoder
27
+ from src.demo.merchant_catalog import DemoMerchantCatalog
28
+ from src.demo.profile_inference import format_profile_html, infer_profile
29
+ from src.demo.render import (
30
+ format_amount_predictions,
31
+ format_fraud_score,
32
+ format_mcc_predictions,
33
+ format_merchant_predictions,
34
+ format_timeline,
35
+ render_comparison_header,
36
+ render_integration_guide,
37
+ render_production_architecture,
38
+ render_why_liquid,
39
+ )
40
+ from src.model.lfm2_small import LFM2Small, ModelConfig
41
+ from src.model.task_heads import (
42
+ AnyHead,
43
+ DownstreamHead,
44
+ HeadConfig,
45
+ MultiHeadModel,
46
+ TiedEmbeddingHead,
47
+ )
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Data loading
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ class DemoData:
56
+ """Loads test-set sequences and labels into memory."""
57
+
58
+ def __init__(self, data_dir: Path, schema: SchemaConfig) -> None:
59
+ token_ids = np.load(data_dir / "token_ids.npy", mmap_mode="r")
60
+ seq_labels = np.load(data_dir / "sequence_labels.npy")
61
+ splits = np.load(data_dir / "split_indices.npz")
62
+
63
+ test_idx = splits["test"]
64
+ self.token_ids = np.array(token_ids[test_idx])
65
+ self.labels = seq_labels[test_idx].astype(int)
66
+ self.num_customers = len(test_idx)
67
+
68
+ self.fraud_indices = np.where(self.labels == 1)[0]
69
+ self.legit_indices = np.where(self.labels == 0)[0]
70
+
71
+ self._curated = self._find_curated_examples()
72
+
73
+ def _find_curated_examples(self) -> dict[str, int]:
74
+ """Pick interesting examples for quick navigation.
75
+
76
+ Five legitimate profiles to show breadth of normal spending patterns,
77
+ three fraud profiles representing the main attack archetypes.
78
+ """
79
+ examples: dict[str, int] = {}
80
+
81
+ # Legitimate customers — varied indices for behavioral variety
82
+ if len(self.legit_indices) > 0:
83
+ examples["Typical Customer"] = int(self.legit_indices[0])
84
+ if len(self.legit_indices) > 100:
85
+ examples["Frequent Shopper"] = int(self.legit_indices[100])
86
+ if len(self.legit_indices) > 200:
87
+ examples["Weekend Spender"] = int(self.legit_indices[200])
88
+ if len(self.legit_indices) > 300:
89
+ examples["High-Spend Loyalist"] = int(self.legit_indices[300])
90
+ if len(self.legit_indices) > 500:
91
+ examples["Low Activity"] = int(self.legit_indices[500])
92
+
93
+ # Fraud archetypes
94
+ if len(self.fraud_indices) > 0:
95
+ examples["Fraud: Card Testing"] = int(self.fraud_indices[0])
96
+ if len(self.fraud_indices) > 50:
97
+ examples["Fraud: Account Takeover"] = int(self.fraud_indices[50])
98
+ if len(self.fraud_indices) > 100:
99
+ examples["Fraud: High Value"] = int(self.fraud_indices[100])
100
+
101
+ return examples
102
+
103
+ @property
104
+ def curated_names(self) -> list[str]:
105
+ return list(self._curated.keys())
106
+
107
+ def get_curated_index(self, name: str) -> int:
108
+ return self._curated[name]
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Model loading
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def build_model(
117
+ model_yaml: Path,
118
+ schema: SchemaConfig,
119
+ finetune_yaml: Path,
120
+ ) -> MultiHeadModel:
121
+ """Construct MultiHeadModel with 4 downstream heads."""
122
+ import yaml
123
+
124
+ backbone = LFM2Small(ModelConfig.from_yaml(model_yaml), schema)
125
+
126
+ with open(finetune_yaml) as f:
127
+ ft_config = yaml.safe_load(f)
128
+
129
+ heads: dict[str, AnyHead] = {}
130
+ for name, hcfg in ft_config["heads"].items():
131
+ config = HeadConfig(
132
+ name=name,
133
+ output_dim=hcfg["output_dim"],
134
+ loss_type=hcfg["loss"],
135
+ pool_strategy=hcfg["pool"],
136
+ target_type=hcfg["target"],
137
+ weight=hcfg.get("weight", 1.0),
138
+ mlp_hidden=hcfg.get("mlp_hidden", 128),
139
+ dropout=hcfg.get("dropout", 0.1),
140
+ )
141
+ if hcfg.get("tied", False):
142
+ heads[name] = TiedEmbeddingHead(
143
+ config, backbone.config.hidden_size, schema.num_features,
144
+ backbone.embedding.value_tables,
145
+ )
146
+ else:
147
+ heads[name] = DownstreamHead(
148
+ config, backbone.config.hidden_size, schema.num_features,
149
+ )
150
+
151
+ return MultiHeadModel(backbone, heads)
152
+
153
+
154
+ def load_model_checkpoint(model: MultiHeadModel, checkpoint_path: Path | None) -> str:
155
+ """Load fine-tuned weights. Returns status message."""
156
+ if checkpoint_path is None:
157
+ return "No checkpoint loaded"
158
+ if not checkpoint_path.exists():
159
+ raise FileNotFoundError(
160
+ f"Checkpoint not found: {checkpoint_path}. "
161
+ f"Run with --checkpoint PATH or --checkpoint none to skip."
162
+ )
163
+
164
+ ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
165
+ model.load_state_dict(ckpt["model_state_dict"], strict=True)
166
+ step = ckpt.get("step", "?")
167
+ return f"step {step}"
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Inference
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ @torch.no_grad()
176
+ def run_inference(
177
+ model: MultiHeadModel,
178
+ token_ids: np.ndarray,
179
+ ) -> dict[str, np.ndarray]:
180
+ """Run all 4 heads on a single customer sequence."""
181
+ tensor = torch.from_numpy(token_ids).unsqueeze(0).long()
182
+ predictions = model(tensor)
183
+
184
+ results: dict[str, np.ndarray] = {}
185
+ for name, logits in predictions.items():
186
+ if name == "fraud":
187
+ prob = torch.sigmoid(logits).squeeze().numpy()
188
+ results[name] = np.array([float(prob)])
189
+ else:
190
+ probs = F.softmax(logits, dim=-1).squeeze(0).numpy()
191
+ results[name] = probs
192
+
193
+ return results
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # App builder
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def create_app(
202
+ pretrained_model: MultiHeadModel,
203
+ random_model: MultiHeadModel,
204
+ data: DemoData,
205
+ decoder: TransactionDecoder,
206
+ merchant_catalog: DemoMerchantCatalog,
207
+ checkpoint_status: str,
208
+ ) -> gr.Blocks:
209
+ """Build the Gradio app with pretrained vs random-init comparison."""
210
+
211
+ pretrained_model.eval()
212
+ random_model.eval()
213
+
214
+ def on_customer_select(
215
+ curated_name: str | None,
216
+ customer_idx: int,
217
+ mode: str,
218
+ ) -> tuple[str, str, str, str, str, str, str, str, str, str, str]:
219
+ """Run both models, return all outputs for comparison."""
220
+
221
+ if mode == "Curated examples" and curated_name:
222
+ idx = data.get_curated_index(curated_name)
223
+ else:
224
+ idx = int(customer_idx)
225
+
226
+ idx = max(0, min(idx, data.num_customers - 1))
227
+ token_ids = data.token_ids[idx]
228
+ is_fraud = bool(data.labels[idx])
229
+
230
+ summary = decoder.summarize_customer(token_ids, is_fraud)
231
+
232
+ t0 = time.perf_counter()
233
+ pre_results = run_inference(pretrained_model, token_ids)
234
+ rand_results = run_inference(random_model, token_ids)
235
+ latency_ms = (time.perf_counter() - t0) * 1000
236
+
237
+ timeline_html = format_timeline(decoder, token_ids)
238
+
239
+ # Pretrained predictions
240
+ pre_fraud = format_fraud_score(float(pre_results["fraud"][0]))
241
+ pre_merchant = format_merchant_predictions(
242
+ pre_results["next_merchant"], merchant_catalog, k=5,
243
+ )
244
+ pre_amount = format_amount_predictions(pre_results["amount_range"], k=5)
245
+ pre_mcc = format_mcc_predictions(pre_results["mcc"], k=5)
246
+
247
+ # Random-init predictions
248
+ rand_fraud = format_fraud_score(float(rand_results["fraud"][0]))
249
+ rand_merchant = format_merchant_predictions(
250
+ rand_results["next_merchant"], merchant_catalog, k=5,
251
+ )
252
+ rand_amount = format_amount_predictions(rand_results["amount_range"], k=5)
253
+ rand_mcc = format_mcc_predictions(rand_results["mcc"], k=5)
254
+
255
+ # Behavioral profile
256
+ profile_match = infer_profile(token_ids)
257
+ profile_html = format_profile_html(profile_match)
258
+
259
+ # Combined comparison HTML for each head
260
+ fraud_compare = _side_by_side("Fraud Score", pre_fraud, rand_fraud)
261
+ merchant_compare = _side_by_side("Next Merchant", pre_merchant, rand_merchant)
262
+ amount_compare = _side_by_side("Amount Range", pre_amount, rand_amount)
263
+ mcc_compare = _side_by_side("Merchant Category", pre_mcc, rand_mcc)
264
+
265
+ latency_str = (
266
+ f"Inference: {latency_ms:.1f}ms (both models) on CPU | "
267
+ f"Customer #{idx} | Ground truth: {'FRAUD' if is_fraud else 'Legitimate'}"
268
+ )
269
+
270
+ return (
271
+ summary, timeline_html, profile_html,
272
+ fraud_compare, merchant_compare, amount_compare, mcc_compare,
273
+ latency_str,
274
+ )
275
+
276
+ with gr.Blocks(
277
+ title="LFM2 Transaction Foundation Model",
278
+ ) as app:
279
+ gr.HTML("""
280
+ <div style="text-align: center; margin-bottom: 16px;">
281
+ <h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #171717;
282
+ letter-spacing: -0.02em;">
283
+ LFM2 Transaction Foundation Model
284
+ </h1>
285
+ <p style="color: #737373; margin: 6px 0 0 0; font-size: 13px;
286
+ font-family: JetBrains Mono, ui-monospace, monospace;">
287
+ Liquid AI &middot; LFM2.5 Architecture &middot; Multi-Head Inference Demo
288
+ </p>
289
+ </div>
290
+ """)
291
+
292
+ with gr.Tabs():
293
+ # ===== Tab 1: Interactive Predictions =====
294
+ with gr.Tab("Predictions"):
295
+ gr.HTML("""
296
+ <div style="padding: 10px 14px; background: #ffffff; border: 1px solid rgba(0,0,0,0.1);
297
+ border-radius: 12px; margin: 8px 0; font-size: 12px; color: #525252;">
298
+ <b style="color: #171717;">How to read this:</b> Select a customer, see their
299
+ transaction history, then compare predictions from the pretrained model (left)
300
+ vs random initialization (right). Same architecture, same fine-tuning data.
301
+ The only difference is self-supervised pretraining on unlabeled sequences.
302
+ </div>
303
+ """)
304
+ with gr.Accordion("Reference model details", open=False):
305
+ gr.HTML(f"""
306
+ <div style="padding: 8px 12px; font-family: JetBrains Mono, ui-monospace, monospace;
307
+ font-size: 11px; color: #525252; display: flex; gap: 20px; flex-wrap: wrap;">
308
+ <span>arch: <b style="color: #171717;">LFM2-small</b> 9.8M params</span>
309
+ <span>layers: <b style="color: #10B981;">5 conv</b> + <b style="color: #7c3aed;">3 attn</b></span>
310
+ <span>input: 64 tx &times; 15 feat = 960 tokens</span>
311
+ <span>checkpoint: <b style="color: #171717;">{checkpoint_status}</b></span>
312
+ <span>data: 200K synthetic sequences, 15 features/tx</span>
313
+ </div>
314
+ """)
315
+
316
+ # Customer Selection. Use Radio (not Checkbox) for the
317
+ # curated-vs-browse toggle: the two modes are mutually exclusive
318
+ # and Radio's visual state is more reliable across Gradio versions.
319
+ _CURATED_MODE = "Curated examples"
320
+ _BROWSE_MODE = "Browse all customers"
321
+ with gr.Row():
322
+ with gr.Column(scale=1):
323
+ gr.HTML("<h3 style='margin: 0 0 8px 0;'>Select Customer</h3>")
324
+ selection_mode = gr.Radio(
325
+ choices=[_CURATED_MODE, _BROWSE_MODE],
326
+ value=_CURATED_MODE,
327
+ label="Selection mode",
328
+ info="Curated: hand-picked legitimate and fraud examples. "
329
+ "Browse: pick any of 20,000 test customers by index.",
330
+ elem_classes="liquid-radio",
331
+ )
332
+ curated_dropdown = gr.Dropdown(
333
+ choices=data.curated_names,
334
+ value=data.curated_names[0] if data.curated_names else None,
335
+ label="Curated Examples",
336
+ info="5 legitimate profiles, 3 fraud archetypes",
337
+ )
338
+ customer_slider = gr.Slider(
339
+ minimum=0,
340
+ maximum=data.num_customers - 1,
341
+ step=1,
342
+ value=0,
343
+ label=f"Customer Index (0-{data.num_customers - 1})",
344
+ info=f"Direct access to any of {data.num_customers:,} test-set customers. "
345
+ f"~3.7% are fraud, rest are legitimate.",
346
+ visible=False,
347
+ )
348
+ run_btn = gr.Button(
349
+ "Run Inference", variant="primary", size="lg",
350
+ elem_id="run-inference-btn",
351
+ )
352
+
353
+ with gr.Column(scale=2):
354
+ summary_text = gr.Textbox(
355
+ label="Customer Profile", interactive=False, lines=2,
356
+ )
357
+ profile_output = gr.HTML(label="Behavioral Profile")
358
+ latency_text = gr.Textbox(
359
+ label="Performance", interactive=False, lines=1,
360
+ )
361
+
362
+ def toggle_selector(mode: str) -> tuple[Any, Any]:
363
+ use_cur = (mode == _CURATED_MODE)
364
+ return gr.update(visible=use_cur), gr.update(visible=not use_cur)
365
+
366
+ selection_mode.change(
367
+ toggle_selector, inputs=[selection_mode],
368
+ outputs=[curated_dropdown, customer_slider],
369
+ )
370
+
371
+ # Transaction Timeline
372
+ gr.HTML("""<h3 style='margin: 16px 0 8px 0; color: #171717;'>Transaction History</h3>
373
+ <div style="font-size: 11px; color: #737373; margin-bottom: 4px;">
374
+ 64 most recent transactions. Tx 63 (highlighted) is the most recent.
375
+ The model predicts what comes next based on this full sequence.
376
+ </div>""")
377
+ timeline_output = gr.HTML()
378
+
379
+ # Side-by-side predictions
380
+ gr.HTML("<h3 style='margin: 16px 0 4px 0; color: #171717; font-size: 18px; font-weight: 600; letter-spacing: -0.01em;'>Model Predictions: Pretrained vs Random Init</h3>")
381
+ gr.HTML(render_comparison_header())
382
+
383
+ fraud_output = gr.HTML()
384
+ merchant_output = gr.HTML()
385
+ amount_output = gr.HTML()
386
+ mcc_output = gr.HTML()
387
+
388
+ # Wire callbacks
389
+ outputs = [
390
+ summary_text, timeline_output, profile_output,
391
+ fraud_output, merchant_output, amount_output, mcc_output,
392
+ latency_text,
393
+ ]
394
+
395
+ run_btn.click(
396
+ on_customer_select,
397
+ inputs=[curated_dropdown, customer_slider, selection_mode],
398
+ outputs=outputs,
399
+ )
400
+ curated_dropdown.change(
401
+ on_customer_select,
402
+ inputs=[curated_dropdown, customer_slider, selection_mode],
403
+ outputs=outputs,
404
+ )
405
+
406
+ # ===== Tab 2: Architecture Deep Dive =====
407
+ with gr.Tab("Architecture"):
408
+ gr.HTML(render_production_architecture())
409
+
410
+ # ===== Tab 3: Why Liquid =====
411
+ with gr.Tab("Why Liquid AI"):
412
+ gr.HTML(render_why_liquid())
413
+
414
+ # ===== Tab 4: Integration Guide =====
415
+ with gr.Tab("Integration"):
416
+ gr.HTML(render_integration_guide())
417
+
418
+ return app
419
+
420
+
421
+ def _side_by_side(title: str, pretrained_html: str, random_html: str) -> str:
422
+ """Render pretrained vs random-init predictions side by side."""
423
+ _mono = "JetBrains Mono, ui-monospace, monospace"
424
+ return f"""
425
+ <div style="margin-bottom: 16px;">
426
+ <div style="font-size: 13px; font-weight: 600; color: #171717; margin-bottom: 8px;
427
+ letter-spacing: -0.01em;">
428
+ {title}
429
+ </div>
430
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
431
+ <div style="background: #ffffff; border: 1px solid rgba(16,185,129,0.25);
432
+ border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);">
433
+ <div style="font-family: {_mono}; font-size: 10px; color: #10B981;
434
+ font-weight: 600; margin-bottom: 8px; text-transform: uppercase;
435
+ letter-spacing: 0.05em;">
436
+ &#10003; Pretrained
437
+ </div>
438
+ {pretrained_html}
439
+ </div>
440
+ <div style="background: #ffffff; border: 1px solid rgba(0,0,0,0.08);
441
+ border-radius: 12px; padding: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.04);">
442
+ <div style="font-family: {_mono}; font-size: 10px; color: #a3a3a3;
443
+ font-weight: 600; margin-bottom: 8px; text-transform: uppercase;
444
+ letter-spacing: 0.05em;">
445
+ &#10007; Random Init
446
+ </div>
447
+ {random_html}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ """
452
+
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # CLI entrypoint
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ def main() -> None:
461
+ parser = argparse.ArgumentParser(
462
+ description="LFM2 Transaction Foundation Model — Interactive Inference Demo",
463
+ )
464
+ parser.add_argument(
465
+ "--checkpoint", type=Path,
466
+ default=Path("experiments/v2_tied/finetune_20260516_190905/checkpoints/step_004999.pt"),
467
+ help="Path to fine-tuned MultiHeadModel checkpoint (.pt file)",
468
+ )
469
+ parser.add_argument(
470
+ "--data-dir", type=Path, default=Path("data/synthetic"),
471
+ help="Directory containing token_ids.npy, sequence_labels.npy, split_indices.npz",
472
+ )
473
+ parser.add_argument(
474
+ "--model-config", type=Path, default=Path("configs/model.yaml"),
475
+ help="Model backbone YAML config",
476
+ )
477
+ parser.add_argument(
478
+ "--schema", type=Path, default=Path("data/schema.yaml"),
479
+ help="Feature schema YAML",
480
+ )
481
+ parser.add_argument(
482
+ "--finetune-config", type=Path,
483
+ default=Path("experiments/v2_tied/finetune_20260516_190905/finetune_config.yaml"),
484
+ help="Fine-tune head config YAML",
485
+ )
486
+ parser.add_argument("--port", type=int, default=7860)
487
+ parser.add_argument("--share", action="store_true", help="Create public Gradio link")
488
+
489
+ args = parser.parse_args()
490
+
491
+ print("Loading schema and merchant catalog...")
492
+ schema = load_schema(args.schema)
493
+ merchant_catalog = DemoMerchantCatalog(schema)
494
+
495
+ print("Loading test data...")
496
+ demo_data = DemoData(args.data_dir, schema)
497
+ print(f" {demo_data.num_customers} test customers "
498
+ f"({len(demo_data.fraud_indices)} fraud, {len(demo_data.legit_indices)} legitimate)")
499
+
500
+ print("Building pretrained model...")
501
+ pretrained_model = build_model(args.model_config, schema, args.finetune_config)
502
+ checkpoint_status = load_model_checkpoint(pretrained_model, args.checkpoint)
503
+ print(f" Pretrained: {checkpoint_status}")
504
+
505
+ print("Building random-init baseline model...")
506
+ random_model = build_model(args.model_config, schema, args.finetune_config)
507
+ print(" Random-init: fresh weights (no checkpoint)")
508
+
509
+ total_params = sum(p.numel() for p in pretrained_model.parameters())
510
+ print(f" Parameters per model: {total_params:,}")
511
+
512
+ print("Building decoder...")
513
+ decoder = TransactionDecoder(schema, merchant_catalog)
514
+
515
+ print("Launching demo...")
516
+ app = create_app(
517
+ pretrained_model, random_model, demo_data,
518
+ decoder, merchant_catalog, checkpoint_status,
519
+ )
520
+ _liquid_theme = gr.themes.Soft(
521
+ primary_hue="neutral",
522
+ secondary_hue="neutral",
523
+ neutral_hue="neutral",
524
+ font=gr.themes.GoogleFont("Inter"),
525
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
526
+ ).set(
527
+ body_background_fill="#f5f5f5",
528
+ body_background_fill_dark="#f5f5f5",
529
+ body_text_color="#171717",
530
+ body_text_color_dark="#171717",
531
+ body_text_color_subdued="#737373",
532
+ body_text_color_subdued_dark="#737373",
533
+ block_background_fill="#ffffff",
534
+ block_background_fill_dark="#ffffff",
535
+ block_border_color="rgba(0,0,0,0.1)",
536
+ block_border_color_dark="rgba(0,0,0,0.1)",
537
+ block_label_background_fill="#f5f5f5",
538
+ block_label_background_fill_dark="#f5f5f5",
539
+ block_label_text_color="#525252",
540
+ block_label_text_color_dark="#525252",
541
+ block_title_text_color="#171717",
542
+ block_title_text_color_dark="#171717",
543
+ block_shadow="0 1px 3px rgba(0,0,0,0.04)",
544
+ block_shadow_dark="0 1px 3px rgba(0,0,0,0.04)",
545
+ input_background_fill="#ffffff",
546
+ input_background_fill_dark="#ffffff",
547
+ input_background_fill_focus="#ffffff",
548
+ input_background_fill_focus_dark="#ffffff",
549
+ input_border_color="rgba(0,0,0,0.1)",
550
+ input_border_color_dark="rgba(0,0,0,0.1)",
551
+ input_border_color_focus="#171717",
552
+ input_border_color_focus_dark="#171717",
553
+ input_placeholder_color="#a3a3a3",
554
+ input_placeholder_color_dark="#a3a3a3",
555
+ panel_background_fill="#fafafa",
556
+ panel_background_fill_dark="#fafafa",
557
+ panel_border_color="rgba(0,0,0,0.06)",
558
+ panel_border_color_dark="rgba(0,0,0,0.06)",
559
+ border_color_primary="rgba(0,0,0,0.1)",
560
+ border_color_primary_dark="rgba(0,0,0,0.1)",
561
+ button_primary_background_fill="#171717",
562
+ button_primary_background_fill_dark="#171717",
563
+ button_primary_background_fill_hover="#404040",
564
+ button_primary_background_fill_hover_dark="#404040",
565
+ button_primary_text_color="#ffffff",
566
+ button_primary_text_color_dark="#ffffff",
567
+ button_secondary_background_fill="#ffffff",
568
+ button_secondary_background_fill_dark="#ffffff",
569
+ button_secondary_text_color="#525252",
570
+ button_secondary_text_color_dark="#525252",
571
+ button_secondary_border_color="rgba(0,0,0,0.1)",
572
+ button_secondary_border_color_dark="rgba(0,0,0,0.1)",
573
+ checkbox_background_color="#ffffff",
574
+ checkbox_background_color_dark="#ffffff",
575
+ checkbox_border_color="rgba(0,0,0,0.2)",
576
+ checkbox_border_color_dark="rgba(0,0,0,0.2)",
577
+ checkbox_background_color_selected="#171717",
578
+ checkbox_background_color_selected_dark="#171717",
579
+ checkbox_label_background_fill="#ffffff",
580
+ checkbox_label_background_fill_dark="#ffffff",
581
+ checkbox_label_text_color="#171717",
582
+ checkbox_label_text_color_dark="#171717",
583
+ slider_color="#171717",
584
+ slider_color_dark="#171717",
585
+ table_border_color="rgba(0,0,0,0.06)",
586
+ table_border_color_dark="rgba(0,0,0,0.06)",
587
+ table_even_background_fill="#fafafa",
588
+ table_even_background_fill_dark="#fafafa",
589
+ table_odd_background_fill="#ffffff",
590
+ table_odd_background_fill_dark="#ffffff",
591
+ shadow_spread="0px",
592
+ shadow_spread_dark="0px",
593
+ color_accent_soft="rgba(0,0,0,0.04)",
594
+ color_accent_soft_dark="rgba(0,0,0,0.04)",
595
+ )
596
+
597
+ _liquid_css = """
598
+ /* Force light mode regardless of system preference */
599
+ :root, .dark { color-scheme: light !important; }
600
+
601
+ .gradio-container {
602
+ background: #f5f5f5 !important;
603
+ max-width: 1280px !important;
604
+ margin: auto !important;
605
+ padding: 1.5rem !important;
606
+ }
607
+
608
+ /* Tabs: pill-style matching Liquid design system */
609
+ .tabs { background: transparent !important; }
610
+ .tab-nav {
611
+ background: #f5f5f5 !important;
612
+ border: none !important;
613
+ border-bottom: 1px solid rgba(0,0,0,0.1) !important;
614
+ gap: 4px !important;
615
+ padding: 4px 0 !important;
616
+ }
617
+ .tab-nav button {
618
+ font-weight: 500 !important;
619
+ font-size: 14px !important;
620
+ color: #737373 !important;
621
+ background: transparent !important;
622
+ border: none !important;
623
+ border-bottom: 2px solid transparent !important;
624
+ padding: 8px 16px !important;
625
+ border-radius: 0 !important;
626
+ transition: all 0.15s ease !important;
627
+ }
628
+ .tab-nav button:hover {
629
+ color: #171717 !important;
630
+ background: rgba(0,0,0,0.03) !important;
631
+ }
632
+ .tab-nav button.selected {
633
+ color: #171717 !important;
634
+ font-weight: 600 !important;
635
+ border-bottom: 2px solid #171717 !important;
636
+ background: transparent !important;
637
+ }
638
+
639
+ /* Block/component overrides */
640
+ .block { border-radius: 12px !important; }
641
+ .block.padded { background: #ffffff !important; }
642
+
643
+ /* Input, textarea, dropdown */
644
+ input, textarea, select, .wrap {
645
+ background: #ffffff !important;
646
+ color: #171717 !important;
647
+ border-color: rgba(0,0,0,0.1) !important;
648
+ }
649
+ .secondary-wrap, .wrap-inner {
650
+ background: #ffffff !important;
651
+ }
652
+
653
+ /* Labels */
654
+ label, .label-wrap, span.svelte-1gfkn6j {
655
+ color: #171717 !important;
656
+ }
657
+ .info {
658
+ color: #737373 !important;
659
+ }
660
+
661
+ /* Action buttons: pill style applied only to explicit primary actions.
662
+ Scoped by elem_id so it doesn't bleed into Gradio radio/checkbox
663
+ options (which Gradio also renders as <button class="primary">). */
664
+ #run-inference-btn button,
665
+ button#run-inference-btn {
666
+ background: #171717 !important;
667
+ color: #ffffff !important;
668
+ border-radius: 9999px !important;
669
+ border: none !important;
670
+ font-weight: 500 !important;
671
+ letter-spacing: -0.02em !important;
672
+ }
673
+ #run-inference-btn button:hover,
674
+ button#run-inference-btn:hover {
675
+ background: #404040 !important;
676
+ }
677
+
678
+ /* Radio group: render as a clean vertical list with native circle
679
+ indicators. Scoped via elem_classes="liquid-radio" so we can target
680
+ reliably without depending on Gradio's internal Svelte-hashed class
681
+ names. Defeats Gradio's default of styling the selected option as a
682
+ depressed dark button -- a form selection should not look like an
683
+ action button. */
684
+ .liquid-radio,
685
+ .liquid-radio > * {
686
+ background: transparent !important;
687
+ border: none !important;
688
+ box-shadow: none !important;
689
+ }
690
+ .liquid-radio .wrap,
691
+ .liquid-radio fieldset {
692
+ display: flex !important;
693
+ flex-direction: column !important;
694
+ gap: 2px !important;
695
+ padding: 0 !important;
696
+ }
697
+ /* Each option label -- override Gradio's button-like rendering */
698
+ .liquid-radio label {
699
+ display: flex !important;
700
+ align-items: center !important;
701
+ gap: 10px !important;
702
+ padding: 8px 10px !important;
703
+ background: transparent !important;
704
+ background-color: transparent !important;
705
+ background-image: none !important;
706
+ border: none !important;
707
+ border-radius: 8px !important;
708
+ cursor: pointer !important;
709
+ font-size: 13px !important;
710
+ font-weight: 400 !important;
711
+ color: #171717 !important;
712
+ box-shadow: none !important;
713
+ transition: background 0.1s ease !important;
714
+ }
715
+ .liquid-radio label:hover {
716
+ background: rgba(0,0,0,0.04) !important;
717
+ }
718
+ /* The radio input itself -- native circle, dark fill when checked */
719
+ .liquid-radio input[type="radio"] {
720
+ appearance: auto !important;
721
+ -webkit-appearance: radio !important;
722
+ accent-color: #171717 !important;
723
+ width: 16px !important;
724
+ height: 16px !important;
725
+ min-width: 16px !important;
726
+ margin: 0 !important;
727
+ cursor: pointer !important;
728
+ opacity: 1 !important;
729
+ }
730
+ /* Selected option: subtle background tint + slightly bolder text.
731
+ Three selectors because different Gradio versions mark the selected
732
+ option differently: .selected class, [aria-checked="true"], or
733
+ :has(input:checked). */
734
+ .liquid-radio label.selected,
735
+ .liquid-radio label[aria-checked="true"],
736
+ .liquid-radio label:has(input:checked) {
737
+ background: rgba(0,0,0,0.04) !important;
738
+ color: #171717 !important;
739
+ font-weight: 500 !important;
740
+ }
741
+ /* Kill any inherited button.primary/button.secondary styling that
742
+ Gradio may apply to radio option wrappers in some versions. */
743
+ .liquid-radio button,
744
+ .liquid-radio button.primary,
745
+ .liquid-radio button.secondary {
746
+ background: transparent !important;
747
+ color: #171717 !important;
748
+ border: none !important;
749
+ border-radius: 8px !important;
750
+ box-shadow: none !important;
751
+ font-weight: 400 !important;
752
+ text-align: left !important;
753
+ justify-content: flex-start !important;
754
+ }
755
+ .liquid-radio button.primary,
756
+ .liquid-radio button.selected {
757
+ background: rgba(0,0,0,0.04) !important;
758
+ font-weight: 500 !important;
759
+ }
760
+
761
+ /* Checkbox */
762
+ .checkbox-item { color: #171717 !important; }
763
+
764
+ /* Dropdown */
765
+ .dropdown-arrow { color: #525252 !important; }
766
+ ul.options { background: #ffffff !important; border-color: rgba(0,0,0,0.1) !important; }
767
+ ul.options li { color: #171717 !important; }
768
+ ul.options li:hover, ul.options li.selected {
769
+ background: #f5f5f5 !important;
770
+ }
771
+
772
+ /* Textbox display (non-editable) */
773
+ .textbox textarea[disabled], .textbox input[disabled] {
774
+ background: #fafafa !important;
775
+ color: #171717 !important;
776
+ opacity: 1 !important;
777
+ }
778
+
779
+ /* Remove dark shadows/borders */
780
+ .block { box-shadow: 0 1px 3px rgba(0,0,0,0.04) !important; }
781
+
782
+ /* Accordion/group headers */
783
+ .form { background: #ffffff !important; border-color: rgba(0,0,0,0.06) !important; }
784
+
785
+ /* Override any remaining dark backgrounds */
786
+ [class*="dark:"], .dark * {
787
+ --tw-bg-opacity: 1 !important;
788
+ }
789
+ """
790
+
791
+ _force_light_js = """
792
+ () => {
793
+ document.documentElement.classList.remove('dark');
794
+ document.documentElement.style.colorScheme = 'light';
795
+ const meta = document.createElement('meta');
796
+ meta.name = 'color-scheme';
797
+ meta.content = 'light';
798
+ document.head.appendChild(meta);
799
+ }
800
+ """
801
+
802
+ # Try the requested port first, then walk up to 10 ports.
803
+ # Gradio errors with OSError when a port is taken; we retry transparently
804
+ # so a stale background process doesn't block a fresh launch.
805
+ for port in range(args.port, args.port + 10):
806
+ try:
807
+ app.launch(
808
+ server_port=port,
809
+ share=args.share,
810
+ theme=_liquid_theme,
811
+ css=_liquid_css,
812
+ js=_force_light_js,
813
+ )
814
+ break
815
+ except OSError as e:
816
+ if "Cannot find empty port" in str(e) or "Address already in use" in str(e):
817
+ print(f" port {port} in use, trying {port + 1}...")
818
+ continue
819
+ raise
820
+ else:
821
+ raise RuntimeError(
822
+ f"No free port in range {args.port}-{args.port + 9}. "
823
+ f"Kill stale processes: lsof -ti:{args.port} | xargs kill"
824
+ )
825
+
826
+
827
+ if __name__ == "__main__":
828
+ main()
src/demo/decode.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decode token IDs back to human-readable transaction descriptions.
2
+
3
+ Handles the reserved-token offset (0=MASK, 1=OOV, 2=NULL, 3+=values)
4
+ and maps each feature's raw value to readable text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+
13
+ from src.data.generator import AMOUNT_RANGE_LABELS
14
+ from src.data.schema import SchemaConfig, VALUES_START
15
+ from src.demo.merchant_catalog import DemoMerchantCatalog, MCC_NAMES
16
+
17
+
18
+ DOW_NAMES: list[str] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
19
+
20
+ ENTRY_MODE_DISPLAY: dict[str, str] = {
21
+ "card_present": "Card Present",
22
+ "card_not_present": "Online",
23
+ "contactless": "Contactless",
24
+ "chip": "Chip",
25
+ "manual_key": "Manual",
26
+ }
27
+
28
+ COUNTRY_NAMES: list[str] = [
29
+ "US", "UK", "CA", "DE", "FR", "JP", "AU", "BR", "IN", "MX",
30
+ "IT", "ES", "NL", "CH", "SE", "NO", "DK", "FI", "KR", "SG",
31
+ "HK", "TW", "NZ", "IE", "BE", "AT", "PT", "PL", "CZ", "GR",
32
+ "IL", "AE", "SA", "TH", "MY", "PH", "ID", "VN", "CL", "CO",
33
+ "AR", "ZA", "NG", "EG", "KE", "TR", "RU", "UA", "RO", "HU",
34
+ ]
35
+
36
+ AVS_DISPLAY: dict[str, str] = {
37
+ "full_match": "AVS Match",
38
+ "zip_match": "ZIP Match",
39
+ "address_match": "Addr Match",
40
+ "no_match": "AVS No Match",
41
+ "not_checked": "AVS N/A",
42
+ }
43
+
44
+ CVV_DISPLAY: dict[str, str] = {
45
+ "match": "CVV Match",
46
+ "no_match": "CVV No Match",
47
+ "not_provided": "No CVV",
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class DecodedTransaction:
53
+ """A single transaction with all features decoded to display strings."""
54
+
55
+ index: int
56
+ hour: str
57
+ dow: str
58
+ days_since_last: str
59
+ is_recurring: str
60
+ mcc: str
61
+ merchant_name: str
62
+ merchant_category: str
63
+ customer_merchant_count: str
64
+ entry_mode: str
65
+ amount_range: str
66
+ card_product: str
67
+ country: str
68
+ avs: str
69
+ cvv: str
70
+ device_hash: str
71
+ customer_tenure: str
72
+
73
+
74
+ class TransactionDecoder:
75
+ """Converts raw token_ids (T, F) into human-readable transactions."""
76
+
77
+ def __init__(self, schema: SchemaConfig, merchant_catalog: DemoMerchantCatalog) -> None:
78
+ self.schema = schema
79
+ self.merchants = merchant_catalog
80
+ self._feature_names = schema.feature_names()
81
+
82
+ def _decode_value(self, feature_idx: int, token_id: int) -> str:
83
+ """Decode a single token_id for a given feature index."""
84
+ feat = self.schema.features[feature_idx]
85
+
86
+ if token_id == 0:
87
+ return "[MASK]"
88
+ if token_id == 1:
89
+ return "[OOV]"
90
+ if token_id == 2:
91
+ return "[NULL]"
92
+
93
+ value = token_id - VALUES_START
94
+
95
+ if feat.name == "hour":
96
+ if 0 <= value <= 23:
97
+ h = value % 12 or 12
98
+ ampm = "AM" if value < 12 else "PM"
99
+ return f"{h} {ampm}"
100
+ return f"H{value}"
101
+
102
+ if feat.name == "dow":
103
+ return DOW_NAMES[value] if 0 <= value < 7 else f"D{value}"
104
+
105
+ if feat.name == "days_since_last":
106
+ if value == 0:
107
+ return "Same day"
108
+ if value <= 5:
109
+ return f"{value}d ago"
110
+ bucket_size = 365 / 30
111
+ approx = int(value * bucket_size)
112
+ return f"~{approx}d ago"
113
+
114
+ if feat.name == "is_recurring":
115
+ return "Recurring" if value == 1 else "One-time"
116
+
117
+ if feat.name == "mcc":
118
+ return MCC_NAMES.get(value, f"MCC-{value}")
119
+
120
+ if feat.name == "merchant_id":
121
+ info = self.merchants.get(value)
122
+ return info.name
123
+
124
+ if feat.name == "customer_merchant_count":
125
+ if value == 0:
126
+ return "1st visit"
127
+ if value < 5:
128
+ return f"{value + 1} visits"
129
+ bucket_size = 500 / 20
130
+ approx = int(value * bucket_size)
131
+ return f"~{approx} visits"
132
+
133
+ if feat.name == "entry_mode":
134
+ if feat.values and value in feat.values:
135
+ raw = feat.values[value]
136
+ return ENTRY_MODE_DISPLAY.get(raw, raw)
137
+ return f"Entry-{value}"
138
+
139
+ if feat.name == "amount":
140
+ range_idx = value // 16
141
+ return AMOUNT_RANGE_LABELS.get(range_idx, f"${value}")
142
+
143
+ if feat.name == "card_product":
144
+ if feat.values and value in feat.values:
145
+ raw = feat.values[value]
146
+ return raw.replace("_", " ").title()
147
+ return f"Card-{value}"
148
+
149
+ if feat.name == "country":
150
+ return COUNTRY_NAMES[value] if 0 <= value < len(COUNTRY_NAMES) else f"Country-{value}"
151
+
152
+ if feat.name == "avs":
153
+ if feat.values and value in feat.values:
154
+ raw = feat.values[value]
155
+ return AVS_DISPLAY.get(raw, raw)
156
+ return f"AVS-{value}"
157
+
158
+ if feat.name == "cvv":
159
+ if feat.values and value in feat.values:
160
+ raw = feat.values[value]
161
+ return CVV_DISPLAY.get(raw, raw)
162
+ return f"CVV-{value}"
163
+
164
+ if feat.name == "device_hash":
165
+ return f"Device-{value:04d}"
166
+
167
+ if feat.name == "customer_tenure":
168
+ months = value * 12
169
+ if months < 12:
170
+ return f"<1 year"
171
+ return f"~{months // 12}yr"
172
+
173
+ return str(value)
174
+
175
+ def decode_sequence(self, token_ids: np.ndarray) -> list[DecodedTransaction]:
176
+ """Decode a full (T, F) sequence into readable transactions.
177
+
178
+ Returns transactions in reverse chronological order (most recent first).
179
+ """
180
+ num_tx = token_ids.shape[0]
181
+ txns: list[DecodedTransaction] = []
182
+
183
+ for t in range(num_tx - 1, -1, -1):
184
+ row = token_ids[t]
185
+
186
+ merchant_val = int(row[5]) - VALUES_START
187
+ merchant_info = self.merchants.get(max(0, merchant_val))
188
+
189
+ txn = DecodedTransaction(
190
+ index=t,
191
+ hour=self._decode_value(0, int(row[0])),
192
+ dow=self._decode_value(1, int(row[1])),
193
+ days_since_last=self._decode_value(2, int(row[2])),
194
+ is_recurring=self._decode_value(3, int(row[3])),
195
+ mcc=self._decode_value(4, int(row[4])),
196
+ merchant_name=self._decode_value(5, int(row[5])),
197
+ merchant_category=merchant_info.category,
198
+ customer_merchant_count=self._decode_value(6, int(row[6])),
199
+ entry_mode=self._decode_value(7, int(row[7])),
200
+ amount_range=self._decode_value(8, int(row[8])),
201
+ card_product=self._decode_value(9, int(row[9])),
202
+ country=self._decode_value(10, int(row[10])),
203
+ avs=self._decode_value(11, int(row[11])),
204
+ cvv=self._decode_value(12, int(row[12])),
205
+ device_hash=self._decode_value(13, int(row[13])),
206
+ customer_tenure=self._decode_value(14, int(row[14])),
207
+ )
208
+ txns.append(txn)
209
+
210
+ return txns
211
+
212
+ def summarize_customer(
213
+ self, token_ids: np.ndarray, is_fraud: bool,
214
+ ) -> str:
215
+ """One-line behavioral summary derived from actual token data."""
216
+ num_tx = token_ids.shape[0]
217
+
218
+ merchant_ids = set()
219
+ mcc_counts: dict[str, int] = {}
220
+ for t in range(num_tx):
221
+ mid = int(token_ids[t, 5]) - VALUES_START
222
+ merchant_ids.add(mid)
223
+ mcc_val = int(token_ids[t, 4]) - VALUES_START
224
+ cat = MCC_NAMES.get(mcc_val, f"Cat-{mcc_val}")
225
+ mcc_counts[cat] = mcc_counts.get(cat, 0) + 1
226
+
227
+ top_cats = sorted(mcc_counts.items(), key=lambda x: -x[1])[:3]
228
+ cat_str = ", ".join(c[0] for c in top_cats)
229
+
230
+ label = "FRAUD" if is_fraud else "Legitimate"
231
+ return (
232
+ f"{num_tx} transactions | {len(merchant_ids)} unique merchants | "
233
+ f"Top categories: {cat_str} | Label: {label}"
234
+ )
src/demo/merchant_catalog.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic merchant name catalog for the inference demo.
2
+
3
+ Maps 10K merchant_id values to plausible business names based on their
4
+ MCC assignments from the data generator. Uses a seeded RNG so the same
5
+ merchant always gets the same name across runs.
6
+
7
+ The catalog is built by:
8
+ 1. Rebuilding the same MerchantCatalog the generator used (same seed=42)
9
+ 2. Assigning human-readable names from per-MCC name pools
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ from src.data.generator import MerchantCatalog
20
+ from src.data.schema import SchemaConfig, load_schema
21
+
22
+
23
+ MCC_NAMES: dict[int, str] = {
24
+ 0: "Transit", 1: "Coffee Shop", 2: "Gas Station", 3: "Parking",
25
+ 4: "Convenience Store", 5: "Discount Store", 6: "Dollar Store",
26
+ 7: "Vending", 8: "Laundromat", 9: "Dry Cleaner",
27
+ 10: "Airlines", 11: "Hotels", 12: "Car Rental", 13: "Cruise Lines",
28
+ 14: "Travel Agency", 15: "Taxi/Rideshare", 16: "Railway", 17: "Bus Lines",
29
+ 18: "Tolls", 19: "Freight/Shipping",
30
+ 20: "Amazon/Online Retail", 21: "Electronics Online", 22: "Clothing Online",
31
+ 23: "Home Goods Online", 24: "Marketplace",
32
+ 25: "Books/Media", 26: "Beauty/Cosmetics", 27: "Pet Supplies",
33
+ 28: "Sporting Goods", 29: "Toys/Games",
34
+ 30: "Casual Dining", 31: "Fast Casual", 32: "Fine Dining",
35
+ 33: "Bars/Pubs", 34: "Bakery/Cafe",
36
+ 35: "Catering", 36: "Food Delivery", 37: "Meal Kit",
37
+ 38: "Ice Cream/Dessert", 39: "Food Truck",
38
+ 40: "Luxury Goods", 41: "Department Store", 42: "Jewelry",
39
+ 43: "Watches", 44: "Designer Fashion",
40
+ 45: "Clothing Retail", 46: "Shoes", 47: "Accessories",
41
+ 48: "Sportswear", 49: "Thrift/Vintage",
42
+ 50: "Office Supplies", 51: "Wholesale Club", 52: "Industrial Supply",
43
+ 53: "Printing/Copy", 54: "Packaging",
44
+ 55: "Legal Services", 56: "Accounting", 57: "Consulting",
45
+ 58: "Marketing/Advertising", 59: "Temp Staffing",
46
+ 60: "Grocery", 61: "Supermarket", 62: "Pharmacy",
47
+ 63: "Health Food", 64: "Liquor Store",
48
+ 65: "Doctor/Medical", 66: "Dentist", 67: "Gym/Fitness",
49
+ 68: "Spa/Wellness", 69: "Veterinary",
50
+ 70: "Fast Food", 71: "Pizza", 72: "Movie Theater",
51
+ 73: "Amusement Park", 74: "Arcade/Gaming",
52
+ 75: "Streaming/Digital", 76: "Music/Audio", 77: "News/Media",
53
+ 78: "Cloud/SaaS", 79: "App Store",
54
+ 80: "Subscription Box", 81: "Software License", 82: "Web Hosting",
55
+ 83: "Domain/DNS", 84: "VPN/Security",
56
+ 85: "Auto Parts", 86: "Auto Service", 87: "Car Wash",
57
+ 88: "Auto Insurance", 89: "Dealership",
58
+ 90: "Hardware Store", 91: "Furniture", 92: "Appliances",
59
+ 93: "Garden/Nursery", 94: "Flooring/Tile",
60
+ 95: "University/College", 96: "Online Course", 97: "Tutoring",
61
+ 98: "Government", 99: "Utilities",
62
+ }
63
+
64
+ MERCHANT_NAME_POOLS: dict[int, list[str]] = {
65
+ 0: ["Metro Transit", "City Bus", "Subway Pass", "BART", "TransitGo"],
66
+ 1: ["Starbucks", "Dunkin'", "Peet's Coffee", "Blue Bottle", "Tim Hortons"],
67
+ 2: ["Shell", "Chevron", "BP", "ExxonMobil", "Costco Gas"],
68
+ 3: ["ParkMobile", "SpotHero", "LAZ Parking", "ParkWhiz", "Ace Parking"],
69
+ 10: ["Delta Airlines", "United Airlines", "Southwest", "JetBlue", "American Airlines"],
70
+ 11: ["Marriott", "Hilton", "Hyatt", "IHG Hotels", "Best Western"],
71
+ 12: ["Enterprise", "Hertz", "Avis", "National Car", "Budget Rent"],
72
+ 20: ["Amazon", "eBay", "Etsy", "Walmart.com", "Target.com"],
73
+ 21: ["Best Buy", "Newegg", "B&H Photo", "Apple Store", "Samsung"],
74
+ 22: ["SHEIN", "ASOS", "Nordstrom", "H&M Online", "Zara Online"],
75
+ 30: ["Applebee's", "Chili's", "Olive Garden", "Red Lobster", "TGI Friday's"],
76
+ 31: ["Chipotle", "Panera Bread", "Shake Shack", "Sweetgreen", "Cava"],
77
+ 32: ["Ruth's Chris", "Capital Grille", "Nobu", "Fleming's", "Morton's"],
78
+ 33: ["Local Bar & Grill", "Sports Bar", "Craft Brewery", "Wine Bar", "Dive Bar"],
79
+ 40: ["Louis Vuitton", "Gucci", "Tiffany & Co", "Hermès", "Cartier"],
80
+ 41: ["Macy's", "Nordstrom", "Bloomingdale's", "Saks Fifth", "Neiman Marcus"],
81
+ 45: ["Zara", "H&M", "Uniqlo", "Gap", "Old Navy"],
82
+ 46: ["Nike", "Foot Locker", "DSW", "Adidas", "New Balance"],
83
+ 48: ["Lululemon", "REI", "Dick's Sporting", "Under Armour", "Patagonia"],
84
+ 50: ["Staples", "Office Depot", "Amazon Business", "Uline", "W.B. Mason"],
85
+ 51: ["Costco", "Sam's Club", "BJ's Wholesale", "Restaurant Depot", "Sysco"],
86
+ 55: ["Smith & Associates", "Baker Law", "Davis Legal", "Park LLP", "Cohen Firm"],
87
+ 60: ["Walmart", "Kroger", "Safeway", "Whole Foods", "Trader Joe's"],
88
+ 61: ["Albertsons", "Publix", "H-E-B", "Wegmans", "Aldi"],
89
+ 62: ["CVS", "Walgreens", "Rite Aid", "Pharmacy Plus", "MedShop"],
90
+ 67: ["Planet Fitness", "LA Fitness", "Equinox", "CrossFit", "Gold's Gym"],
91
+ 70: ["McDonald's", "Burger King", "Wendy's", "Taco Bell", "KFC"],
92
+ 71: ["Domino's", "Papa John's", "Pizza Hut", "Little Caesars", "Marco's"],
93
+ 72: ["AMC Theatres", "Regal Cinema", "Cinemark", "Alamo Draft", "IMAX"],
94
+ 75: ["Netflix", "Spotify", "Disney+", "YouTube Premium", "Hulu"],
95
+ 76: ["Apple Music", "Tidal", "SoundCloud", "Audible", "Pandora"],
96
+ 78: ["AWS", "Google Cloud", "Azure", "Salesforce", "Slack"],
97
+ 80: ["HelloFresh", "BarkBox", "FabFitFun", "Birchbox", "Dollar Shave"],
98
+ 81: ["Microsoft 365", "Adobe CC", "JetBrains", "Zoom Pro", "Notion"],
99
+ 85: ["AutoZone", "O'Reilly Auto", "NAPA", "Advance Auto", "Pep Boys"],
100
+ 86: ["Jiffy Lube", "Meineke", "Firestone", "Midas", "Valvoline"],
101
+ 90: ["Home Depot", "Lowe's", "Ace Hardware", "Menards", "True Value"],
102
+ 91: ["IKEA", "Wayfair", "Pottery Barn", "Crate & Barrel", "West Elm"],
103
+ 95: ["State University", "Community College", "Coursera", "edX", "Udemy"],
104
+ 96: ["Masterclass", "Skillshare", "LinkedIn Learning", "Pluralsight", "Codecademy"],
105
+ 98: ["IRS", "DMV", "City Hall", "Court Fees", "Passport Office"],
106
+ 99: ["Electric Co", "Water Utility", "Gas Utility", "Internet/Cable", "Phone Bill"],
107
+ }
108
+
109
+
110
+ @dataclass
111
+ class MerchantInfo:
112
+ """Human-readable merchant identity."""
113
+
114
+ merchant_id: int
115
+ name: str
116
+ mcc_code: int
117
+ category: str
118
+
119
+
120
+ class DemoMerchantCatalog:
121
+ """Maps merchant_id -> human-readable name + category.
122
+
123
+ Deterministic: same seed always produces the same mapping.
124
+ """
125
+
126
+ def __init__(self, schema: SchemaConfig, seed: int = 42) -> None:
127
+ rng = np.random.default_rng(seed)
128
+ n_merchants = schema.get_feature("merchant_id").num_values
129
+ n_mccs = schema.get_feature("mcc").num_values
130
+
131
+ catalog = MerchantCatalog.build(n_merchants, n_mccs, rng)
132
+
133
+ self._merchant_map: dict[int, MerchantInfo] = {}
134
+ name_rng = np.random.default_rng(seed + 1000)
135
+
136
+ mcc_counters: dict[int, int] = {}
137
+ for mid in range(n_merchants):
138
+ mcc = int(catalog.mcc_assignments[mid])
139
+ mcc_counters[mcc] = mcc_counters.get(mcc, 0) + 1
140
+
141
+ pool = MERCHANT_NAME_POOLS.get(mcc)
142
+ category = MCC_NAMES.get(mcc, f"Category {mcc}")
143
+
144
+ if pool:
145
+ idx = name_rng.integers(0, len(pool))
146
+ base_name = pool[idx]
147
+ count = mcc_counters[mcc]
148
+ if count <= len(pool):
149
+ name = pool[(count - 1) % len(pool)]
150
+ else:
151
+ name = f"{base_name} #{count}"
152
+ else:
153
+ name = f"{category} #{mcc_counters[mcc]}"
154
+
155
+ self._merchant_map[mid] = MerchantInfo(
156
+ merchant_id=mid,
157
+ name=name,
158
+ mcc_code=mcc,
159
+ category=category,
160
+ )
161
+
162
+ def get(self, merchant_id: int) -> MerchantInfo:
163
+ info = self._merchant_map.get(merchant_id)
164
+ if info is None:
165
+ return MerchantInfo(merchant_id, f"Merchant #{merchant_id}", -1, "Unknown")
166
+ return info
167
+
168
+ def get_name(self, merchant_id: int) -> str:
169
+ return self.get(merchant_id).name
170
+
171
+ def get_category(self, merchant_id: int) -> str:
172
+ return self.get(merchant_id).category
173
+
174
+ @classmethod
175
+ def from_schema_path(cls, schema_path: str | Path) -> DemoMerchantCatalog:
176
+ schema = load_schema(schema_path)
177
+ return cls(schema)
src/demo/profile_inference.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Infer behavioral profile from transaction sequence features.
2
+
3
+ Analyzes MCC distribution, temporal patterns, entry modes, and amount
4
+ patterns to classify a customer into one of 16 behavioral archetypes.
5
+ This gives meetings a "we know what the model is reasoning about" beat.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ import numpy as np
13
+
14
+ from src.data.schema import VALUES_START
15
+ from src.demo.merchant_catalog import MCC_NAMES
16
+
17
+
18
+ @dataclass
19
+ class ProfileMatch:
20
+ """Inferred behavioral profile with confidence signals."""
21
+
22
+ name: str
23
+ description: str
24
+ confidence: float
25
+ evidence: list[str]
26
+
27
+
28
+ PROFILE_SIGNATURES: list[dict[str, object]] = [
29
+ {
30
+ "name": "Commuter",
31
+ "description": "Daily transit, coffee, and gas with predictable weekday patterns",
32
+ "mcc_signal": {0, 1, 2, 3},
33
+ "hour_signal": {7, 8, 9, 17, 18},
34
+ "dow_signal": "weekday_heavy",
35
+ "entry_mode_signal": "contactless",
36
+ },
37
+ {
38
+ "name": "Traveler",
39
+ "description": "Frequent flyer with hotels, restaurants, and multi-country activity",
40
+ "mcc_signal": {10, 11, 12, 13, 14},
41
+ "hour_signal": None,
42
+ "dow_signal": None,
43
+ "entry_mode_signal": None,
44
+ "country_signal": "multi_country",
45
+ },
46
+ {
47
+ "name": "Online Shopper",
48
+ "description": "E-commerce heavy with evening browsing and consistent device usage",
49
+ "mcc_signal": {20, 21, 22, 23, 24, 25},
50
+ "hour_signal": {20, 21, 22, 23},
51
+ "dow_signal": None,
52
+ "entry_mode_signal": "card_not_present",
53
+ },
54
+ {
55
+ "name": "Restaurant Regular",
56
+ "description": "Lunch and dinner out at local restaurants, moderate spend",
57
+ "mcc_signal": {30, 31, 32, 33, 34},
58
+ "hour_signal": {12, 13, 18, 19, 20},
59
+ "dow_signal": None,
60
+ "entry_mode_signal": None,
61
+ },
62
+ {
63
+ "name": "Luxury Spender",
64
+ "description": "High-value purchases at department stores and luxury retailers",
65
+ "mcc_signal": {40, 41, 42, 43, 44},
66
+ "hour_signal": None,
67
+ "dow_signal": None,
68
+ "entry_mode_signal": None,
69
+ "amount_signal": "high",
70
+ },
71
+ {
72
+ "name": "Small Business Owner",
73
+ "description": "Office supplies, wholesale, business services during work hours",
74
+ "mcc_signal": {50, 51, 52, 53, 54, 55, 56, 57},
75
+ "hour_signal": {9, 10, 11, 14, 15, 16},
76
+ "dow_signal": "weekday_heavy",
77
+ "entry_mode_signal": None,
78
+ },
79
+ {
80
+ "name": "Retiree",
81
+ "description": "Daytime errands: groceries, pharmacy, low-moderate spend",
82
+ "mcc_signal": {60, 61, 62, 63},
83
+ "hour_signal": {9, 10, 11, 14, 15},
84
+ "dow_signal": None,
85
+ "entry_mode_signal": None,
86
+ "amount_signal": "low",
87
+ },
88
+ {
89
+ "name": "Student",
90
+ "description": "Fast food, entertainment, streaming, small online purchases",
91
+ "mcc_signal": {70, 71, 72, 73, 74, 75},
92
+ "hour_signal": {11, 12, 20, 21, 22, 23},
93
+ "dow_signal": None,
94
+ "entry_mode_signal": None,
95
+ "amount_signal": "low",
96
+ },
97
+ {
98
+ "name": "Gig Worker",
99
+ "description": "Irregular hours, frequent gas and food, high transaction frequency",
100
+ "mcc_signal": {2, 30, 31, 70, 71},
101
+ "hour_signal": None,
102
+ "dow_signal": "all_days",
103
+ "entry_mode_signal": "contactless",
104
+ "frequency_signal": "high",
105
+ },
106
+ {
107
+ "name": "Suburban Parent",
108
+ "description": "Family groceries, kids activities, school supplies, weekend patterns",
109
+ "mcc_signal": {60, 61, 28, 90, 91},
110
+ "hour_signal": {16, 17, 18, 19},
111
+ "dow_signal": "weekend_spike",
112
+ "entry_mode_signal": None,
113
+ },
114
+ {
115
+ "name": "Digital Nomad",
116
+ "description": "Remote worker across countries: coworking, cafes, SaaS tools",
117
+ "mcc_signal": {1, 34, 78, 81, 82},
118
+ "hour_signal": None,
119
+ "dow_signal": None,
120
+ "entry_mode_signal": "card_not_present",
121
+ "country_signal": "multi_country",
122
+ },
123
+ {
124
+ "name": "Healthcare Worker",
125
+ "description": "Shift-based schedule: early morning and late evening clusters",
126
+ "mcc_signal": {62, 65, 66},
127
+ "hour_signal": {5, 6, 7, 22, 23},
128
+ "dow_signal": "all_days",
129
+ "entry_mode_signal": None,
130
+ },
131
+ {
132
+ "name": "Subscription Maximalist",
133
+ "description": "Heavy recurring: streaming, SaaS, meal kits, gym memberships",
134
+ "mcc_signal": {75, 76, 77, 78, 80, 81},
135
+ "hour_signal": None,
136
+ "dow_signal": None,
137
+ "entry_mode_signal": "card_not_present",
138
+ "recurring_signal": "high",
139
+ },
140
+ {
141
+ "name": "High Net Worth",
142
+ "description": "Infrequent large transactions: fine dining, luxury hotels, international",
143
+ "mcc_signal": {32, 11, 40, 42},
144
+ "hour_signal": None,
145
+ "dow_signal": None,
146
+ "entry_mode_signal": None,
147
+ "amount_signal": "very_high",
148
+ "country_signal": "multi_country",
149
+ },
150
+ {
151
+ "name": "Seasonal Worker",
152
+ "description": "Concentrated spending bursts, basic cards, domestic only",
153
+ "mcc_signal": {60, 2, 70},
154
+ "hour_signal": None,
155
+ "dow_signal": None,
156
+ "entry_mode_signal": None,
157
+ "amount_signal": "low",
158
+ },
159
+ {
160
+ "name": "Urban Cashless",
161
+ "description": "City dweller: transit, delivery, convenience, many small contactless taps",
162
+ "mcc_signal": {0, 1, 36, 70, 15},
163
+ "hour_signal": None,
164
+ "dow_signal": None,
165
+ "entry_mode_signal": "contactless",
166
+ "amount_signal": "low",
167
+ "frequency_signal": "high",
168
+ },
169
+ ]
170
+
171
+
172
+ def infer_profile(token_ids: np.ndarray) -> ProfileMatch:
173
+ """Analyze a (T=64, F=15) sequence and infer the most likely behavioral profile."""
174
+ num_tx = token_ids.shape[0]
175
+
176
+ hours = token_ids[:, 0] - VALUES_START
177
+ dows = token_ids[:, 1] - VALUES_START
178
+ mccs = token_ids[:, 4] - VALUES_START
179
+ entry_modes = token_ids[:, 7] - VALUES_START
180
+ amounts = token_ids[:, 8] - VALUES_START
181
+ countries = token_ids[:, 10] - VALUES_START
182
+ recurring = token_ids[:, 3] - VALUES_START
183
+
184
+ mcc_counts: dict[int, int] = {}
185
+ for m in mccs:
186
+ mcc_counts[int(m)] = mcc_counts.get(int(m), 0) + 1
187
+
188
+ hour_counts = np.bincount(np.clip(hours, 0, 23), minlength=24)
189
+ dow_counts = np.bincount(np.clip(dows, 0, 6), minlength=7)
190
+ entry_counts = np.bincount(np.clip(entry_modes, 0, 4), minlength=5)
191
+
192
+ mean_amount = float(np.mean(amounts))
193
+ num_countries = len(set(int(c) for c in countries))
194
+ recurring_rate = float(np.mean(recurring == 1))
195
+ unique_merchants = len(set(int(token_ids[t, 5] - VALUES_START) for t in range(num_tx)))
196
+
197
+ best_score = -1.0
198
+ best_profile = PROFILE_SIGNATURES[0]
199
+ best_evidence: list[str] = []
200
+
201
+ for sig in PROFILE_SIGNATURES:
202
+ score = 0.0
203
+ evidence: list[str] = []
204
+
205
+ mcc_signal = sig.get("mcc_signal", set())
206
+ if mcc_signal:
207
+ mcc_hits = sum(mcc_counts.get(m, 0) for m in mcc_signal)
208
+ mcc_frac = mcc_hits / num_tx
209
+ score += mcc_frac * 4.0
210
+ if mcc_frac > 0.3:
211
+ top_mcc = max(mcc_signal, key=lambda m: mcc_counts.get(m, 0))
212
+ evidence.append(f"{mcc_frac*100:.0f}% transactions at {MCC_NAMES.get(top_mcc, 'key')} merchants")
213
+
214
+ hour_signal = sig.get("hour_signal")
215
+ if hour_signal:
216
+ hour_hits = sum(int(hour_counts[h]) for h in hour_signal if h < 24)
217
+ hour_frac = hour_hits / num_tx
218
+ score += hour_frac * 2.0
219
+ if hour_frac > 0.4:
220
+ evidence.append(f"Peak activity at expected hours")
221
+
222
+ dow_signal = sig.get("dow_signal")
223
+ if dow_signal == "weekday_heavy":
224
+ weekday_frac = float(dow_counts[:5].sum()) / num_tx
225
+ if weekday_frac > 0.75:
226
+ score += 1.5
227
+ evidence.append(f"{weekday_frac*100:.0f}% weekday transactions")
228
+ elif dow_signal == "weekend_spike":
229
+ weekend_frac = float(dow_counts[5:].sum()) / num_tx
230
+ if weekend_frac > 0.35:
231
+ score += 1.5
232
+ evidence.append(f"High weekend activity ({weekend_frac*100:.0f}%)")
233
+ elif dow_signal == "all_days":
234
+ if dow_counts.min() > num_tx * 0.08:
235
+ score += 1.0
236
+
237
+ entry_signal = sig.get("entry_mode_signal")
238
+ if entry_signal == "contactless":
239
+ if entry_counts[2] > num_tx * 0.3:
240
+ score += 1.5
241
+ evidence.append(f"Contactless-heavy ({entry_counts[2]*100//num_tx}%)")
242
+ elif entry_signal == "card_not_present":
243
+ if entry_counts[1] > num_tx * 0.4:
244
+ score += 1.5
245
+ evidence.append(f"Primarily online ({entry_counts[1]*100//num_tx}%)")
246
+
247
+ amount_signal = sig.get("amount_signal")
248
+ if amount_signal == "low" and mean_amount < 60:
249
+ score += 1.0
250
+ evidence.append(f"Low avg spend")
251
+ elif amount_signal == "high" and mean_amount > 120:
252
+ score += 1.5
253
+ evidence.append(f"High avg spend")
254
+ elif amount_signal == "very_high" and mean_amount > 160:
255
+ score += 2.0
256
+ evidence.append(f"Very high avg spend")
257
+
258
+ country_signal = sig.get("country_signal")
259
+ if country_signal == "multi_country" and num_countries > 3:
260
+ score += 2.0
261
+ evidence.append(f"{num_countries} countries visited")
262
+
263
+ recurring_sig = sig.get("recurring_signal")
264
+ if recurring_sig == "high" and recurring_rate > 0.3:
265
+ score += 1.5
266
+ evidence.append(f"{recurring_rate*100:.0f}% recurring")
267
+
268
+ freq_signal = sig.get("frequency_signal")
269
+ if freq_signal == "high" and unique_merchants > 15:
270
+ score += 1.0
271
+
272
+ if score > best_score:
273
+ best_score = score
274
+ best_profile = sig
275
+ best_evidence = evidence
276
+
277
+ max_possible = 10.0
278
+ confidence = min(1.0, best_score / max_possible)
279
+
280
+ if not best_evidence:
281
+ top_mcc = max(mcc_counts, key=mcc_counts.get) if mcc_counts else 0
282
+ best_evidence.append(f"Primary category: {MCC_NAMES.get(top_mcc, 'Unknown')}")
283
+
284
+ return ProfileMatch(
285
+ name=str(best_profile["name"]),
286
+ description=str(best_profile["description"]),
287
+ confidence=confidence,
288
+ evidence=best_evidence,
289
+ )
290
+
291
+
292
+ def format_profile_html(match: ProfileMatch) -> str:
293
+ """Render profile match using Liquid design tokens."""
294
+ conf_pct = match.confidence * 100
295
+ if conf_pct > 60:
296
+ conf_color = "#10B981"
297
+ elif conf_pct > 30:
298
+ conf_color = "#F59E0B"
299
+ else:
300
+ conf_color = "#737373"
301
+
302
+ evidence_items = "".join(
303
+ f'<li style="margin-bottom: 2px;">{e}</li>' for e in match.evidence
304
+ )
305
+ _mono = "JetBrains Mono, ui-monospace, SFMono-Regular, monospace"
306
+
307
+ return f"""
308
+ <div style="padding: 12px 14px; background: #ffffff; border-radius: 12px;
309
+ border: 1px solid rgba(0,0,0,0.1); border-left: 3px solid {conf_color};
310
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);">
311
+ <div style="font-size: 16px; font-weight: 600; color: #171717;
312
+ letter-spacing: -0.01em; margin-bottom: 2px;">
313
+ {match.name}
314
+ </div>
315
+ <div style="font-size: 12px; color: #525252; margin-bottom: 6px;">
316
+ {match.description}
317
+ </div>
318
+ <div style="font-family: {_mono}; font-size: 11px; color: #737373; margin-bottom: 4px;">
319
+ confidence: <span style="color: {conf_color}; font-weight: 600;">{conf_pct:.0f}%</span>
320
+ </div>
321
+ <ul style="font-size: 11px; color: #525252; margin: 0; padding-left: 14px; line-height: 1.5;">
322
+ {evidence_items}
323
+ </ul>
324
+ </div>
325
+ """
src/demo/render.py ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTML rendering for the inference demo UI.
2
+
3
+ Styling follows the Liquid AI design system from liquid-lfm-cloud:
4
+ - Monochrome primary scale (#171717 primary, #f5f5f5 background)
5
+ - Semantic accents: emerald (#10B981), amber (#F59E0B), red (#EF4444), blue (#3B82F6)
6
+ - JetBrains Mono for technical/metric elements, system sans-serif for body
7
+ - 16px rounded cards with subtle shadows
8
+ - Pill-shaped buttons and badges
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Callable
14
+
15
+ import numpy as np
16
+
17
+ from src.data.generator import AMOUNT_RANGE_LABELS
18
+ from src.data.schema import VALUES_START
19
+ from src.demo.decode import TransactionDecoder
20
+ from src.demo.merchant_catalog import DemoMerchantCatalog, MCC_NAMES
21
+
22
+ # Liquid design tokens — light mode (liquid-lfm-cloud design system)
23
+ _BG = "#f5f5f5"
24
+ _BG_CARD = "#ffffff"
25
+ _BG_CARD_ALT = "#fafafa"
26
+ _BORDER = "rgba(0,0,0,0.1)"
27
+ _BORDER_SUBTLE = "rgba(0,0,0,0.05)"
28
+ _TEXT = "#171717"
29
+ _TEXT_MUTED = "#525252"
30
+ _TEXT_DIM = "#737373"
31
+ _ACCENT_BLUE = "#3B82F6"
32
+ _ACCENT_GREEN = "#10B981"
33
+ _ACCENT_AMBER = "#F59E0B"
34
+ _ACCENT_RED = "#EF4444"
35
+ _RADIUS_CARD = "16px"
36
+ _RADIUS_SM = "8px"
37
+ _FONT_MONO = "JetBrains Mono, ui-monospace, SFMono-Regular, monospace"
38
+ _FONT_SANS = "-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif"
39
+
40
+
41
+ def format_fraud_score(prob: float) -> str:
42
+ """Format fraud probability as colored gauge bar."""
43
+ pct = prob * 100
44
+ if pct < 20:
45
+ color = _ACCENT_GREEN
46
+ risk = "LOW RISK"
47
+ elif pct < 60:
48
+ color = _ACCENT_AMBER
49
+ risk = "MEDIUM RISK"
50
+ else:
51
+ color = _ACCENT_RED
52
+ risk = "HIGH RISK"
53
+
54
+ bar_width = max(2, min(100, int(pct)))
55
+ return f"""
56
+ <div style="margin: 8px 0;">
57
+ <div style="font-family: {_FONT_MONO}; font-size: 24px; font-weight: 600;
58
+ color: {color}; margin-bottom: 4px; letter-spacing: -0.02em;">
59
+ {pct:.1f}%
60
+ <span style="font-size: 12px; font-weight: 500; opacity: 0.8;
61
+ letter-spacing: 0.05em;">{risk}</span>
62
+ </div>
63
+ <div style="background: #e5e5e5; border-radius: 9999px; height: 8px;
64
+ width: 100%; overflow: hidden;">
65
+ <div style="background: {color}; height: 100%; width: {bar_width}%;
66
+ border-radius: 9999px; transition: width 0.3s ease;"></div>
67
+ </div>
68
+ </div>
69
+ """
70
+
71
+
72
+ def format_topk_predictions(
73
+ probs: np.ndarray,
74
+ k: int,
75
+ label_fn: Callable[[int], str],
76
+ ) -> str:
77
+ """Format top-k predictions as styled rows with probability bars."""
78
+ top_indices = np.argsort(probs)[::-1][:k]
79
+
80
+ rows = ""
81
+ for i, idx in enumerate(top_indices):
82
+ p = probs[idx] * 100
83
+ label = label_fn(int(idx))
84
+ bar_width = max(2, int(p * 2.5))
85
+ opacity = 1.0 - i * 0.15
86
+ weight = "600" if i == 0 else "400"
87
+ # Show "<0.1%" for probabilities that round to 0.0% at 1 decimal place.
88
+ # This happens with high-cardinality heads (10K merchants) when the model
89
+ # hasn't learned a meaningful distribution (e.g. random-init baseline).
90
+ p_str = f"{p:.1f}%" if p >= 0.05 else "<0.1%"
91
+ rows += f"""
92
+ <div style="display: flex; align-items: center; gap: 8px; padding: 4px 0;">
93
+ <div style="flex: 1; font-size: 13px; font-weight: {weight};
94
+ color: {_TEXT}; opacity: {opacity};">{label}</div>
95
+ <div style="width: 50px; text-align: right; font-family: {_FONT_MONO};
96
+ font-size: 12px; color: {_TEXT_MUTED};">{p_str}</div>
97
+ <div style="width: 120px;">
98
+ <div style="background: {_ACCENT_BLUE}; height: 6px; width: {bar_width}%;
99
+ border-radius: 9999px; opacity: {opacity};"></div>
100
+ </div>
101
+ </div>
102
+ """
103
+
104
+ return f'<div style="padding: 4px 0;">{rows}</div>'
105
+
106
+
107
+ def format_merchant_predictions(
108
+ probs: np.ndarray,
109
+ merchant_catalog: DemoMerchantCatalog,
110
+ k: int = 5,
111
+ ) -> str:
112
+ """Top-k merchant predictions with names and categories."""
113
+ def label_fn(idx: int) -> str:
114
+ if idx < VALUES_START:
115
+ return "[special]"
116
+ mid = idx - VALUES_START
117
+ info = merchant_catalog.get(mid)
118
+ return f"{info.name} ({info.category})"
119
+
120
+ return format_topk_predictions(probs, k, label_fn)
121
+
122
+
123
+ def format_amount_predictions(probs: np.ndarray, k: int = 5) -> str:
124
+ """Top-k amount range predictions."""
125
+ def label_fn(idx: int) -> str:
126
+ return AMOUNT_RANGE_LABELS.get(idx, f"Range {idx}")
127
+
128
+ return format_topk_predictions(probs, k, label_fn)
129
+
130
+
131
+ def format_mcc_predictions(probs: np.ndarray, k: int = 5) -> str:
132
+ """Top-k MCC predictions with category names."""
133
+ def label_fn(idx: int) -> str:
134
+ if idx < VALUES_START:
135
+ return "[special]"
136
+ mcc_val = idx - VALUES_START
137
+ return MCC_NAMES.get(mcc_val, f"MCC-{mcc_val}")
138
+
139
+ return format_topk_predictions(probs, k, label_fn)
140
+
141
+
142
+ def format_timeline(decoder: TransactionDecoder, token_ids: np.ndarray) -> str:
143
+ """Render transaction sequence as a scrollable table."""
144
+ txns = decoder.decode_sequence(token_ids)
145
+
146
+ header = f"""
147
+ <div style="max-height: 380px; overflow-y: auto; border: 1px solid {_BORDER};
148
+ border-radius: {_RADIUS_CARD}; background: {_BG_CARD};">
149
+ <table style="width: 100%; border-collapse: collapse; font-size: 12px;
150
+ font-family: {_FONT_MONO};">
151
+ <thead>
152
+ <tr style="border-bottom: 1px solid {_BORDER}; position: sticky; top: 0;
153
+ background: {_BG_CARD}; z-index: 1;">
154
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
155
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;">Tx</th>
156
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
157
+ font-size: 10px; text-transform: uppercase;">When</th>
158
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
159
+ font-size: 10px; text-transform: uppercase;">Merchant</th>
160
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
161
+ font-size: 10px; text-transform: uppercase;">Category</th>
162
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
163
+ font-size: 10px; text-transform: uppercase;">Amount</th>
164
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
165
+ font-size: 10px; text-transform: uppercase;">Method</th>
166
+ <th style="padding: 8px; text-align: left; color: {_TEXT_DIM};
167
+ font-size: 10px; text-transform: uppercase;">Country</th>
168
+ </tr>
169
+ </thead>
170
+ <tbody>
171
+ """
172
+
173
+ rows = ""
174
+ for txn in txns:
175
+ when = f"{txn.dow} {txn.hour}"
176
+ highlight = f"background: rgba(59, 130, 246, 0.08);" if txn.index == 63 else ""
177
+ rows += f"""
178
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE}; {highlight}">
179
+ <td style="padding: 6px 8px; color: {_TEXT_DIM};">{txn.index}</td>
180
+ <td style="padding: 6px 8px; color: {_TEXT_MUTED};">{when}</td>
181
+ <td style="padding: 6px 8px; color: {_TEXT}; font-weight: 500;">{txn.merchant_name}</td>
182
+ <td style="padding: 6px 8px; color: {_TEXT_DIM};">{txn.merchant_category}</td>
183
+ <td style="padding: 6px 8px; color: {_TEXT_MUTED};">{txn.amount_range}</td>
184
+ <td style="padding: 6px 8px; color: {_TEXT_DIM};">{txn.entry_mode}</td>
185
+ <td style="padding: 6px 8px; color: {_TEXT_DIM};">{txn.country}</td>
186
+ </tr>
187
+ """
188
+
189
+ return header + rows + "</tbody></table></div>"
190
+
191
+
192
+ def render_production_architecture() -> str:
193
+ """LFM2.5 production architecture and how it applies to payments."""
194
+ _purple = "#7c3aed"
195
+ _purple_bg = "rgba(124,58,237,0.08)"
196
+ _purple_border = "rgba(124,58,237,0.25)"
197
+
198
+ def _layer_cell(label: str, idx: int, is_attn: bool) -> str:
199
+ bg = _purple_bg if is_attn else "rgba(16,185,129,0.08)"
200
+ bc = _purple_border if is_attn else "rgba(16,185,129,0.25)"
201
+ color = _purple if is_attn else _ACCENT_GREEN
202
+ return f"""<div style="flex: 1; padding: 6px 2px; background: {bg};
203
+ border: 1px solid {bc}; border-radius: 4px; text-align: center; min-width: 0;">
204
+ <div style="font-family: {_FONT_MONO}; font-size: 8px; color: {color};
205
+ font-weight: 600;">{label}</div>
206
+ <div style="font-family: {_FONT_MONO}; font-size: 7px; color: {_TEXT_DIM};">L{idx}</div>
207
+ </div>"""
208
+
209
+ layers_1_2b = [
210
+ ("C", 0, False), ("C", 1, False), ("A", 2, True), ("C", 3, False),
211
+ ("C", 4, False), ("A", 5, True), ("C", 6, False), ("C", 7, False),
212
+ ("A", 8, True), ("C", 9, False), ("A", 10, True), ("C", 11, False),
213
+ ("A", 12, True), ("C", 13, False), ("A", 14, True), ("C", 15, False),
214
+ ]
215
+ layer_cells = "".join(_layer_cell(l, i, a) for l, i, a in layers_1_2b)
216
+
217
+ return f"""
218
+ <div style="max-width: 1100px; margin: 0 auto; padding: 16px;">
219
+
220
+ <!-- Header -->
221
+ <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700;
222
+ letter-spacing: -0.02em;">
223
+ LFM2.5 for Payment Sequences
224
+ </h2>
225
+ <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 24px 0; line-height: 1.5;">
226
+ The published LFM2.5 architecture adapted for structured transaction data.
227
+ Same hybrid conv-attention backbone. New per-feature embedding layer.
228
+ </p>
229
+
230
+ <!-- Config comparison table -->
231
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px;">
232
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
233
+ border-radius: {_RADIUS_CARD};">
234
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
235
+ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px;">
236
+ Production Target</div>
237
+ <table style="width: 100%; font-size: 12px; border-collapse: collapse;">
238
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Parameters</td>
239
+ <td style="padding: 3px 0; color: {_TEXT}; font-weight: 600; text-align: right;">1.2B</td></tr>
240
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Hidden dim</td>
241
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">2048</td></tr>
242
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Layers</td>
243
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">
244
+ <span style="color: {_ACCENT_GREEN};">10 conv</span> + <span style="color: {_purple};">6 attn</span></td></tr>
245
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Attention</td>
246
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">32Q / 8KV (GQA)</td></tr>
247
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">MLP</td>
248
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">SwiGLU 12288</td></tr>
249
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Sequence</td>
250
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">128 tx &times; 30 feat = 3,840</td></tr>
251
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Latency target</td>
252
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_ACCENT_GREEN}; font-weight: 600; text-align: right;">&lt; 50ms (H100)</td></tr>
253
+ </table>
254
+ </div>
255
+
256
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
257
+ border-radius: {_RADIUS_CARD};">
258
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
259
+ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px;">
260
+ This Demo (Reference)</div>
261
+ <table style="width: 100%; font-size: 12px; border-collapse: collapse;">
262
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Parameters</td>
263
+ <td style="padding: 3px 0; color: {_TEXT}; font-weight: 600; text-align: right;">9.8M</td></tr>
264
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Hidden dim</td>
265
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">256</td></tr>
266
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Layers</td>
267
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">
268
+ <span style="color: {_ACCENT_GREEN};">5 conv</span> + <span style="color: {_purple};">3 attn</span></td></tr>
269
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Attention</td>
270
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">4Q / 2KV (GQA)</td></tr>
271
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">MLP</td>
272
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">SwiGLU 1024</td></tr>
273
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Sequence</td>
274
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">64 tx &times; 15 feat = 960</td></tr>
275
+ <tr><td style="padding: 3px 0; color: {_TEXT_DIM};">Measured latency</td>
276
+ <td style="padding: 3px 0; font-family: {_FONT_MONO}; color: {_TEXT}; text-align: right;">&lt; 80ms (CPU)</td></tr>
277
+ </table>
278
+ </div>
279
+ </div>
280
+
281
+ <!-- LFM2.5-1.2B layer diagram -->
282
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
283
+ border-radius: {_RADIUS_CARD}; margin-bottom: 24px;">
284
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
285
+ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px;">
286
+ LFM2.5-1.2B Layer Pattern (16 layers)</div>
287
+ <div style="display: flex; gap: 3px; margin-bottom: 8px;">
288
+ {layer_cells}
289
+ </div>
290
+ <div style="display: flex; gap: 16px; font-family: {_FONT_MONO}; font-size: 10px;">
291
+ <span style="color: {_ACCENT_GREEN};">&#9632; Conv (10): local patterns, k=3, O(n)</span>
292
+ <span style="color: {_purple};">&#9632; Attention (6): global context, GQA, O(n&sup2;)</span>
293
+ </div>
294
+ <div style="margin-top: 8px; font-size: 11px; color: {_TEXT_DIM}; line-height: 1.5;">
295
+ First and last layers are convolutional. Attention is densest mid-stack.
296
+ The LM head reads from a local-conv output, but 6 attention layers have already
297
+ encoded global context upstream.
298
+ </div>
299
+ </div>
300
+
301
+ <!-- Three key architectural adaptations -->
302
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 24px;">
303
+
304
+ <!-- Embedding adaptation -->
305
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
306
+ border-radius: {_RADIUS_CARD};">
307
+ <div style="font-size: 14px; font-weight: 600; color: {_ACCENT_BLUE}; margin-bottom: 6px;">
308
+ Per-Feature Embedding</div>
309
+ <p style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5; margin: 0 0 8px 0;">
310
+ Text LFM2 has one embedding table. Payment LFM2 has one table per feature
311
+ (hour, merchant, MCC, amount, ...) plus a feature-type table. Summed to produce
312
+ the same (B, T, D) the backbone expects.</p>
313
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
314
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;">
315
+ value_tables[f](token) + type_table(f)</div>
316
+ </div>
317
+
318
+ <!-- KV-cache advantage -->
319
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
320
+ border-radius: {_RADIUS_CARD};">
321
+ <div style="font-size: 14px; font-weight: 600; color: {_ACCENT_GREEN}; margin-bottom: 6px;">
322
+ KV-Cache Advantage</div>
323
+ <p style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5; margin: 0 0 8px 0;">
324
+ Only 6 of 16 layers need KV cache (attention layers only). A pure-attention
325
+ model at the same depth needs 16 layers of cache. At 3,840 token sequences
326
+ with 1,024 concurrent requests:</p>
327
+ <div style="font-family: {_FONT_MONO}; font-size: 11px;">
328
+ <span style="color: {_ACCENT_GREEN}; font-weight: 600;">LFM2: ~25 GB</span>
329
+ <span style="color: {_TEXT_DIM};"> vs </span>
330
+ <span style="color: {_ACCENT_RED};">Pure attn: ~64 GB</span>
331
+ </div>
332
+ </div>
333
+
334
+ <!-- Weight-tied heads -->
335
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
336
+ border-radius: {_RADIUS_CARD};">
337
+ <div style="font-size: 14px; font-weight: 600; color: {_purple}; margin-bottom: 6px;">
338
+ Weight-Tied Heads</div>
339
+ <p style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5; margin: 0 0 8px 0;">
340
+ Downstream heads that predict pretrained features (next merchant, MCC)
341
+ project through the backbone's own embedding table. Zero extra parameters,
342
+ +50% accuracy vs fresh MLP heads.</p>
343
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
344
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;">
345
+ adapter(h) @ embedding.weight.T</div>
346
+ </div>
347
+ </div>
348
+
349
+ <!-- How it translates -->
350
+ <div style="padding: 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
351
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
352
+ <div style="font-size: 14px; font-weight: 600; color: {_TEXT}; margin-bottom: 10px;">
353
+ How This Translates to Your Implementation</div>
354
+ <div style="display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 12px;">
355
+ <div style="font-family: {_FONT_MONO}; color: {_ACCENT_BLUE}; font-weight: 600;">Schema</div>
356
+ <div style="color: {_TEXT_MUTED};">Your features, your vocab sizes, your ordering. We review the schema design.</div>
357
+ <div style="font-family: {_FONT_MONO}; color: {_ACCENT_BLUE}; font-weight: 600;">Pretrain</div>
358
+ <div style="color: {_TEXT_MUTED};">Self-supervised on your unlabeled transactions. No fraud labels needed.</div>
359
+ <div style="font-family: {_FONT_MONO}; color: {_ACCENT_BLUE}; font-weight: 600;">Fine-tune</div>
360
+ <div style="color: {_TEXT_MUTED};">Attach task heads (fraud, disputes, auth optimization). Multi-task with shared backbone.</div>
361
+ <div style="font-family: {_FONT_MONO}; color: {_ACCENT_BLUE}; font-weight: 600;">Deploy</div>
362
+ <div style="color: {_TEXT_MUTED};">Your infra, your GPUs, your compliance. Sub-100ms for real-time authorization decisioning.</div>
363
+ <div style="font-family: {_FONT_MONO}; color: {_ACCENT_BLUE}; font-weight: 600;">Own</div>
364
+ <div style="color: {_TEXT_MUTED};">No data leaves your infrastructure. No external API dependency. You own the model.</div>
365
+ </div>
366
+ </div>
367
+
368
+ <!-- Architecture source -->
369
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;">
370
+ Architecture: <a href="https://arxiv.org/abs/2511.23404" style="color: {_ACCENT_BLUE}; text-decoration: none;">
371
+ arXiv 2511.23404</a> &middot;
372
+ Weights: <a href="https://huggingface.co/LiquidAI" style="color: {_ACCENT_BLUE}; text-decoration: none;">
373
+ huggingface.co/LiquidAI</a>
374
+ </div>
375
+ </div>
376
+ """
377
+
378
+
379
+ def render_comparison_header() -> str:
380
+ """Header explaining the pretrained vs random-init comparison."""
381
+ return f"""
382
+ <div style="padding: 10px 14px; background: rgba(245,158,11,0.06);
383
+ border: 1px solid rgba(245,158,11,0.2); border-radius: {_RADIUS_SM};
384
+ margin-bottom: 12px; font-size: 12px; color: {_ACCENT_AMBER}; line-height: 1.5;">
385
+ <b>Pretrained vs Random Init:</b>
386
+ <span style="color: {_TEXT_MUTED};">Same architecture, same input, same fine-tuning data.
387
+ Left: pretrained on 200K unlabeled sequences first. Right: trained from scratch.
388
+ The difference is the value of self-supervised pretraining.</span>
389
+ </div>
390
+ """
391
+
392
+
393
+ def render_why_liquid() -> str:
394
+ """Render the 'Why Liquid AI' value proposition tab.
395
+
396
+ Content drawn from docs/architecture-walkthrough.md §14.
397
+ Uses measured reference-implementation numbers, not marketing claims.
398
+ """
399
+ def _table_row(cells: list[str], bold_last: bool = False) -> str:
400
+ tds = ""
401
+ for i, c in enumerate(cells):
402
+ weight = "600" if (bold_last and i == len(cells) - 1) else "400"
403
+ align = "right" if i > 0 else "left"
404
+ tds += (
405
+ f'<td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;'
406
+ f' color: {_TEXT}; font-weight: {weight}; text-align: {align};">{c}</td>'
407
+ )
408
+ return f"<tr style='border-bottom: 1px solid {_BORDER_SUBTLE};'>{tds}</tr>"
409
+
410
+ def _table_header(cols: list[str]) -> str:
411
+ ths = ""
412
+ for i, c in enumerate(cols):
413
+ align = "right" if i > 0 else "left"
414
+ ths += (
415
+ f'<th style="padding: 6px 10px; font-size: 10px; color: {_TEXT_DIM};'
416
+ f' text-transform: uppercase; letter-spacing: 0.05em; text-align: {align};'
417
+ f' font-weight: 600;">{c}</th>'
418
+ )
419
+ return f"<tr style='border-bottom: 1px solid {_BORDER};'>{ths}</tr>"
420
+
421
+ return f"""
422
+ <div style="max-width: 1100px; margin: 0 auto; padding: 16px;">
423
+
424
+ <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700;
425
+ letter-spacing: -0.02em;">
426
+ Why LFM2.5 for Transaction Sequences
427
+ </h2>
428
+ <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 24px 0; line-height: 1.5;">
429
+ Three claims, each backed by a different kind of evidence. Serving cost is arithmetic.
430
+ Label efficiency is measured. Architectural fit is a first-principles argument.
431
+ </p>
432
+
433
+ <!-- 1. Serving cost -->
434
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
435
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
436
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
437
+ 1. Serving Cost Scales Better Than Pure Attention
438
+ </h3>
439
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 12px 0;">
440
+ 10 of 16 layers use O(n) conv instead of O(n&sup2;) attention. The gap is structural
441
+ and compounds as sequence length grows. Measured at matched parameter count on the same
442
+ hardware:
443
+ </p>
444
+ <table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
445
+ {_table_header(["Sequence", "Tokens", "Hybrid", "Pure Attn", "Speedup"])}
446
+ {_table_row(["64 tx &times; 15 feat", "960", "5.96 ms", "9.01 ms", "1.5x"])}
447
+ {_table_row(["64 tx &times; 30 feat", "1,920", "13.05 ms", "23.84 ms", "1.8x"])}
448
+ {_table_row(["128 tx &times; 30 feat", "3,840", "35.65 ms", "75.95 ms",
449
+ "<b>2.1x</b>"], bold_last=False)}
450
+ {_table_row(["256 tx &times; 30 feat", "7,680", "119 ms", "283 ms",
451
+ "<b>2.4x</b>"], bold_last=False)}
452
+ </table>
453
+ <p style="font-size: 11px; color: {_TEXT_DIM}; margin: 0; line-height: 1.5;">
454
+ At 3,840 tokens (production target), the FLOP analysis predicts 7.7% fewer operations.
455
+ The actual speedup is larger because conv layers achieve better hardware utilization.
456
+ At 7,680 tokens, savings reach 17% in FLOPs and 2.4x in wall-clock.
457
+ </p>
458
+ </div>
459
+
460
+ <!-- 2. Label scarcity -->
461
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
462
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
463
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
464
+ 2. Pretraining Gets to 98% of Full-Data Quality with 10% of Labels
465
+ </h3>
466
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 12px 0;">
467
+ Self-supervised pretraining on unlabeled transactions. No fraud labels needed.
468
+ Then fine-tune with whatever labels you have. The pretrained model at 10% labels
469
+ beats the random-init baseline at 100% labels.
470
+ </p>
471
+ <table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
472
+ {_table_header(["Labels", "Sequences", "Pretrained PR-AUC", "Baseline PR-AUC", "Delta"])}
473
+ {_table_row(["1%", "1,700", "<b>0.539</b>", "0.046", "<b>+0.493</b>"])}
474
+ {_table_row(["10%", "17,000", "<b>0.948</b>", "0.690", "<b>+0.258</b>"])}
475
+ {_table_row(["100%", "170,000", "<b>0.964</b>", "0.922", "+0.041"])}
476
+ </table>
477
+ <p style="font-size: 11px; color: {_TEXT_DIM}; margin: 0; line-height: 1.5;">
478
+ At 1% labels the baseline has learned nothing (0.046 is random guessing).
479
+ The pretrained model is already useful. This is the defining advantage for
480
+ institutions with billions of unlabeled transactions and limited fraud labels.
481
+ </p>
482
+ </div>
483
+
484
+ <!-- 3. Convergence + multi-head -->
485
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px;">
486
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
487
+ border-radius: {_RADIUS_CARD};">
488
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
489
+ 5x Convergence Speed
490
+ </h3>
491
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0 0 8px 0;">
492
+ The pretrained model hit 0.959 PR-AUC at step 1,000. The random-init baseline
493
+ never reached that level in 5,000 steps (peaked at 0.922).
494
+ </p>
495
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0;">
496
+ For quarterly model refreshes, this cuts GPU-hours per cycle by 80%.
497
+ For weekly refreshes, it is the difference between feasible and infeasible.
498
+ </p>
499
+ </div>
500
+
501
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
502
+ border-radius: {_RADIUS_CARD};">
503
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
504
+ One Backbone, Many Tasks
505
+ </h3>
506
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0 0 8px 0;">
507
+ A single forward pass serves fraud detection, next-merchant prediction, amount
508
+ forecasting, and merchant category classification. Add dispute prediction, default
509
+ risk, or authorization optimization as new heads without retraining the backbone.
510
+ </p>
511
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
512
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;">
513
+ Tied-embedding heads: +50% merchant accuracy at zero parameter cost
514
+ </div>
515
+ </div>
516
+ </div>
517
+
518
+ <!-- 4. Architectural fit -->
519
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
520
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
521
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
522
+ 3. The Architecture Matches Transaction Data Structure
523
+ </h3>
524
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0 0 8px 0;">
525
+ Transaction data is not like text. Information density is concentrated locally
526
+ (within-transaction feature correlations, adjacent-transaction continuity) with sparse
527
+ global signal (behavioral baselines across the full history). LFM2.5 allocates
528
+ O(n) conv to the dense local patterns and O(n&sup2;) attention to the sparse
529
+ global patterns. A pure transformer allocates O(n&sup2;) compute uniformly
530
+ across all distances.
531
+ </p>
532
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
533
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
534
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
535
+ font-weight: 600; margin-bottom: 4px;">Within Transaction</div>
536
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
537
+ Merchant determines MCC. Entry mode correlates with amount. Dense, local,
538
+ often deterministic. A 3-wide conv kernel captures this.
539
+ </div>
540
+ </div>
541
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
542
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
543
+ font-weight: 600; margin-bottom: 4px;">Adjacent Transactions</div>
544
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
545
+ Strong temporal continuity. A customer at Starbucks is likely at a similar
546
+ merchant next. The conditional distribution of t+1 given t is heavily peaked.
547
+ </div>
548
+ </div>
549
+ <div style="padding: 10px; background: {_BG_CARD_ALT}; border-radius: 8px;">
550
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
551
+ font-weight: 600; margin-bottom: 4px;">Distant Transactions</div>
552
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.4;">
553
+ Weak but non-zero signal. Behavioral profile matters for fraud baseline,
554
+ but per-position information density is thin. This is where attention earns its cost.
555
+ </div>
556
+ </div>
557
+ </div>
558
+ </div>
559
+
560
+ <!-- 5. Data ownership -->
561
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
562
+ border-radius: {_RADIUS_CARD}; margin-bottom: 12px;">
563
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
564
+ Your Data, Your Model, Your Infrastructure
565
+ </h3>
566
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0;">
567
+ Liquid licenses the architecture and training recipe. You train on your proprietary
568
+ data behind your firewall. No data leaves your infrastructure. No dependency on
569
+ external model APIs. The result is a foundation model you own, optimized for your
570
+ specific transaction patterns, deployed on your hardware.
571
+ </p>
572
+ </div>
573
+
574
+ <!-- What we claim / don't claim -->
575
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
576
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
577
+ <h3 style="color: {_TEXT}; margin: 0 0 8px 0; font-size: 14px; font-weight: 600;">
578
+ What We Claim vs What We Don't
579
+ </h3>
580
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font-size: 12px;">
581
+ <div>
582
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT};
583
+ font-weight: 600; margin-bottom: 6px; text-transform: uppercase;
584
+ letter-spacing: 0.05em;">We claim</div>
585
+ <ul style="margin: 0; padding-left: 14px; color: {_TEXT_MUTED}; line-height: 1.6;">
586
+ <li>Fewer FLOPs per forward pass above ~1,500 tokens (arithmetic)</li>
587
+ <li>Cost gap widens with sequence length (structural, measured 2.1-2.4x)</li>
588
+ <li>Pretraining dramatically improves low-label performance (measured)</li>
589
+ <li>Full pipeline works end-to-end on LFM2.5 (built it)</li>
590
+ </ul>
591
+ </div>
592
+ <div>
593
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
594
+ font-weight: 600; margin-bottom: 6px; text-transform: uppercase;
595
+ letter-spacing: 0.05em;">We don't claim</div>
596
+ <ul style="margin: 0; padding-left: 14px; color: {_TEXT_MUTED}; line-height: 1.6;">
597
+ <li>Quality advantage over pure transformers at matched scale</li>
598
+ <li>Absolute latency numbers extrapolated from 10M to 1.2B</li>
599
+ <li>Superiority on all tasks (the advantage is specific to structured sequences)</li>
600
+ <li>Real-data validation (all results are on synthetic data)</li>
601
+ </ul>
602
+ </div>
603
+ </div>
604
+ </div>
605
+
606
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;">
607
+ Source: <a href="https://arxiv.org/abs/2511.23404" style="color: {_TEXT_DIM};
608
+ text-decoration: underline;">arXiv 2511.23404</a> &middot;
609
+ Reference implementation measured at 9.85M params, Apple Silicon
610
+ </div>
611
+ </div>
612
+ """
613
+
614
+
615
+ def render_integration_guide() -> str:
616
+ """Render high-abstraction integration architecture flow.
617
+
618
+ Distills docs/integration-guide.md into a visual pipeline overview.
619
+ Monochrome design -- no rainbow pills or per-card color coding.
620
+ """
621
+
622
+ def _phase_card(num: str, title: str, body: str, detail: str) -> str:
623
+ return f"""
624
+ <div style="padding: 14px 16px; background: {_BG_CARD}; border: 1px solid {_BORDER};
625
+ border-radius: {_RADIUS_CARD};">
626
+ <div style="display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px;">
627
+ <span style="font-family: {_FONT_MONO}; font-size: 11px; color: {_TEXT_DIM};
628
+ font-weight: 600;">{num}</span>
629
+ <span style="font-size: 14px; font-weight: 600; color: {_TEXT};">{title}</span>
630
+ </div>
631
+ <p style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5; margin: 0 0 8px 0;">
632
+ {body}</p>
633
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
634
+ padding: 6px 8px; background: {_BG_CARD_ALT}; border-radius: 6px;
635
+ line-height: 1.5;">
636
+ {detail}</div>
637
+ </div>"""
638
+
639
+ def _gotcha(num: str, title: str, desc: str) -> str:
640
+ return f"""
641
+ <div style="display: flex; gap: 8px; padding: 5px 0;
642
+ border-bottom: 1px solid {_BORDER_SUBTLE};">
643
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
644
+ font-weight: 600; min-width: 18px;">{num}.</div>
645
+ <div>
646
+ <span style="font-size: 12px; font-weight: 600; color: {_TEXT};">{title}</span>
647
+ <span style="font-size: 12px; color: {_TEXT_MUTED};"> -- {desc}</span>
648
+ </div>
649
+ </div>"""
650
+
651
+ return f"""
652
+ <div style="max-width: 1100px; margin: 0 auto; padding: 16px;">
653
+
654
+ <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700;
655
+ letter-spacing: -0.02em;">
656
+ Integration Architecture
657
+ </h2>
658
+ <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 20px 0; line-height: 1.5;">
659
+ Seven phases from raw transaction data to production fraud scoring.
660
+ Each phase has a clear input, output, and set of decisions.
661
+ </p>
662
+
663
+ <!-- Pipeline flow -->
664
+ <div style="display: flex; align-items: center; justify-content: center; gap: 6px;
665
+ margin-bottom: 24px; padding: 10px 0; flex-wrap: wrap;">
666
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
667
+ border-radius: 9999px; font-family: {_FONT_MONO};
668
+ font-size: 10px; font-weight: 600;">Schema</span>
669
+ <span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>
670
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
671
+ border-radius: 9999px; font-family: {_FONT_MONO};
672
+ font-size: 10px; font-weight: 600;">Tokenize</span>
673
+ <span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>
674
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
675
+ border-radius: 9999px; font-family: {_FONT_MONO};
676
+ font-size: 10px; font-weight: 600;">Embed</span>
677
+ <span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>
678
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
679
+ border-radius: 9999px; font-family: {_FONT_MONO};
680
+ font-size: 10px; font-weight: 600;">Backbone</span>
681
+ <span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>
682
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
683
+ border-radius: 9999px; font-family: {_FONT_MONO};
684
+ font-size: 10px; font-weight: 600;">Pretrain</span>
685
+ <span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>
686
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
687
+ border-radius: 9999px; font-family: {_FONT_MONO};
688
+ font-size: 10px; font-weight: 600;">Heads</span>
689
+ <span style="color: {_TEXT_DIM}; font-size: 12px;">&rarr;</span>
690
+ <span style="padding: 5px 12px; background: {_TEXT}; color: #fff;
691
+ border-radius: 9999px; font-family: {_FONT_MONO};
692
+ font-size: 10px; font-weight: 600;">Deploy</span>
693
+ </div>
694
+
695
+ <!-- Phase cards -->
696
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;">
697
+ {_phase_card("1", "Schema Design",
698
+ "Define your features, vocab sizes, and feature ordering. This is the contract "
699
+ "between your data team and your ML team. Three feature types: categorical "
700
+ "(direct index), ordinal (hour, day-of-week), and bucketed-continuous (amount "
701
+ "into quantile bins). Spend two weeks here.",
702
+ "3 reserved tokens per feature (MASK, OOV, NULL) &nbsp;|&nbsp; "
703
+ "Order features by semantic family for the conv window &nbsp;|&nbsp; "
704
+ "Embed schema fingerprint in checkpoint metadata")}
705
+
706
+ {_phase_card("2", "Tokenization",
707
+ "Convert raw transaction fields into integer token IDs. Categorical features "
708
+ "map directly to vocab indices. Continuous values (amount, days-since-last) get "
709
+ "quantile-bucketed into N bins. High-cardinality features (merchant_id) need the "
710
+ "long tail bucketed or factored into orthogonal features.",
711
+ "amount &rarr; 16-256 quantile bins &nbsp;|&nbsp; "
712
+ "merchant_id: top 10K distinct, rest into ~1K frequency buckets &nbsp;|&nbsp; "
713
+ "Unseen values at inference &rarr; OOV token (ID 1)")}
714
+
715
+ {_phase_card("3", "Structured Embedding",
716
+ "One embedding table per feature (sized to its vocab) plus a feature-type table. "
717
+ "Summed to produce the (B, T*F, D) tensor the backbone expects. "
718
+ "No raw continuous features -- everything goes through an embedding table.",
719
+ "value_tables[f](token) + type_table(f) &nbsp;|&nbsp; "
720
+ "High-cardinality features dominate param budget -- "
721
+ "bucket the merchant long tail")}
722
+
723
+ {_phase_card("4", "Backbone Config",
724
+ "Start from a published LFM2 scale point (350M, 700M, 1.2B, 2.6B). "
725
+ "Keep the conv-to-attention ratio, GQA config, SwiGLU MLP, and RoPE theta. "
726
+ "Do not deviate without a specific reason.",
727
+ "10:6 conv:attn at 1.2B &nbsp;|&nbsp; 32Q/8KV GQA &nbsp;|&nbsp; "
728
+ "QK-RMSNorm before RoPE &nbsp;|&nbsp; theta=1M &nbsp;|&nbsp; "
729
+ "First and last layers are conv")}
730
+
731
+ {_phase_card("5", "Pretraining",
732
+ "Self-supervised on your unlabeled transactions. No fraud labels needed. "
733
+ "Causal next-feature prediction or masked-transaction prediction. "
734
+ "Average per-feature losses (do NOT sum).",
735
+ "AdamW lr=3e-4, betas=(0.9, 0.95), wd=0.1 &nbsp;|&nbsp; "
736
+ "Cosine decay to 10% &nbsp;|&nbsp; BF16 (loss in FP32) &nbsp;|&nbsp; "
737
+ "Chinchilla: ~20 tokens per parameter")}
738
+
739
+ {_phase_card("6", "Downstream Heads",
740
+ "Attach task-specific heads. The critical decision: "
741
+ "TiedEmbeddingHead for features that were in pretraining vocab "
742
+ "(next_merchant, MCC). Fresh MLP for everything else (fraud, disputes).",
743
+ "Tied head: adapter(h) @ embedding.weight.T (+50% accuracy) &nbsp;|&nbsp; "
744
+ "Pool: last_tx_mean for sequence tasks, pre_last_tx for next-tx &nbsp;|&nbsp; "
745
+ "Dual LR: backbone 5e-5, heads 1e-3")}
746
+ </div>
747
+
748
+ <!-- Deployment gets its own full-width card -->
749
+ <div style="margin-bottom: 20px;">
750
+ {_phase_card("7", "Deployment",
751
+ "KV cache only on 6 attention layers (not 16). Dynamic batching with "
752
+ "5-10ms collection window. Conv-dominant models quantize cleanly to INT8. "
753
+ "Same model runs on GPU or CPU.",
754
+ "LFM2 @ 3,840 tokens: ~25 GB KV at 1K concurrent &nbsp;|&nbsp; "
755
+ "Pure attn: ~64 GB &nbsp;|&nbsp; "
756
+ "Sub-100ms on H100 for real-time auth decisioning")}
757
+ </div>
758
+
759
+ <!-- Gotchas -->
760
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
761
+ border-radius: {_RADIUS_CARD}; margin-bottom: 20px;">
762
+ <div style="font-size: 14px; font-weight: 600; color: {_TEXT}; margin-bottom: 8px;">
763
+ Top Gotchas
764
+ </div>
765
+ {_gotcha("1", "Per-feature loss summing",
766
+ "high-cardinality features dominate. Average, don't sum.")}
767
+ {_gotcha("2", "Fresh MLP for pretrained features",
768
+ "next_merchant stays at random. Use TiedEmbeddingHead.")}
769
+ {_gotcha("3", "Schema-checkpoint drift",
770
+ "training on schema v3, deploying on v2. Embed fingerprint in metadata.")}
771
+ {_gotcha("4", "Frozen backbone",
772
+ "PR-AUC drops from 0.96 to 0.16. Plan to fine-tune.")}
773
+ {_gotcha("5", "Pool strategy leak",
774
+ "last_tx_mean for next-tx prediction leaks the target. Use pre_last_tx.")}
775
+ </div>
776
+
777
+ <!-- Engagement model -->
778
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
779
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
780
+ <div style="font-size: 14px; font-weight: 600; color: {_TEXT}; margin-bottom: 10px;">
781
+ Typical Engagement
782
+ </div>
783
+ <table style="width: 100%; border-collapse: collapse;">
784
+ <tr style="border-bottom: 1px solid {_BORDER};">
785
+ <th style="padding: 6px 10px; text-align: left; font-size: 10px; color: {_TEXT_DIM};
786
+ text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;">Phase</th>
787
+ <th style="padding: 6px 10px; text-align: left; font-size: 10px; color: {_TEXT_DIM};
788
+ text-transform: uppercase; font-weight: 600;">Duration</th>
789
+ <th style="padding: 6px 10px; text-align: left; font-size: 10px; color: {_TEXT_DIM};
790
+ text-transform: uppercase; font-weight: 600;">What Happens</th>
791
+ </tr>
792
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE};">
793
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
794
+ color: {_TEXT};">Discovery</td>
795
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
796
+ color: {_TEXT_MUTED};">2-4 weeks</td>
797
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
798
+ Schema definition, data sample (~1M sequences), compliance review, architectural fit assessment</td>
799
+ </tr>
800
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE};">
801
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
802
+ color: {_TEXT};">POC</td>
803
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
804
+ color: {_TEXT_MUTED};">2 weeks</td>
805
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
806
+ Pretrain + fine-tune on your data sample, measurement report, go/no-go recommendation</td>
807
+ </tr>
808
+ <tr style="border-bottom: 1px solid {_BORDER_SUBTLE};">
809
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
810
+ color: {_TEXT};">Production</td>
811
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
812
+ color: {_TEXT_MUTED};">3-6 months</td>
813
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
814
+ Engineering team builds, Liquid provides architectural support, weekly design review</td>
815
+ </tr>
816
+ <tr>
817
+ <td style="padding: 5px 10px; font-size: 12px; font-weight: 600;
818
+ color: {_TEXT};">Scale</td>
819
+ <td style="padding: 5px 10px; font-family: {_FONT_MONO}; font-size: 11px;
820
+ color: {_TEXT_MUTED};">Ongoing</td>
821
+ <td style="padding: 5px 10px; font-size: 12px; color: {_TEXT_MUTED};">
822
+ Operations, monitoring, retraining cadence, architecture evolution</td>
823
+ </tr>
824
+ </table>
825
+ </div>
826
+
827
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;">
828
+ Full guide: docs/integration-guide.md &middot;
829
+ Architecture: <a href="https://arxiv.org/abs/2511.23404" style="color: {_TEXT_DIM};
830
+ text-decoration: underline;">arXiv 2511.23404</a>
831
+ </div>
832
+ </div>
833
+ """
src/model/__init__.py ADDED
File without changes
src/model/embedding.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured-feature embedding layer for transaction sequences.
2
+
3
+ Replaces the single text-token embedding table of public LFM2.5 with
4
+ per-feature value tables plus a feature-type table, summed. Each feature
5
+ has its own embedding table sized to that feature's vocabulary. A shared
6
+ feature-type table (15 rows) tells the model which feature a token represents.
7
+
8
+ Input: (B, T, F) int tensor of token IDs, where T=64 transactions, F=15 features
9
+ Output: (B, T*F, D) float tensor of embeddings, where D=hidden_dim
10
+ """
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+
15
+ from src.data.schema import SchemaConfig
16
+
17
+
18
+ class StructuredEmbedding(nn.Module):
19
+ """Per-feature value embeddings + feature-type embeddings, summed.
20
+
21
+ The value_tables are exposed as a ModuleList so the per-feature LM heads
22
+ can tie weights to them. The type_table is NOT tied to anything.
23
+ """
24
+
25
+ def __init__(self, schema: SchemaConfig, hidden_dim: int) -> None:
26
+ super().__init__()
27
+ self.num_features = schema.num_features
28
+ self.num_transactions = schema.num_transactions
29
+ self.hidden_dim = hidden_dim
30
+
31
+ self.value_tables = nn.ModuleList([
32
+ nn.Embedding(feature.vocab_size, hidden_dim)
33
+ for feature in schema.features
34
+ ])
35
+
36
+ self.type_table = nn.Embedding(schema.num_features, hidden_dim)
37
+
38
+ self._vocab_sizes = [f.vocab_size for f in schema.features]
39
+
40
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
41
+ """Embed structured token IDs into a flat sequence.
42
+
43
+ Args:
44
+ token_ids: (B, T, F) int tensor. T=num_transactions, F=num_features.
45
+ Each value must be in [0, vocab_size) for its feature.
46
+
47
+ Returns:
48
+ (B, T*F, D) float tensor of summed value + type embeddings.
49
+ """
50
+ B, T, F = token_ids.shape
51
+ assert F == self.num_features, (
52
+ f"Expected {self.num_features} features, got {F}"
53
+ )
54
+
55
+ # Feature type indices: [0, 1, 2, ..., F-1], broadcast across batch and time
56
+ type_indices = torch.arange(F, device=token_ids.device) # (F,)
57
+ type_emb = self.type_table(type_indices) # (F, D)
58
+
59
+ feature_embeddings = []
60
+ for f_idx in range(F):
61
+ feat_tokens = token_ids[:, :, f_idx] # (B, T)
62
+ val_emb = self.value_tables[f_idx](feat_tokens) # (B, T, D)
63
+ feature_embeddings.append(val_emb + type_emb[f_idx]) # (B, T, D) + (D,) broadcast
64
+
65
+ # (B, T, F, D) -> (B, T*F, D)
66
+ stacked = torch.stack(feature_embeddings, dim=2) # (B, T, F, D)
67
+ return stacked.reshape(B, T * F, self.hidden_dim) # (B, 960, D)
src/model/heads.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prediction heads for the transaction foundation model.
2
+
3
+ PerFeatureLMHeads: 15 linear projections for next-token prediction during
4
+ pretraining. Each head projects hidden_dim -> feature_vocab_size with weights
5
+ tied to the corresponding value embedding table (parameter identity, not copy).
6
+ No bias, matching LFM2's lm_head convention.
7
+
8
+ Weight tying semantics:
9
+ - Forward through embedding: output = weight[token_id] (index select)
10
+ - Forward through LM head: logits = hidden @ weight.T (matmul)
11
+ - Backward: gradients flow through BOTH paths to the SAME Parameter.
12
+ The optimizer updates it once, accumulating both contributions.
13
+ - The feature-type embedding table is NOT tied to any head (it has no
14
+ corresponding prediction target).
15
+
16
+ The backbone's final RMSNorm (embedding_norm) is applied to hidden states
17
+ BEFORE they reach these heads, matching LFM2's architecture. That
18
+ normalization lives in the backbone (F8), not here.
19
+ """
20
+
21
+ import torch
22
+ import torch.nn as nn
23
+
24
+ from src.model.embedding import StructuredEmbedding
25
+
26
+
27
+ class PerFeatureLMHeads(nn.Module):
28
+ """Per-feature prediction heads with tied embedding weights.
29
+
30
+ Each of the 15 heads is a bias-free linear projection from hidden_dim to
31
+ that feature's vocab_size. The weight matrix is the SAME nn.Parameter as
32
+ the corresponding value embedding table.
33
+ """
34
+
35
+ def __init__(self, embedding: StructuredEmbedding) -> None:
36
+ super().__init__()
37
+ self.num_features = embedding.num_features
38
+ self.hidden_dim = embedding.hidden_dim
39
+
40
+ self.heads = nn.ModuleList()
41
+ for f_idx in range(self.num_features):
42
+ vocab_size = embedding.value_tables[f_idx].num_embeddings
43
+ head = nn.Linear(embedding.hidden_dim, vocab_size, bias=False)
44
+ # Tie weights: head.weight IS embedding.value_tables[f].weight.
45
+ # Both the embedding lookup and the LM head projection operate on
46
+ # the same underlying tensor. PyTorch deduplicates in parameters().
47
+ head.weight = embedding.value_tables[f_idx].weight
48
+ self.heads.append(head)
49
+
50
+ def forward(self, hidden_states: torch.Tensor) -> list[torch.Tensor]:
51
+ """Project hidden states to per-feature logits.
52
+
53
+ Args:
54
+ hidden_states: (B, S, D) from the backbone (after embedding_norm).
55
+
56
+ Returns:
57
+ List of 15 tensors. heads[f] has shape (B, S, vocab_size_f).
58
+ During training, the loss function selects positions per feature:
59
+ position p predicts feature (p+1) % num_features.
60
+ """
61
+ return [head(hidden_states) for head in self.heads]
62
+
63
+ def get_vocab_sizes(self) -> list[int]:
64
+ """Vocab size for each feature, useful for loss computation."""
65
+ return [head.out_features for head in self.heads]
src/model/lfm2_small.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LFM2Small: scaled-down LFM2.5-1.2B backbone for transaction sequences.
2
+
3
+ Reimplements (not subclasses) the core LFM2 architecture at ~8.3M total params.
4
+ 8 layers in conv-conv-attn-conv-attn-conv-attn-conv order, preserving every
5
+ structural choice from the full 1.2B model:
6
+
7
+ - Gated short convolution with depthwise causal Conv1d
8
+ - Grouped query attention (4Q / 2KV, group size 2) with QK RMSNorm
9
+ - SwiGLU MLP with auto-adjusted intermediate dimension
10
+ - Pre-norm residual connections
11
+ - Final RMSNorm (embedding_norm) before LM heads
12
+
13
+ Module naming matches LFM2 conventions exactly:
14
+ layers[i].self_attn.{q_proj, k_proj, v_proj, out_proj, q_layernorm, k_layernorm}
15
+ layers[i].conv.{in_proj, out_proj, conv}
16
+ layers[i].feed_forward.{w1, w2, w3}
17
+ layers[i].{operator_norm, ffn_norm}
18
+ embedding_norm
19
+
20
+ Reference: modeling_lfm2.py in HuggingFace transformers (LiquidAI/LFM2-1.2B).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ import yaml
32
+
33
+ from src.data.schema import SchemaConfig, load_schema
34
+ from src.model.embedding import StructuredEmbedding
35
+ from src.model.heads import PerFeatureLMHeads
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Config
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ @dataclass
44
+ class ModelConfig:
45
+ """Typed config for LFM2Small. Loads from configs/model.yaml."""
46
+
47
+ hidden_size: int = 256
48
+ intermediate_size: int = 1024
49
+ num_attention_heads: int = 4
50
+ num_key_value_heads: int = 2
51
+ num_layers: int = 8
52
+ layer_order: list[str] = field(default_factory=lambda: [
53
+ "conv", "conv", "attn", "conv", "attn", "conv", "attn", "conv",
54
+ ])
55
+ conv_kernel_size: int = 3
56
+ block_auto_adjust_ff_dim: bool = True
57
+ block_multiple_of: int = 256
58
+ block_ffn_dim_multiplier: float = 1.0
59
+ rms_norm_eps: float = 1e-6
60
+ rope_theta: float = 1_000_000.0
61
+ max_position_embeddings: int = 4096
62
+ initializer_range: float = 0.02
63
+ num_transactions: int = 64
64
+ num_features: int = 15
65
+
66
+ @property
67
+ def head_dim(self) -> int:
68
+ return self.hidden_size // self.num_attention_heads
69
+
70
+ @property
71
+ def num_kv_groups(self) -> int:
72
+ return self.num_attention_heads // self.num_key_value_heads
73
+
74
+ @property
75
+ def effective_intermediate_size(self) -> int:
76
+ """MLP dim after LFM2's block_auto_adjust_ff_dim.
77
+
78
+ With hidden=256, intermediate=1024: int(2*1024/3)=682, rounded to 768.
79
+ """
80
+ if not self.block_auto_adjust_ff_dim:
81
+ return self.intermediate_size
82
+ size = int(2 * self.intermediate_size / 3)
83
+ size = int(self.block_ffn_dim_multiplier * size)
84
+ return self.block_multiple_of * (
85
+ (size + self.block_multiple_of - 1) // self.block_multiple_of
86
+ )
87
+
88
+ @classmethod
89
+ def from_yaml(cls, path: str | Path) -> ModelConfig:
90
+ with open(path) as f:
91
+ raw = yaml.safe_load(f)
92
+ bb = raw.get("backbone", {})
93
+ seq = raw.get("sequence", {})
94
+ return cls(
95
+ hidden_size=bb.get("hidden_size", 256),
96
+ intermediate_size=bb.get("intermediate_size", 1024),
97
+ num_attention_heads=bb.get("num_attention_heads", 4),
98
+ num_key_value_heads=bb.get("num_key_value_heads", 2),
99
+ num_layers=bb.get("num_layers", 8),
100
+ layer_order=bb.get("layer_order", [
101
+ "conv", "conv", "attn", "conv", "attn", "conv", "attn", "conv",
102
+ ]),
103
+ conv_kernel_size=bb.get("conv_kernel_size", 3),
104
+ block_auto_adjust_ff_dim=bb.get("block_auto_adjust_ff_dim", True),
105
+ block_multiple_of=bb.get("block_multiple_of", 256),
106
+ block_ffn_dim_multiplier=bb.get("block_ffn_dim_multiplier", 1.0),
107
+ rms_norm_eps=bb.get("rms_norm_eps", 1e-6),
108
+ rope_theta=bb.get("rope_theta", 1_000_000.0),
109
+ num_transactions=seq.get("num_transactions", 64),
110
+ num_features=seq.get("features_per_transaction", 15),
111
+ )
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Building blocks
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class RMSNorm(nn.Module):
120
+ """Root mean square layer normalization (Lfm2RMSNorm)."""
121
+
122
+ def __init__(self, dim: int, eps: float = 1e-6) -> None:
123
+ super().__init__()
124
+ self.weight = nn.Parameter(torch.ones(dim))
125
+ self.eps = eps
126
+
127
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
128
+ dtype = x.dtype
129
+ x = x.float()
130
+ x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
131
+ return (self.weight * x).to(dtype)
132
+
133
+
134
+ class RotaryEmbedding(nn.Module):
135
+ """Rotary position embeddings. Flat token positions 0..S-1."""
136
+
137
+ def __init__(
138
+ self, head_dim: int, max_seq_len: int = 4096, theta: float = 1_000_000.0,
139
+ ) -> None:
140
+ super().__init__()
141
+ inv_freq = 1.0 / (theta ** (torch.arange(0, head_dim, 2).float() / head_dim))
142
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
143
+ self.max_seq_len = max_seq_len
144
+
145
+ def forward(
146
+ self, x: torch.Tensor, position_ids: torch.Tensor,
147
+ ) -> tuple[torch.Tensor, torch.Tensor]:
148
+ """Returns (cos, sin) each shaped (B, S, head_dim)."""
149
+ # (1, D/2, 1) @ (B, 1, S) -> (B, D/2, S) -> (B, S, D/2)
150
+ inv_freq = self.inv_freq[None, :, None].float().to(x.device)
151
+ pos = position_ids[:, None, :].float()
152
+ freqs = (inv_freq @ pos).transpose(1, 2)
153
+ emb = torch.cat([freqs, freqs], dim=-1) # (B, S, head_dim)
154
+ return emb.cos().to(x.dtype), emb.sin().to(x.dtype)
155
+
156
+
157
+ def rotate_half(x: torch.Tensor) -> torch.Tensor:
158
+ x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2 :]
159
+ return torch.cat((-x2, x1), dim=-1)
160
+
161
+
162
+ def apply_rotary_pos_emb(
163
+ q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor,
164
+ ) -> tuple[torch.Tensor, torch.Tensor]:
165
+ """cos/sin: (B, S, D) unsqueezed to (B, 1, S, D). q/k: (B, H, S, D)."""
166
+ cos = cos.unsqueeze(1)
167
+ sin = sin.unsqueeze(1)
168
+ return (q * cos) + (rotate_half(q) * sin), (k * cos) + (rotate_half(k) * sin)
169
+
170
+
171
+ def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
172
+ """Expand KV heads for GQA: (B, H_kv, S, D) -> (B, H_kv*n_rep, S, D)."""
173
+ if n_rep == 1:
174
+ return x
175
+ B, H, S, D = x.shape
176
+ return x[:, :, None, :, :].expand(B, H, n_rep, S, D).reshape(B, H * n_rep, S, D)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Layers
181
+ # ---------------------------------------------------------------------------
182
+
183
+
184
+ class SwiGLU(nn.Module):
185
+ """SwiGLU MLP (Lfm2MLP): w2(silu(w1(x)) * w3(x))."""
186
+
187
+ def __init__(self, config: ModelConfig) -> None:
188
+ super().__init__()
189
+ intermediate = config.effective_intermediate_size
190
+ self.w1 = nn.Linear(config.hidden_size, intermediate, bias=False)
191
+ self.w3 = nn.Linear(config.hidden_size, intermediate, bias=False)
192
+ self.w2 = nn.Linear(intermediate, config.hidden_size, bias=False)
193
+
194
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
195
+ return self.w2(F.silu(self.w1(x)) * self.w3(x))
196
+
197
+
198
+ class ShortConv(nn.Module):
199
+ """Gated short convolution (Lfm2ShortConv).
200
+
201
+ in_proj splits hidden -> (B_gate, C_gate, x). B_gate * x feeds a causal
202
+ depthwise Conv1d, output gated by C_gate, then out_proj. Left-padding
203
+ (padding=kernel-1, truncated to seqlen) ensures no future token leakage.
204
+ """
205
+
206
+ def __init__(self, config: ModelConfig) -> None:
207
+ super().__init__()
208
+ h, k = config.hidden_size, config.conv_kernel_size
209
+ self.in_proj = nn.Linear(h, 3 * h, bias=False)
210
+ self.conv = nn.Conv1d(h, h, kernel_size=k, groups=h, bias=False, padding=k - 1)
211
+ self.out_proj = nn.Linear(h, h, bias=False)
212
+
213
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
214
+ # (B, S, D) -> project -> (B, 3D, S) -> chunk into three (B, D, S) tensors
215
+ seqlen = hidden_states.shape[1]
216
+ BCx = self.in_proj(hidden_states).transpose(-1, -2)
217
+ B_gate, C_gate, x = BCx.chunk(3, dim=-2)
218
+ # Causal depthwise conv: left-padded, truncate right to preserve causality
219
+ conv_out = self.conv(B_gate * x)[..., :seqlen]
220
+ y = C_gate * conv_out
221
+ return self.out_proj(y.transpose(-1, -2).contiguous())
222
+
223
+
224
+ class Attention(nn.Module):
225
+ """Grouped query attention with QK RMSNorm (Lfm2Attention).
226
+
227
+ QK norms after projection and before rotary stabilize deep training.
228
+ Present in LFM2 but absent from LLaMA-family models.
229
+ """
230
+
231
+ def __init__(self, config: ModelConfig) -> None:
232
+ super().__init__()
233
+ self.num_heads = config.num_attention_heads
234
+ self.num_kv_heads = config.num_key_value_heads
235
+ self.num_kv_groups = config.num_kv_groups
236
+ self.head_dim = config.head_dim
237
+ self.scaling = self.head_dim ** -0.5
238
+
239
+ self.q_proj = nn.Linear(config.hidden_size, self.num_heads * self.head_dim, bias=False)
240
+ self.k_proj = nn.Linear(config.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
241
+ self.v_proj = nn.Linear(config.hidden_size, self.num_kv_heads * self.head_dim, bias=False)
242
+ self.out_proj = nn.Linear(self.num_heads * self.head_dim, config.hidden_size, bias=False)
243
+
244
+ self.q_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
245
+ self.k_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
246
+
247
+ def forward(
248
+ self,
249
+ hidden_states: torch.Tensor,
250
+ position_embeddings: tuple[torch.Tensor, torch.Tensor],
251
+ ) -> torch.Tensor:
252
+ B, S, _ = hidden_states.shape
253
+
254
+ # Project -> reshape to heads -> QK norm -> transpose to (B, H, S, D)
255
+ q = self.q_proj(hidden_states).view(B, S, self.num_heads, self.head_dim)
256
+ k = self.k_proj(hidden_states).view(B, S, self.num_kv_heads, self.head_dim)
257
+ v = self.v_proj(hidden_states).view(B, S, self.num_kv_heads, self.head_dim)
258
+
259
+ q = self.q_layernorm(q).transpose(1, 2) # (B, H, S, D)
260
+ k = self.k_layernorm(k).transpose(1, 2) # (B, H_kv, S, D)
261
+ v = v.transpose(1, 2) # (B, H_kv, S, D)
262
+
263
+ cos, sin = position_embeddings
264
+ q, k = apply_rotary_pos_emb(q, k, cos, sin)
265
+
266
+ k = repeat_kv(k, self.num_kv_groups) # (B, H, S, D)
267
+ v = repeat_kv(v, self.num_kv_groups)
268
+
269
+ attn_out = F.scaled_dot_product_attention(q, k, v, is_causal=True, scale=self.scaling)
270
+ return self.out_proj(attn_out.transpose(1, 2).reshape(B, S, -1).contiguous())
271
+
272
+
273
+ class DecoderLayer(nn.Module):
274
+ """Pre-norm residual: conv or attention + SwiGLU (Lfm2DecoderLayer).
275
+
276
+ x = x + op(operator_norm(x)) # op = conv or self_attn
277
+ x = x + feed_forward(ffn_norm(x))
278
+ """
279
+
280
+ def __init__(self, config: ModelConfig, layer_idx: int) -> None:
281
+ super().__init__()
282
+ self.is_attention_layer = config.layer_order[layer_idx] == "attn"
283
+
284
+ if self.is_attention_layer:
285
+ self.self_attn = Attention(config)
286
+ else:
287
+ self.conv = ShortConv(config)
288
+
289
+ self.feed_forward = SwiGLU(config)
290
+ self.operator_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
291
+ self.ffn_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
292
+
293
+ def forward(
294
+ self,
295
+ hidden_states: torch.Tensor,
296
+ position_embeddings: tuple[torch.Tensor, torch.Tensor] | None = None,
297
+ ) -> torch.Tensor:
298
+ residual = hidden_states
299
+ if self.is_attention_layer:
300
+ hidden_states = self.self_attn(self.operator_norm(hidden_states), position_embeddings)
301
+ else:
302
+ hidden_states = self.conv(self.operator_norm(hidden_states))
303
+ hidden_states = hidden_states + residual
304
+ return hidden_states + self.feed_forward(self.ffn_norm(hidden_states))
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Full model
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ class LFM2Small(nn.Module):
313
+ """LFM2-small: structured embedding + interleaved backbone + tied LM heads.
314
+
315
+ ~8.3M params at hidden=256 (embedding ~1.7M, backbone ~6.6M, heads tied).
316
+ """
317
+
318
+ def __init__(self, config: ModelConfig, schema: SchemaConfig) -> None:
319
+ super().__init__()
320
+ self.config = config
321
+
322
+ assert len(config.layer_order) == config.num_layers
323
+ assert config.hidden_size % config.num_attention_heads == 0
324
+ assert config.num_attention_heads % config.num_key_value_heads == 0
325
+
326
+ self.embedding = StructuredEmbedding(schema, config.hidden_size)
327
+ self.layers = nn.ModuleList([
328
+ DecoderLayer(config, i) for i in range(config.num_layers)
329
+ ])
330
+ self.rotary_emb = RotaryEmbedding(
331
+ config.head_dim, config.max_position_embeddings, config.rope_theta,
332
+ )
333
+ self.embedding_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
334
+ self.lm_heads = PerFeatureLMHeads(self.embedding)
335
+
336
+ self._init_weights()
337
+
338
+ def _init_weights(self) -> None:
339
+ """Initialize weights following LFM2 conventions. Skips lm_heads (tied)."""
340
+ for name, module in self.named_modules():
341
+ if name.startswith("lm_heads"):
342
+ continue
343
+ if isinstance(module, (nn.Linear, nn.Conv1d)):
344
+ nn.init.normal_(module.weight, std=self.config.initializer_range)
345
+ elif isinstance(module, nn.Embedding):
346
+ nn.init.normal_(module.weight, std=self.config.initializer_range)
347
+
348
+ def backbone_forward(self, token_ids: torch.Tensor) -> torch.Tensor:
349
+ """Embedding + backbone + final norm. Returns (B, S, D).
350
+
351
+ Use for downstream heads (fraud prediction) that skip LM logits.
352
+ """
353
+ hidden_states = self.embedding(token_ids) # (B, T*F, D)
354
+ position_ids = torch.arange(
355
+ hidden_states.shape[1], device=hidden_states.device,
356
+ ).unsqueeze(0)
357
+ position_embeddings = self.rotary_emb(hidden_states, position_ids)
358
+
359
+ for layer in self.layers:
360
+ hidden_states = layer(hidden_states, position_embeddings)
361
+
362
+ return self.embedding_norm(hidden_states)
363
+
364
+ def forward(self, token_ids: torch.Tensor) -> list[torch.Tensor]:
365
+ """Token IDs -> per-feature logits for causal LM pretraining.
366
+
367
+ Args:
368
+ token_ids: (B, T, F) int tensor.
369
+
370
+ Returns:
371
+ 15 tensors, each (B, T*F, vocab_size_f). Position p predicts
372
+ position p+1; the training loop selects head[(p+1) % num_features].
373
+ """
374
+ return self.lm_heads(self.backbone_forward(token_ids))
375
+
376
+ def param_count(self) -> dict[str, int]:
377
+ """Parameter counts by component. Accounts for weight tying."""
378
+ emb = sum(p.numel() for p in self.embedding.parameters())
379
+ backbone = sum(p.numel() for p in self.layers.parameters())
380
+ backbone += sum(p.numel() for p in self.embedding_norm.parameters())
381
+ total = sum(p.numel() for p in self.parameters())
382
+ return {"embedding": emb, "backbone": backbone, "lm_heads_tied": 0, "total_unique": total}
383
+
384
+ @classmethod
385
+ def from_config_files(cls, model_yaml: str | Path, schema_yaml: str | Path) -> LFM2Small:
386
+ """Construct from YAML config files."""
387
+ return cls(ModelConfig.from_yaml(model_yaml), load_schema(schema_yaml))
src/model/task_heads.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Downstream task heads for multi-head fine-tuning.
2
+
3
+ Four heads sharing the pretrained LFM2Small backbone:
4
+
5
+ 1. fraud -- P(fraud) from last-transaction pool (BCE)
6
+ 2. next_merchant -- merchant_id of last tx from prefix (CE, self-supervised)
7
+ 3. amount -- amount bucket of last tx from prefix (CE, self-supervised)
8
+ 4. mcc -- MCC of last tx from prefix (CE, self-supervised)
9
+
10
+ Two head implementations:
11
+ - DownstreamHead -- fresh 2-layer MLP. Doesn't inherit pretrained
12
+ knowledge stored in the tied LM-head weights.
13
+ Default for fraud (which has no LM-head analog).
14
+ - TiedEmbeddingHead -- pool -> adapter -> matmul through the backbone's
15
+ tied embedding table for the target feature.
16
+ Recovers the pretrained next-feature signal that
17
+ a fresh MLP cannot. Use when the target feature
18
+ has a corresponding weight-tied LM head in
19
+ pretraining (anything with target_type "feature:N").
20
+
21
+ Pool strategies:
22
+ last_tx_mean -- mean of last transaction's 15 positions. These have seen
23
+ the full sequence (causal masking), so the representation
24
+ encodes full-sequence context. Used for sequence-level tasks.
25
+ pre_last_tx -- hidden state at position S - num_features - 1 (end of tx 62).
26
+ Sees tx 0-62 only, appropriate for predicting tx 63's features
27
+ without target leakage. Verified safe: both causal attention
28
+ and left-padded Conv1d respect this boundary.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+
35
+ import torch
36
+ import torch.nn as nn
37
+ import torch.nn.functional as F
38
+
39
+
40
+ @dataclass
41
+ class HeadConfig:
42
+ """Single head configuration, loaded from finetune.yaml."""
43
+
44
+ name: str
45
+ output_dim: int
46
+ loss_type: str # "bce" | "ce"
47
+ pool_strategy: str # "last_tx_mean" | "pre_last_tx"
48
+ target_type: str # "sequence_label" | "feature:<idx>"
49
+ weight: float = 1.0
50
+ mlp_hidden: int = 128
51
+ dropout: float = 0.1
52
+
53
+
54
+ class DownstreamHead(nn.Module):
55
+ """Pool hidden states -> 2-layer MLP -> task prediction.
56
+
57
+ Param count per head: ~33K (small output) to ~65K (merchant_id, 5003 classes).
58
+ """
59
+
60
+ def __init__(self, config: HeadConfig, hidden_dim: int, num_features: int) -> None:
61
+ super().__init__()
62
+ self.config = config
63
+ self.num_features = num_features
64
+
65
+ self.mlp = nn.Sequential(
66
+ nn.Linear(hidden_dim, config.mlp_hidden),
67
+ nn.ReLU(),
68
+ nn.Dropout(config.dropout),
69
+ nn.Linear(config.mlp_hidden, config.output_dim),
70
+ )
71
+
72
+ def pool(self, hidden_states: torch.Tensor) -> torch.Tensor:
73
+ """(B, S, D) -> (B, D) via head-specific pooling."""
74
+ nf = self.num_features
75
+ if self.config.pool_strategy == "last_tx_mean":
76
+ return hidden_states[:, -nf:, :].mean(dim=1)
77
+ if self.config.pool_strategy == "pre_last_tx":
78
+ return hidden_states[:, -(nf + 1), :]
79
+ raise ValueError(f"Unknown pool strategy: {self.config.pool_strategy}")
80
+
81
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
82
+ return self.mlp(self.pool(hidden_states))
83
+
84
+ def compute_loss(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
85
+ if self.config.loss_type == "bce":
86
+ return F.binary_cross_entropy_with_logits(
87
+ logits.squeeze(-1), targets.float(),
88
+ )
89
+ return F.cross_entropy(logits, targets)
90
+
91
+ def extract_targets(
92
+ self,
93
+ token_ids: torch.Tensor,
94
+ sequence_labels: torch.Tensor | None,
95
+ aux_targets: dict[str, torch.Tensor] | None = None,
96
+ ) -> torch.Tensor:
97
+ """Get this head's targets from input data.
98
+
99
+ Args:
100
+ token_ids: (B, T, F).
101
+ sequence_labels: (B,) binary fraud labels, or None.
102
+ aux_targets: optional dict of auxiliary per-sequence targets
103
+ (e.g. "amount_range" from amount_range_labels.npy).
104
+ """
105
+ if self.config.target_type == "sequence_label":
106
+ assert sequence_labels is not None
107
+ return sequence_labels
108
+ if self.config.target_type == "amount_range":
109
+ assert aux_targets is not None and "amount_range" in aux_targets
110
+ return aux_targets["amount_range"]
111
+ feat_idx = int(self.config.target_type.split(":")[1])
112
+ return token_ids[:, -1, feat_idx]
113
+
114
+
115
+ class TiedEmbeddingHead(nn.Module):
116
+ """Downstream head that projects through the backbone's tied embedding table.
117
+
118
+ The pretrained LM head for feature F is the value-embedding table for F
119
+ (weight-tied). The fresh-MLP DownstreamHead discards that projection by
120
+ learning a new one from scratch. This head preserves it: pool the backbone
121
+ hidden states, run a small adapter, then matmul through the same embedding
122
+ table the backbone reads from at input time.
123
+
124
+ Why this matters:
125
+ - At fine-tune time the adapter learns a small distribution shift, not
126
+ a 256-d -> vocab_size projection from scratch.
127
+ - Gradients flow back to the embedding table from both the input-embed
128
+ side and this head, just as during pretraining.
129
+ - When the backbone is frozen, the embedding table is frozen too, so
130
+ this head reduces to "small adapter on top of pretrained features."
131
+
132
+ Constraints:
133
+ - Only valid for target_type "feature:N". The tied table is keyed to
134
+ a specific feature index.
135
+ - Output dim is implicitly the vocab_size of that feature.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ config: HeadConfig,
141
+ hidden_dim: int,
142
+ num_features: int,
143
+ value_tables: nn.ModuleList,
144
+ ) -> None:
145
+ super().__init__()
146
+ self.config = config
147
+ self.num_features = num_features
148
+
149
+ if not config.target_type.startswith("feature:"):
150
+ raise ValueError(
151
+ f"TiedEmbeddingHead requires target_type 'feature:N', got "
152
+ f"{config.target_type!r}",
153
+ )
154
+ self.feature_idx = int(config.target_type.split(":")[1])
155
+ # value_tables is the same nn.ModuleList in StructuredEmbedding. By
156
+ # assigning it as a module attribute we share parameters with the
157
+ # backbone — no parameter duplication in state_dict.
158
+ self.value_tables = value_tables
159
+
160
+ # The adapter is the only thing this head learns from scratch. Two
161
+ # layers with a nonlinearity gives enough flexibility for a small
162
+ # distribution-shift correction without obscuring the tied projection.
163
+ self.adapter = nn.Sequential(
164
+ nn.Linear(hidden_dim, hidden_dim),
165
+ nn.ReLU(),
166
+ nn.Dropout(config.dropout),
167
+ nn.Linear(hidden_dim, hidden_dim),
168
+ )
169
+
170
+ def pool(self, hidden_states: torch.Tensor) -> torch.Tensor:
171
+ nf = self.num_features
172
+ if self.config.pool_strategy == "last_tx_mean":
173
+ return hidden_states[:, -nf:, :].mean(dim=1)
174
+ if self.config.pool_strategy == "pre_last_tx":
175
+ return hidden_states[:, -(nf + 1), :]
176
+ raise ValueError(f"Unknown pool strategy: {self.config.pool_strategy}")
177
+
178
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
179
+ pooled = self.pool(hidden_states) # (B, D)
180
+ adapted = self.adapter(pooled) # (B, D)
181
+ # Project through the tied embedding table. F.linear computes
182
+ # adapted @ weight.T, exactly matching how the pretrained LM head
183
+ # produces logits. No bias, matching PerFeatureLMHeads.
184
+ weight = self.value_tables[self.feature_idx].weight # (vocab_f, D)
185
+ return F.linear(adapted, weight)
186
+
187
+ def compute_loss(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
188
+ # Tied heads only support CE — they predict over a tied vocabulary.
189
+ return F.cross_entropy(logits, targets)
190
+
191
+ def extract_targets(
192
+ self,
193
+ token_ids: torch.Tensor,
194
+ sequence_labels: torch.Tensor | None,
195
+ aux_targets: dict[str, torch.Tensor] | None = None,
196
+ ) -> torch.Tensor:
197
+ return token_ids[:, -1, self.feature_idx]
198
+
199
+
200
+ # DownstreamHead and TiedEmbeddingHead share a duck-typed interface.
201
+ # Union type for the head dict in MultiHeadModel.
202
+ AnyHead = DownstreamHead | TiedEmbeddingHead
203
+
204
+
205
+ class MultiHeadModel(nn.Module):
206
+ """Pretrained backbone + downstream task heads.
207
+
208
+ Calls backbone.backbone_forward() once, then each head pools and
209
+ predicts independently. Backbone may be frozen or slow-learned.
210
+ """
211
+
212
+ def __init__(
213
+ self, backbone: nn.Module, heads: dict[str, AnyHead],
214
+ ) -> None:
215
+ super().__init__()
216
+ self.backbone = backbone
217
+ self.heads = nn.ModuleDict(heads)
218
+
219
+ def forward(self, token_ids: torch.Tensor) -> dict[str, torch.Tensor]:
220
+ hidden = self.backbone.backbone_forward(token_ids)
221
+ return {name: head(hidden) for name, head in self.heads.items()}
222
+
223
+ def compute_losses(
224
+ self,
225
+ predictions: dict[str, torch.Tensor],
226
+ token_ids: torch.Tensor,
227
+ sequence_labels: torch.Tensor | None,
228
+ aux_targets: dict[str, torch.Tensor] | None = None,
229
+ ) -> tuple[torch.Tensor, dict[str, float]]:
230
+ """Weighted sum of per-head losses."""
231
+ device = next(iter(predictions.values())).device
232
+ total = torch.tensor(0.0, device=device)
233
+ per_head: dict[str, float] = {}
234
+
235
+ for name, head in self.heads.items():
236
+ targets = head.extract_targets(
237
+ token_ids, sequence_labels, aux_targets,
238
+ ).to(device)
239
+ loss = head.compute_loss(predictions[name], targets)
240
+ total = total + head.config.weight * loss
241
+ per_head[name] = loss.item()
242
+
243
+ return total, per_head
244
+
245
+ def head_param_count(self) -> dict[str, int]:
246
+ """Parameter count per head (excludes shared backbone)."""
247
+ return {
248
+ name: sum(p.numel() for p in head.parameters())
249
+ for name, head in self.heads.items()
250
+ }
src/training/__init__.py ADDED
File without changes
src/training/finetune.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-head fine-tuning loop.
2
+
3
+ Loads a pretrained LFM2Small backbone and attaches downstream task heads:
4
+ - fraud: binary classification (supervised, BCE)
5
+ - next_merchant, amount, mcc: feature prediction (self-supervised, CE)
6
+
7
+ Supports:
8
+ - Joint multi-head training with per-head loss weighting
9
+ - Separate LR for backbone (5e-5) vs heads (1e-3)
10
+ - Optional backbone freezing
11
+ - Random-init baseline (omit --checkpoint)
12
+ - Label-scarcity subsampling (--label-fraction)
13
+ - ROC-AUC / PR-AUC for classification, top-k accuracy for categorical
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import shutil
20
+ import time
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ import torch
27
+ import yaml
28
+ from sklearn.metrics import average_precision_score, roc_auc_score
29
+ from torch.amp import GradScaler, autocast
30
+ from torch.utils.data import DataLoader, Dataset
31
+
32
+ from src.data.schema import load_schema
33
+ from src.model.lfm2_small import LFM2Small, ModelConfig
34
+ from src.model.task_heads import (
35
+ AnyHead,
36
+ DownstreamHead,
37
+ HeadConfig,
38
+ MultiHeadModel,
39
+ TiedEmbeddingHead,
40
+ )
41
+ from src.training.trainer_utils import (
42
+ MetricsLogger,
43
+ create_scheduler,
44
+ load_checkpoint,
45
+ nan_guard,
46
+ save_checkpoint,
47
+ setup_deterministic,
48
+ )
49
+
50
+ log = logging.getLogger(__name__)
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Dataset
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ class FinetuneDataset(Dataset):
59
+ """Token IDs + sequence-level fraud labels + optional amount_range targets."""
60
+
61
+ def __init__(
62
+ self,
63
+ token_ids: np.ndarray,
64
+ sequence_labels: np.ndarray,
65
+ indices: np.ndarray,
66
+ amount_range_targets: np.ndarray | None = None,
67
+ ) -> None:
68
+ self.token_ids = token_ids[indices]
69
+ self.labels = sequence_labels[indices]
70
+ if amount_range_targets is not None:
71
+ self.ar_targets = amount_range_targets[indices]
72
+ else:
73
+ self.ar_targets = np.full(len(indices), -1, dtype=np.int8)
74
+
75
+ def __len__(self) -> int:
76
+ return len(self.token_ids)
77
+
78
+ def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
79
+ return (
80
+ torch.from_numpy(self.token_ids[idx].astype(np.int64)),
81
+ torch.tensor(self.labels[idx], dtype=torch.float32),
82
+ torch.tensor(int(self.ar_targets[idx]), dtype=torch.int64),
83
+ )
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Head construction
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def build_heads(
92
+ config: dict[str, Any],
93
+ hidden_dim: int,
94
+ num_features: int,
95
+ backbone: LFM2Small | None = None,
96
+ ) -> dict[str, AnyHead]:
97
+ """Instantiate downstream heads from finetune.yaml.
98
+
99
+ If a head config sets ``tied: true``, instantiates TiedEmbeddingHead instead
100
+ of DownstreamHead. Tied heads need the backbone's value_tables to share
101
+ parameters, so ``backbone`` must be provided whenever any tied head is
102
+ configured.
103
+ """
104
+ heads: dict[str, AnyHead] = {}
105
+ for name, hcfg in config["heads"].items():
106
+ hc = HeadConfig(
107
+ name=name,
108
+ output_dim=hcfg["output_dim"],
109
+ loss_type=hcfg["loss"],
110
+ pool_strategy=hcfg["pool"],
111
+ target_type=hcfg["target"],
112
+ weight=hcfg.get("weight", 1.0),
113
+ mlp_hidden=hcfg.get("mlp_hidden", 128),
114
+ dropout=hcfg.get("dropout", 0.1),
115
+ )
116
+ if hcfg.get("tied", False):
117
+ if backbone is None:
118
+ raise ValueError(
119
+ f"Head '{name}' uses tied=true but build_heads was called "
120
+ f"without a backbone reference.",
121
+ )
122
+ heads[name] = TiedEmbeddingHead(
123
+ hc, hidden_dim, num_features, backbone.embedding.value_tables,
124
+ )
125
+ else:
126
+ heads[name] = DownstreamHead(hc, hidden_dim, num_features)
127
+ return heads
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Optimizer
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def create_finetune_optimizer(
136
+ model: MultiHeadModel,
137
+ backbone_lr: float,
138
+ head_lr: float,
139
+ betas: tuple[float, float] = (0.9, 0.95),
140
+ weight_decay: float = 0.1,
141
+ ) -> torch.optim.AdamW:
142
+ """Separate LR groups: backbone (slow) vs heads (fast)."""
143
+ backbone_decay: list[torch.nn.Parameter] = []
144
+ backbone_no_decay: list[torch.nn.Parameter] = []
145
+ head_decay: list[torch.nn.Parameter] = []
146
+ head_no_decay: list[torch.nn.Parameter] = []
147
+
148
+ for name, param in model.named_parameters():
149
+ if not param.requires_grad:
150
+ continue
151
+ is_head = name.startswith("heads.")
152
+ if is_head:
153
+ (head_decay if param.dim() >= 2 else head_no_decay).append(param)
154
+ else:
155
+ (backbone_decay if param.dim() >= 2 else backbone_no_decay).append(param)
156
+
157
+ groups = [
158
+ {"params": backbone_decay, "lr": backbone_lr, "weight_decay": weight_decay},
159
+ {"params": backbone_no_decay, "lr": backbone_lr, "weight_decay": 0.0},
160
+ {"params": head_decay, "lr": head_lr, "weight_decay": weight_decay},
161
+ {"params": head_no_decay, "lr": head_lr, "weight_decay": 0.0},
162
+ ]
163
+ groups = [g for g in groups if g["params"]]
164
+ return torch.optim.AdamW(groups, betas=betas)
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Validation
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _topk_accuracy(logits: torch.Tensor, targets: torch.Tensor, k: int) -> float:
173
+ topk = logits.topk(k, dim=-1).indices
174
+ return (topk == targets.unsqueeze(-1)).any(-1).float().mean().item()
175
+
176
+
177
+ @torch.no_grad()
178
+ def validate(
179
+ model: MultiHeadModel,
180
+ val_loader: DataLoader,
181
+ device: torch.device,
182
+ amp_dtype: torch.dtype,
183
+ use_amp: bool,
184
+ ) -> dict[str, float]:
185
+ """Per-head validation: ROC-AUC/PR-AUC for BCE, top-k accuracy for CE."""
186
+ model.eval()
187
+ all_preds: dict[str, list[torch.Tensor]] = {n: [] for n in model.heads}
188
+ all_targets: dict[str, list[torch.Tensor]] = {n: [] for n in model.heads}
189
+ total_loss = 0.0
190
+ n_batches = 0
191
+
192
+ for token_ids, labels, ar in val_loader:
193
+ token_ids = token_ids.to(device)
194
+ labels = labels.to(device)
195
+ ar = ar.to(device)
196
+ aux = {"amount_range": ar}
197
+
198
+ with autocast(device.type, dtype=amp_dtype, enabled=use_amp):
199
+ preds = model(token_ids)
200
+
201
+ loss, _ = model.compute_losses(preds, token_ids, labels, aux)
202
+ total_loss += loss.item()
203
+ n_batches += 1
204
+
205
+ for name, head in model.heads.items():
206
+ targets = head.extract_targets(token_ids, labels, aux)
207
+ if head.config.loss_type == "bce":
208
+ all_preds[name].append(
209
+ torch.sigmoid(preds[name].squeeze(-1)).cpu(),
210
+ )
211
+ else:
212
+ all_preds[name].append(preds[name].cpu())
213
+ all_targets[name].append(targets.cpu())
214
+
215
+ metrics: dict[str, float] = {
216
+ "val/loss": total_loss / max(1, n_batches),
217
+ }
218
+
219
+ for name, head in model.heads.items():
220
+ p = torch.cat(all_preds[name]).float()
221
+ t = torch.cat(all_targets[name]).float()
222
+
223
+ if head.config.loss_type == "bce":
224
+ p_np, t_np = p.numpy(), t.numpy().astype(int)
225
+ if len(np.unique(t_np)) > 1:
226
+ metrics[f"val/{name}/roc_auc"] = roc_auc_score(t_np, p_np)
227
+ metrics[f"val/{name}/pr_auc"] = average_precision_score(t_np, p_np)
228
+ else:
229
+ metrics[f"val/{name}/top1_acc"] = (
230
+ (p.argmax(-1) == t).float().mean().item()
231
+ )
232
+ if head.config.output_dim > 10:
233
+ metrics[f"val/{name}/top5_acc"] = _topk_accuracy(p, t, k=5)
234
+
235
+ model.train()
236
+ return metrics
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Training
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def _cycle(loader: DataLoader):
245
+ while True:
246
+ for batch in loader:
247
+ yield batch
248
+
249
+
250
+ def finetune(
251
+ pretrain_checkpoint: str | Path | None = None,
252
+ finetune_config: str | Path = "configs/finetune.yaml",
253
+ model_config: str | Path = "configs/model.yaml",
254
+ schema_path: str | Path = "data/schema.yaml",
255
+ data_dir: str | Path = "data/synthetic",
256
+ output_dir: str | Path = "experiments",
257
+ max_steps: int | None = None,
258
+ label_fraction: float | None = None,
259
+ ) -> Path:
260
+ """Multi-head fine-tuning. Returns path to final checkpoint.
261
+
262
+ pretrain_checkpoint=None runs random-init baseline.
263
+ label_fraction overrides config's active_label_fraction.
264
+ """
265
+ with open(finetune_config) as f:
266
+ config = yaml.safe_load(f)
267
+ model_cfg = ModelConfig.from_yaml(model_config)
268
+ schema = load_schema(schema_path)
269
+ data_dir = Path(data_dir)
270
+
271
+ total_steps = (
272
+ max_steps if max_steps is not None
273
+ else config["training"]["total_steps"]
274
+ )
275
+
276
+ mode = "finetune" if pretrain_checkpoint else "baseline"
277
+ run_name = f"{mode}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
278
+ run_dir = Path(output_dir) / run_name
279
+ ckpt_dir = run_dir / "checkpoints"
280
+ ckpt_dir.mkdir(parents=True, exist_ok=True)
281
+ shutil.copy2(finetune_config, run_dir / "finetune_config.yaml")
282
+
283
+ repro = config["reproducibility"]
284
+ setup_deterministic(
285
+ repro["seed"], repro["deterministic_warn_only"],
286
+ repro["cublas_workspace_config"],
287
+ )
288
+
289
+ # ---- Data ----
290
+ token_ids = np.load(data_dir / "token_ids.npy")
291
+ sequence_labels = np.load(data_dir / "sequence_labels.npy")
292
+ splits = np.load(data_dir / "split_indices.npz")
293
+
294
+ ar_path = data_dir / "amount_range_labels.npy"
295
+ ar_targets: np.ndarray | None = None
296
+ if ar_path.exists():
297
+ ar_all = np.load(ar_path)
298
+ ar_targets = ar_all[:, -1]
299
+ log.info("Loaded amount_range targets: %d sequences, 16 classes", len(ar_targets))
300
+
301
+ train_indices = splits["train"]
302
+ frac = (
303
+ label_fraction if label_fraction is not None
304
+ else config.get("active_label_fraction", 1.0)
305
+ )
306
+ if frac < 1.0:
307
+ rng = np.random.RandomState(repro["seed"])
308
+ n_keep = max(1, int(len(train_indices) * frac))
309
+ train_indices = rng.choice(train_indices, n_keep, replace=False)
310
+ log.info(
311
+ "Label scarcity: %d/%d training seqs (%.0f%%)",
312
+ n_keep, len(splits["train"]), frac * 100,
313
+ )
314
+
315
+ train_ds = FinetuneDataset(token_ids, sequence_labels, train_indices, ar_targets)
316
+ val_ds = FinetuneDataset(token_ids, sequence_labels, splits["val"], ar_targets)
317
+
318
+ bs = config["training"]["batch_size"]
319
+ train_loader = DataLoader(
320
+ train_ds, batch_size=bs, shuffle=True,
321
+ num_workers=4, prefetch_factor=2, pin_memory=True, drop_last=True,
322
+ )
323
+ val_loader = DataLoader(
324
+ val_ds, batch_size=bs, shuffle=False,
325
+ num_workers=2, pin_memory=True,
326
+ )
327
+
328
+ # ---- Model ----
329
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
330
+ backbone = LFM2Small(model_cfg, schema)
331
+
332
+ if pretrain_checkpoint is not None:
333
+ ckpt = load_checkpoint(pretrain_checkpoint, backbone)
334
+ log.info(
335
+ "Loaded pretrained backbone from %s (step %s)",
336
+ pretrain_checkpoint, ckpt.get("step", "?"),
337
+ )
338
+ else:
339
+ log.info("Random-init baseline: no pretrained weights")
340
+
341
+ heads = build_heads(
342
+ config, model_cfg.hidden_size, model_cfg.num_features, backbone,
343
+ )
344
+ model = MultiHeadModel(backbone, heads).to(device)
345
+
346
+ if config["training"].get("freeze_backbone", False):
347
+ for param in model.backbone.parameters():
348
+ param.requires_grad = False
349
+ log.info("Backbone frozen: training heads only")
350
+
351
+ hpc = model.head_param_count()
352
+ log.info(
353
+ "Heads: %s | total head params: %d",
354
+ {k: f"{v:,}" for k, v in hpc.items()}, sum(hpc.values()),
355
+ )
356
+
357
+ # ---- Optimizer + scheduler ----
358
+ opt_cfg = config["optimizer"]
359
+ head_lr = opt_cfg.get("head_lr", opt_cfg["lr"] * 20)
360
+ optimizer = create_finetune_optimizer(
361
+ model, backbone_lr=opt_cfg["lr"], head_lr=head_lr,
362
+ betas=tuple(opt_cfg["betas"]), weight_decay=opt_cfg["weight_decay"],
363
+ )
364
+ sched_cfg = config["scheduler"]
365
+ scheduler = create_scheduler(
366
+ optimizer, sched_cfg["warmup_steps"], total_steps,
367
+ sched_cfg["min_lr_fraction"],
368
+ )
369
+
370
+ # ---- Mixed precision ----
371
+ use_amp = device.type == "cuda"
372
+ amp_dtype = (
373
+ torch.bfloat16
374
+ if use_amp and torch.cuda.is_bf16_supported()
375
+ else torch.float32
376
+ )
377
+ scaler = GradScaler(enabled=(use_amp and amp_dtype == torch.float16))
378
+
379
+ # ---- Logging ----
380
+ log_cfg = config.get("logging", {})
381
+ metrics_logger = MetricsLogger(
382
+ run_dir / "logs", run_name, log_cfg.get("wandb", "auto"),
383
+ )
384
+ metrics_logger.log_config(config)
385
+
386
+ # ---- Training loop ----
387
+ accum_steps = config["training"]["effective_batch_size"] // bs
388
+ grad_clip = config["training"]["grad_clip"]
389
+ ckpt_interval = config["checkpointing"]["interval_steps"]
390
+ val_interval = config["validation"]["interval_steps"]
391
+ log_interval = log_cfg.get("log_interval", 50)
392
+
393
+ data_iter = _cycle(train_loader)
394
+ model.train()
395
+ t0 = time.monotonic()
396
+
397
+ log.info("Fine-tuning [%s]: steps 0 -> %d", mode, total_steps)
398
+
399
+ for step in range(total_steps):
400
+ acc_loss = 0.0
401
+ acc_ph: dict[str, float] = {}
402
+
403
+ for _ in range(accum_steps):
404
+ tids, lbls, ar = next(data_iter)
405
+ tids, lbls, ar = tids.to(device), lbls.to(device), ar.to(device)
406
+ aux = {"amount_range": ar}
407
+
408
+ with autocast(device.type, dtype=amp_dtype, enabled=use_amp):
409
+ preds = model(tids)
410
+ loss, ph = model.compute_losses(preds, tids, lbls, aux)
411
+ scaled = loss / accum_steps
412
+
413
+ scaler.scale(scaled).backward()
414
+ acc_loss += loss.item()
415
+ for k, v in ph.items():
416
+ acc_ph[k] = acc_ph.get(k, 0.0) + v
417
+
418
+ acc_loss /= accum_steps
419
+ for k in acc_ph:
420
+ acc_ph[k] /= accum_steps
421
+
422
+ scaler.unscale_(optimizer)
423
+ grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
424
+ scaler.step(optimizer)
425
+ scaler.update()
426
+ scheduler.step()
427
+ optimizer.zero_grad()
428
+
429
+ if config["nan_guard"]["enabled"]:
430
+ nan_guard(torch.tensor(acc_loss), step, run_dir / "debug", model)
431
+
432
+ if step % log_interval == 0 or step == 0:
433
+ gn = (
434
+ grad_norm.item()
435
+ if isinstance(grad_norm, torch.Tensor)
436
+ else float(grad_norm)
437
+ )
438
+ lr_bb = optimizer.param_groups[0]["lr"]
439
+ lr_hd = optimizer.param_groups[-1]["lr"]
440
+ metrics_logger.log_scalar("loss/total", acc_loss, step)
441
+ metrics_logger.log_scalar("lr/backbone", lr_bb, step)
442
+ metrics_logger.log_scalar("lr/heads", lr_hd, step)
443
+ for k, v in acc_ph.items():
444
+ metrics_logger.log_scalar(f"loss/{k}", v, step)
445
+ parts = " ".join(f"{k}={v:.3f}" for k, v in acc_ph.items())
446
+ log.info(
447
+ "step %d/%d | loss=%.4f [%s] | lr=%.2e/%.2e | grad=%.2f",
448
+ step, total_steps, acc_loss, parts, lr_bb, lr_hd, gn,
449
+ )
450
+
451
+ if (step + 1) % ckpt_interval == 0 or step == total_steps - 1:
452
+ save_checkpoint(
453
+ ckpt_dir / f"step_{step:06d}.pt",
454
+ model, optimizer, scheduler, step, config,
455
+ )
456
+
457
+ if (step + 1) % val_interval == 0 or step == total_steps - 1:
458
+ val_m = validate(
459
+ model, val_loader, device, amp_dtype, use_amp,
460
+ )
461
+ for k, v in val_m.items():
462
+ metrics_logger.log_scalar(k, v, step)
463
+ log.info(
464
+ "step %d | %s", step,
465
+ " | ".join(f"{k}={v:.4f}" for k, v in val_m.items()),
466
+ )
467
+
468
+ metrics_logger.close()
469
+ final = ckpt_dir / f"step_{total_steps - 1:06d}.pt"
470
+ log.info("Fine-tuning complete [%s]. Final: %s", mode, final)
471
+ return final
472
+
473
+
474
+ if __name__ == "__main__":
475
+ import argparse
476
+
477
+ logging.basicConfig(
478
+ level=logging.INFO,
479
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
480
+ )
481
+ parser = argparse.ArgumentParser(description="Multi-head fine-tuning")
482
+ parser.add_argument(
483
+ "--checkpoint", type=str, default=None,
484
+ help="Pretrained checkpoint path. Omit for random-init baseline.",
485
+ )
486
+ parser.add_argument("--config", type=str, default="configs/finetune.yaml")
487
+ parser.add_argument("--model-config", type=str, default="configs/model.yaml")
488
+ parser.add_argument("--schema", type=str, default="data/schema.yaml")
489
+ parser.add_argument("--data-dir", type=str, default="data/synthetic")
490
+ parser.add_argument("--output-dir", type=str, default="experiments")
491
+ parser.add_argument("--max-steps", type=int, default=None)
492
+ parser.add_argument("--label-fraction", type=float, default=None)
493
+ args = parser.parse_args()
494
+
495
+ finetune(
496
+ pretrain_checkpoint=args.checkpoint,
497
+ finetune_config=args.config,
498
+ model_config=args.model_config,
499
+ schema_path=args.schema,
500
+ data_dir=args.data_dir,
501
+ output_dir=args.output_dir,
502
+ max_steps=args.max_steps,
503
+ label_fraction=args.label_fraction,
504
+ )
src/training/trainer_utils.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared training utilities for pretraining and fine-tuning.
2
+
3
+ All training loops import from here. No duplicated optimizer construction,
4
+ scheduler logic, or checkpoint I/O across pretrain.py and finetune.py.
5
+
6
+ Functions:
7
+ create_optimizer — AdamW, weight decay on 2D+ params only
8
+ create_scheduler — Linear warmup + cosine decay to min_lr_fraction
9
+ save_checkpoint — Atomic write (.tmp then rename)
10
+ load_checkpoint — Restore with optional fingerprint verification
11
+ setup_deterministic — Seed all RNGs, deterministic algorithms
12
+ nan_guard — Per-step finiteness check with debug dump
13
+
14
+ Class:
15
+ MetricsLogger — Tensorboard + optional wandb
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import math
22
+ import os
23
+ import random
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+ import torch
29
+ import torch.nn as nn
30
+ from torch.optim import AdamW
31
+ from torch.optim.lr_scheduler import LambdaLR
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Optimizer / Scheduler
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def create_optimizer(
42
+ model: nn.Module,
43
+ lr: float = 3e-4,
44
+ betas: tuple[float, float] = (0.9, 0.95),
45
+ weight_decay: float = 0.1,
46
+ ) -> AdamW:
47
+ """AdamW with weight decay on matrices only (not norms, not biases)."""
48
+ decay, no_decay = [], []
49
+ for param in model.parameters():
50
+ if not param.requires_grad:
51
+ continue
52
+ (decay if param.dim() >= 2 else no_decay).append(param)
53
+
54
+ return AdamW(
55
+ [{"params": decay, "weight_decay": weight_decay},
56
+ {"params": no_decay, "weight_decay": 0.0}],
57
+ lr=lr, betas=betas,
58
+ )
59
+
60
+
61
+ def create_scheduler(
62
+ optimizer: AdamW,
63
+ warmup_steps: int,
64
+ total_steps: int,
65
+ min_lr_fraction: float = 0.1,
66
+ ) -> LambdaLR:
67
+ """Linear warmup then cosine decay to min_lr_fraction * peak LR."""
68
+
69
+ def lr_lambda(step: int) -> float:
70
+ if step < warmup_steps:
71
+ return step / max(1, warmup_steps)
72
+ progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
73
+ return min_lr_fraction + (1 - min_lr_fraction) * 0.5 * (1 + math.cos(math.pi * progress))
74
+
75
+ return LambdaLR(optimizer, lr_lambda)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Checkpointing
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def save_checkpoint(
84
+ path: str | Path,
85
+ model: nn.Module,
86
+ optimizer: AdamW,
87
+ scheduler: LambdaLR,
88
+ step: int,
89
+ config: dict[str, Any],
90
+ fingerprint: str = "",
91
+ ) -> None:
92
+ """Atomic save: writes to .tmp then os.replace for crash safety."""
93
+ path = Path(path)
94
+ path.parent.mkdir(parents=True, exist_ok=True)
95
+ tmp = path.with_suffix(".tmp")
96
+
97
+ torch.save({
98
+ "model_state_dict": model.state_dict(),
99
+ "optimizer_state_dict": optimizer.state_dict(),
100
+ "scheduler_state_dict": scheduler.state_dict(),
101
+ "step": step,
102
+ "config": config,
103
+ "fingerprint": fingerprint,
104
+ }, tmp)
105
+ os.replace(tmp, path)
106
+ log.info("Checkpoint saved: %s (step %d)", path, step)
107
+
108
+
109
+ def load_checkpoint(
110
+ path: str | Path,
111
+ model: nn.Module,
112
+ optimizer: AdamW | None = None,
113
+ scheduler: LambdaLR | None = None,
114
+ expected_fingerprint: str | None = None,
115
+ strict: bool = True,
116
+ ) -> dict[str, Any]:
117
+ """Load checkpoint. Pass optimizer/scheduler=None to skip restoring them.
118
+
119
+ strict=False allows loading pretrained weights into a model with extra
120
+ parameters (e.g. a fraud head added for fine-tuning).
121
+ Raises ValueError on fingerprint mismatch.
122
+ """
123
+ ckpt = torch.load(path, map_location="cpu", weights_only=False)
124
+
125
+ if expected_fingerprint is not None:
126
+ saved = ckpt.get("fingerprint", "")
127
+ if saved != expected_fingerprint:
128
+ raise ValueError(
129
+ f"Fingerprint mismatch: checkpoint='{saved}', expected='{expected_fingerprint}'"
130
+ )
131
+
132
+ model.load_state_dict(ckpt["model_state_dict"], strict=strict)
133
+ if optimizer is not None and "optimizer_state_dict" in ckpt:
134
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
135
+ if scheduler is not None and "scheduler_state_dict" in ckpt:
136
+ scheduler.load_state_dict(ckpt["scheduler_state_dict"])
137
+
138
+ log.info("Checkpoint loaded: %s (step %s)", path, ckpt.get("step", "?"))
139
+ return ckpt
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Reproducibility
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def setup_deterministic(
148
+ seed: int = 42,
149
+ warn_only: bool = True,
150
+ cublas_workspace_config: str = ":4096:8",
151
+ ) -> None:
152
+ """Seed all RNGs and enable deterministic CUDA kernels.
153
+
154
+ warn_only=True because depthwise Conv1d backward may lack a deterministic
155
+ CUDA kernel �� logs a warning instead of crashing (D19e).
156
+ """
157
+ random.seed(seed)
158
+ np.random.seed(seed)
159
+ torch.manual_seed(seed)
160
+ if torch.cuda.is_available():
161
+ torch.cuda.manual_seed_all(seed)
162
+
163
+ torch.use_deterministic_algorithms(True, warn_only=warn_only)
164
+ torch.backends.cudnn.deterministic = True
165
+ torch.backends.cudnn.benchmark = False
166
+ os.environ["CUBLAS_WORKSPACE_CONFIG"] = cublas_workspace_config
167
+
168
+ log.info("Deterministic mode: seed=%d, warn_only=%s", seed, warn_only)
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # NaN guard
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ class NaNError(RuntimeError):
177
+ """Raised when loss contains NaN or Inf."""
178
+ pass
179
+
180
+
181
+ def nan_guard(
182
+ loss: torch.Tensor,
183
+ step: int,
184
+ output_dir: str | Path,
185
+ model: nn.Module | None = None,
186
+ ) -> None:
187
+ """Check loss is finite. Dumps debug state and raises NaNError on failure."""
188
+ if torch.isfinite(loss):
189
+ return
190
+
191
+ output_dir = Path(output_dir)
192
+ output_dir.mkdir(parents=True, exist_ok=True)
193
+ debug_path = output_dir / f"nan_debug_step{step}.pt"
194
+
195
+ state: dict[str, Any] = {"step": step, "loss": loss.detach().cpu()}
196
+ if model is not None:
197
+ state["model_state_dict"] = {
198
+ k: v.detach().cpu() for k, v in model.state_dict().items()
199
+ }
200
+
201
+ torch.save(state, debug_path)
202
+ log.error("NaN/Inf loss at step %d. Debug state: %s", step, debug_path)
203
+ raise NaNError(f"Loss is {loss.item()} at step {step}. Debug: {debug_path}")
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Metrics logging
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ class MetricsLogger:
212
+ """Tensorboard writer + optional wandb. Wandb activates on WANDB_API_KEY."""
213
+
214
+ def __init__(
215
+ self,
216
+ log_dir: str | Path,
217
+ experiment_name: str = "run",
218
+ use_wandb: str = "auto",
219
+ wandb_project: str = "lfm2-transactions",
220
+ ) -> None:
221
+ from torch.utils.tensorboard import SummaryWriter
222
+
223
+ self.tb = SummaryWriter(log_dir=str(log_dir))
224
+ self.wandb_run = None
225
+
226
+ if use_wandb == "auto" and os.environ.get("WANDB_API_KEY"):
227
+ try:
228
+ import wandb
229
+
230
+ self.wandb_run = wandb.init(
231
+ project=wandb_project, name=experiment_name, dir=str(log_dir),
232
+ )
233
+ except ImportError:
234
+ log.info("wandb not installed, tensorboard only")
235
+
236
+ def log_scalar(self, tag: str, value: float, step: int) -> None:
237
+ self.tb.add_scalar(tag, value, step)
238
+ if self.wandb_run is not None:
239
+ import wandb
240
+
241
+ wandb.log({tag: value}, step=step)
242
+
243
+ def log_per_feature_losses(
244
+ self,
245
+ total_loss: float,
246
+ per_feature_losses: dict[str, float],
247
+ step: int,
248
+ ) -> None:
249
+ self.log_scalar("loss/total", total_loss, step)
250
+ for name, val in per_feature_losses.items():
251
+ self.log_scalar(f"loss/{name}", val, step)
252
+
253
+ def log_config(self, config: dict[str, Any]) -> None:
254
+ self.tb.add_text("config", str(config), 0)
255
+ if self.wandb_run is not None:
256
+ import wandb
257
+
258
+ wandb.config.update(config)
259
+
260
+ def close(self) -> None:
261
+ self.tb.close()
262
+ if self.wandb_run is not None:
263
+ import wandb
264
+
265
+ wandb.finish()