Commit ·
083b138
1
Parent(s): b3112c7
add multi-head demo as 4th-6th tabs; restore Why Liquid + Integration
Browse files- README.md +14 -6
- app.py +20 -5
- checkpoints/multihead_v3.pt +3 -0
- data/synthetic/cast_token_ids.npy +3 -0
- data/synthetic/sequence_labels.npy +3 -0
- data/synthetic/split_indices.npz +2 -2
- data/synthetic/token_ids.npy +2 -2
- data/synthetic/tokenizer_state.json +424 -0
- encoder/configs/model_nocompress.yaml +59 -0
- encoder/src/data/loader.py +139 -0
- encoder/src/demo/app.py +490 -0
- encoder/src/demo/copilot_app_unified.py +172 -105
- encoder/src/demo/inference.py +152 -0
- encoder/src/demo/render.py +728 -0
- encoder/src/model/encoder_heads.py +131 -0
- encoder/src/model/lfm_pseudo_token_wrapper.py +213 -0
- encoder/src/model/transaction_encoder_nocompress.py +118 -0
- encoder/src/model/transaction_fm.py +268 -0
- requirements.txt +1 -0
- src/data/generator.py +1022 -0
- src/data/tokenizer.py +316 -0
- src/demo/__init__.py +1 -0
- src/demo/app.py +828 -0
- src/demo/decode.py +234 -0
- src/demo/merchant_catalog.py +177 -0
- src/demo/profile_inference.py +325 -0
- src/demo/render.py +833 -0
- src/model/__init__.py +0 -0
- src/model/embedding.py +67 -0
- src/model/heads.py +65 -0
- src/model/lfm2_small.py +387 -0
- src/model/task_heads.py +250 -0
- src/training/__init__.py +0 -0
- src/training/finetune.py +504 -0
- src/training/trainer_utils.py +265 -0
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: Transaction
|
| 3 |
emoji: 🪙
|
| 4 |
colorFrom: gray
|
| 5 |
colorTo: indigo
|
|
@@ -9,7 +9,7 @@ app_file: app.py
|
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
# Transaction
|
| 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 |
-
|
| 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)
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 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 Encoder — LFM2.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
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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 · LFM2.5-350M base · 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"· 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:
|
| 2 |
|
| 3 |
-
|
| 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 |
-
- "
|
| 12 |
-
|
| 13 |
-
- "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
| 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 · LFM2.5-350M backbone · 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 · Collections · 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
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
with gr.Tabs():
|
| 102 |
-
with gr.Tab("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
|
| 111 |
-
def
|
| 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 |
-
|
| 120 |
dispute_cast: Path,
|
| 121 |
collections_cast: Path,
|
| 122 |
fraud_cast: Path,
|
| 123 |
device: torch.device,
|
| 124 |
) -> tuple[CopilotModel, CollectionsCopilotModel, FraudPatternCopilotModel]:
|
| 125 |
-
"""Load
|
| 126 |
-
|
| 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=
|
| 134 |
cast_path=dispute_cast,
|
| 135 |
device=device,
|
| 136 |
)
|
| 137 |
-
print(f"[2/3]
|
| 138 |
collections_model = CollectionsCopilotModel.from_paths(
|
| 139 |
checkpoint_path=collections_checkpoint,
|
| 140 |
model_config_path=collections_config,
|
| 141 |
schema_path=schema,
|
| 142 |
-
histories_path=
|
| 143 |
cast_path=collections_cast,
|
| 144 |
device=device,
|
| 145 |
)
|
| 146 |
-
print(f"[3/3]
|
| 147 |
fraud_model = FraudPatternCopilotModel.from_paths(
|
| 148 |
checkpoint_path=fraud_checkpoint,
|
| 149 |
model_config_path=fraud_config,
|
| 150 |
schema_path=schema,
|
| 151 |
-
histories_path=
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"--
|
| 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/
|
| 206 |
)
|
|
|
|
| 207 |
parser.add_argument(
|
| 208 |
-
"--
|
| 209 |
type=Path,
|
| 210 |
-
default=Path("
|
| 211 |
)
|
| 212 |
parser.add_argument(
|
| 213 |
-
"--
|
| 214 |
type=Path,
|
| 215 |
-
default=Path("
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
demo.queue().launch(
|
| 246 |
server_name="0.0.0.0",
|
| 247 |
server_port=args.port,
|
| 248 |
share=args.share,
|
| 249 |
-
theme=
|
| 250 |
-
|
| 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 —
|
| 93 |
+
especially across more than one business unit, customer, or downstream task —
|
| 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²). 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’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 — 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²)
|
| 245 |
+
attention to the sparse global ones. A pure transformer would spend
|
| 246 |
+
O(N²) 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–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> ·
|
| 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;">→</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 × 15 feat = 960 tokens | "
|
| 429 |
+
"amount → 16 quantile bins | "
|
| 430 |
+
"merchant_id top-10K + frequency bucketing for the tail | "
|
| 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 | "
|
| 446 |
+
"value_tables[f](token) + type_table(f) | "
|
| 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 < 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) | "
|
| 460 |
+
"1.2B: d_lfm=2048, project from d_encoder=512-1024 → 2048 | "
|
| 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’s "
|
| 468 |
+
"parameters are excluded from the optimizer’s parameter set during "
|
| 469 |
+
"training — gradients flow through the backbone to update the upstream "
|
| 470 |
+
"encoder and downstream heads, but the backbone’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’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 | "
|
| 480 |
+
"LoRA r=16, α=32, dropout 0.05 on q_proj / k_proj / v_proj / out_proj | "
|
| 481 |
+
"PEFT wraps the leaf modules | "
|
| 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 | "
|
| 496 |
+
"Pool: <code>last_tx_mean</code> for sequence tasks | "
|
| 497 |
+
"Pool: <code>pre_last_tx_mean</code> for next-tx tasks | "
|
| 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 | "
|
| 512 |
+
"Categorical: softmax(logits) → top-k + calibration | "
|
| 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’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 · ~1M params
|
| 538 |
+
</div>
|
| 539 |
+
<div style="font-size: 12px; color: {_TEXT_MUTED}; line-height: 1.5;">
|
| 540 |
+
Low-rank adapters on the backbone’s attention projections.
|
| 541 |
+
Initialized so the LoRA path contributes zero at step 0, then steers
|
| 542 |
+
attention behavior toward the customer’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 · ~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’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 · ~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, β = (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) | "
|
| 622 |
+
"Multi-tenant: shared backbone forward + per-request LoRA switching | "
|
| 623 |
+
"CPU (~5s/inference) or H100 (<100ms), same code | "
|
| 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’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> ·
|
| 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 · LFM2.5 Architecture · 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 × 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 |
+
✓ 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 |
+
✗ 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 × 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;">< 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 × 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;">< 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};">■ Conv (10): local patterns, k=3, O(n)</span>
|
| 292 |
+
<span style="color: {_purple};">■ Attention (6): global context, GQA, O(n²)</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> ·
|
| 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²) 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 × 15 feat", "960", "5.96 ms", "9.01 ms", "1.5x"])}
|
| 447 |
+
{_table_row(["64 tx × 30 feat", "1,920", "13.05 ms", "23.84 ms", "1.8x"])}
|
| 448 |
+
{_table_row(["128 tx × 30 feat", "3,840", "35.65 ms", "75.95 ms",
|
| 449 |
+
"<b>2.1x</b>"], bold_last=False)}
|
| 450 |
+
{_table_row(["256 tx × 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²) attention to the sparse
|
| 529 |
+
global patterns. A pure transformer allocates O(n²) 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> ·
|
| 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;">→</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;">→</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;">→</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;">→</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;">→</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;">→</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) | "
|
| 703 |
+
"Order features by semantic family for the conv window | "
|
| 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 → 16-256 quantile bins | "
|
| 712 |
+
"merchant_id: top 10K distinct, rest into ~1K frequency buckets | "
|
| 713 |
+
"Unseen values at inference → 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) | "
|
| 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 | 32Q/8KV GQA | "
|
| 728 |
+
"QK-RMSNorm before RoPE | theta=1M | "
|
| 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 | "
|
| 736 |
+
"Cosine decay to 10% | BF16 (loss in FP32) | "
|
| 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) | "
|
| 744 |
+
"Pool: last_tx_mean for sequence tasks, pre_last_tx for next-tx | "
|
| 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 | "
|
| 755 |
+
"Pure attn: ~64 GB | "
|
| 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 ·
|
| 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()
|