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

add Cold Start tab: SSL pretraining lifts new-merchant fraud catch ~5x

Browse files

new encoder/src/demo/cold_start.py renders the result tab: three stat
cards, the two-panel figure (fraud caught vs label history; fraud
caught vs approval rate at 1% labels), a 7-column operating-point
table read from docs/figures/recall_at_approval.json, the "how it
works" paragraph for the SSL stage, and a synthetic-data scope block.

wired as a new tab in both app.py (standalone 4-tab) and
copilot_app_unified.py (7-tab unified). figure is base64-embedded so
the tab is self-contained html. *.png added to LFS tracking.

result on synthetic data (held-out test, n=20k, 3.7% base rate, 1%
labels = stand-in for a newly onboarded entity, 95% approval rate):
fraud caught 7.5% -> 36.0% (~4.8x), false-decline rate 4.9% -> 3.8%.
roc-auc 0.575 -> 0.814; pr-auc 0.048 -> 0.210. whole-book bonus at
full labels and 1% decline budget: +16 pts.

mechanism: stage-1 self-supervised next-feature prediction through the
frozen lfm2.5 base anchors the encoder's value tables before the
supervised fine-tune. same connector-pretraining stage lfm2.5-audio
and lfm2.5-vl already use.

.gitattributes CHANGED
@@ -1,3 +1,4 @@
1
  *.pt filter=lfs diff=lfs merge=lfs -text
2
  *.npy filter=lfs diff=lfs merge=lfs -text
3
  *.npz filter=lfs diff=lfs merge=lfs -text
 
 
1
  *.pt filter=lfs diff=lfs merge=lfs -text
2
  *.npy filter=lfs diff=lfs merge=lfs -text
3
  *.npz filter=lfs diff=lfs merge=lfs -text
4
+ *.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -22,11 +22,16 @@ per-surface LoRA adapts the attention layers.
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.
 
22
 
23
  ## What this demo shows
24
 
25
+ Seven 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
+ - **Cold Start** — a brief self-supervised pretraining stage ("SSL")
31
+ catches ~5× more fraud at a fixed approval rate on a newly onboarded
32
+ merchant with almost no labels, while declining fewer good customers.
33
+ The same connector-pretraining stage LFM2.5-Audio and LFM2.5-VL use.
34
+ Synthetic-data demonstration with operating-point numbers + figure.
35
  - **Why Liquid** — architectural pitch for the encoder-on-frozen-backbone
36
  recipe.
37
  - **Integration** — build-it-yourself guide.
docs/figures/cold_start_demo.png ADDED

Git LFS Details

  • SHA256: 3d77a9ec5978a205f54e20e673d2e1a1c69010715f129ca0b6c2b2f4562ede22
  • Pointer size: 131 Bytes
  • Size of remote file: 218 kB
docs/figures/recall_at_approval.json ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "base_rate": 0.03665,
3
+ "n_test": 20000,
4
+ "split": "test",
5
+ "runs": {
6
+ "ssl_1pct": {
7
+ "roc_auc": 0.8140344300750755,
8
+ "pr_auc": 0.2104627331942034,
9
+ "operating_points": {
10
+ "appr99": {
11
+ "approval_rate": 0.99,
12
+ "decline_rate": 0.01145,
13
+ "recall": 0.12551159618008187,
14
+ "false_decline_rate": 0.007110603622774692,
15
+ "precision": 0.4017467248908297
16
+ },
17
+ "appr98": {
18
+ "approval_rate": 0.98,
19
+ "decline_rate": 0.02005,
20
+ "recall": 0.19508867667121418,
21
+ "false_decline_rate": 0.01339077178595526,
22
+ "precision": 0.3566084788029925
23
+ },
24
+ "appr97": {
25
+ "approval_rate": 0.97,
26
+ "decline_rate": 0.03005,
27
+ "recall": 0.2660300136425648,
28
+ "false_decline_rate": 0.021072299787200915,
29
+ "precision": 0.324459234608985
30
+ },
31
+ "appr95": {
32
+ "approval_rate": 0.95,
33
+ "decline_rate": 0.05005,
34
+ "recall": 0.3601637107776262,
35
+ "false_decline_rate": 0.03825193335755437,
36
+ "precision": 0.26373626373626374
37
+ }
38
+ }
39
+ },
40
+ "nossl_1pct": {
41
+ "roc_auc": 0.5746099668824207,
42
+ "pr_auc": 0.04786141744299679,
43
+ "operating_points": {
44
+ "appr99": {
45
+ "approval_rate": 0.99,
46
+ "decline_rate": 0.01,
47
+ "recall": 0.021828103683492497,
48
+ "false_decline_rate": 0.009550007785332434,
49
+ "precision": 0.08
50
+ },
51
+ "appr98": {
52
+ "approval_rate": 0.98,
53
+ "decline_rate": 0.02005,
54
+ "recall": 0.03956343792633015,
55
+ "false_decline_rate": 0.0193076244355634,
56
+ "precision": 0.07231920199501247
57
+ },
58
+ "appr97": {
59
+ "approval_rate": 0.97,
60
+ "decline_rate": 0.03,
61
+ "recall": 0.05320600272851296,
62
+ "false_decline_rate": 0.029117143302018995,
63
+ "precision": 0.065
64
+ },
65
+ "appr95": {
66
+ "approval_rate": 0.95,
67
+ "decline_rate": 0.0502,
68
+ "recall": 0.07503410641200546,
69
+ "false_decline_rate": 0.04925520319717652,
70
+ "precision": 0.054780876494023904
71
+ }
72
+ }
73
+ },
74
+ "ssl_10pct": {
75
+ "roc_auc": 0.9393715555037555,
76
+ "pr_auc": 0.8570152888812671,
77
+ "operating_points": {
78
+ "appr99": {
79
+ "approval_rate": 0.99,
80
+ "decline_rate": 0.02215,
81
+ "recall": 0.6016371077762619,
82
+ "false_decline_rate": 0.00010380443244926559,
83
+ "precision": 0.9954853273137697
84
+ },
85
+ "appr98": {
86
+ "approval_rate": 0.98,
87
+ "decline_rate": 0.02215,
88
+ "recall": 0.6016371077762619,
89
+ "false_decline_rate": 0.00010380443244926559,
90
+ "precision": 0.9954853273137697
91
+ },
92
+ "appr97": {
93
+ "approval_rate": 0.97,
94
+ "decline_rate": 0.03,
95
+ "recall": 0.7803547066848567,
96
+ "false_decline_rate": 0.0014532620542897182,
97
+ "precision": 0.9533333333333334
98
+ },
99
+ "appr95": {
100
+ "approval_rate": 0.95,
101
+ "decline_rate": 0.05015,
102
+ "recall": 0.8472032742155525,
103
+ "false_decline_rate": 0.019826646597809725,
104
+ "precision": 0.6191425722831505
105
+ }
106
+ }
107
+ },
108
+ "nossl_10pct": {
109
+ "roc_auc": 0.9259714016664364,
110
+ "pr_auc": 0.7596305449815982,
111
+ "operating_points": {
112
+ "appr99": {
113
+ "approval_rate": 0.99,
114
+ "decline_rate": 0.0139,
115
+ "recall": 0.37789904502046384,
116
+ "false_decline_rate": 5.1902216224632795e-05,
117
+ "precision": 0.9964028776978417
118
+ },
119
+ "appr98": {
120
+ "approval_rate": 0.98,
121
+ "decline_rate": 0.02,
122
+ "recall": 0.5266030013642565,
123
+ "false_decline_rate": 0.0007266310271448591,
124
+ "precision": 0.965
125
+ },
126
+ "appr97": {
127
+ "approval_rate": 0.97,
128
+ "decline_rate": 0.03,
129
+ "recall": 0.6657571623465212,
130
+ "false_decline_rate": 0.005813048217158873,
131
+ "precision": 0.8133333333333334
132
+ },
133
+ "appr95": {
134
+ "approval_rate": 0.95,
135
+ "decline_rate": 0.05,
136
+ "recall": 0.7612551159618008,
137
+ "false_decline_rate": 0.022940779571287696,
138
+ "precision": 0.558
139
+ }
140
+ }
141
+ },
142
+ "ssl_100pct": {
143
+ "roc_auc": 0.9561845809915676,
144
+ "pr_auc": 0.8820119418197224,
145
+ "operating_points": {
146
+ "appr99": {
147
+ "approval_rate": 0.99,
148
+ "decline_rate": 0.01595,
149
+ "recall": 0.43519781718963163,
150
+ "false_decline_rate": 0.0,
151
+ "precision": 1.0
152
+ },
153
+ "appr98": {
154
+ "approval_rate": 0.98,
155
+ "decline_rate": 0.02015,
156
+ "recall": 0.5497953615279673,
157
+ "false_decline_rate": 0.0,
158
+ "precision": 1.0
159
+ },
160
+ "appr97": {
161
+ "approval_rate": 0.97,
162
+ "decline_rate": 0.03005,
163
+ "recall": 0.7939972714870396,
164
+ "false_decline_rate": 0.000986142108268023,
165
+ "precision": 0.9683860232945092
166
+ },
167
+ "appr95": {
168
+ "approval_rate": 0.95,
169
+ "decline_rate": 0.05,
170
+ "recall": 0.8663028649386084,
171
+ "false_decline_rate": 0.01894430892199097,
172
+ "precision": 0.635
173
+ }
174
+ }
175
+ },
176
+ "nossl_100pct": {
177
+ "roc_auc": 0.9452378866918683,
178
+ "pr_auc": 0.7709143923552446,
179
+ "operating_points": {
180
+ "appr99": {
181
+ "approval_rate": 0.99,
182
+ "decline_rate": 0.01005,
183
+ "recall": 0.2742155525238745,
184
+ "false_decline_rate": 0.0,
185
+ "precision": 1.0
186
+ },
187
+ "appr98": {
188
+ "approval_rate": 0.98,
189
+ "decline_rate": 0.02,
190
+ "recall": 0.5252387448840382,
191
+ "false_decline_rate": 0.0007785332433694919,
192
+ "precision": 0.9625
193
+ },
194
+ "appr97": {
195
+ "approval_rate": 0.97,
196
+ "decline_rate": 0.03035,
197
+ "recall": 0.669849931787176,
198
+ "false_decline_rate": 0.006020657082057404,
199
+ "precision": 0.8088962108731467
200
+ },
201
+ "appr95": {
202
+ "approval_rate": 0.95,
203
+ "decline_rate": 0.05005,
204
+ "recall": 0.7639836289222374,
205
+ "false_decline_rate": 0.02288887735506306,
206
+ "precision": 0.5594405594405595
207
+ }
208
+ }
209
+ }
210
+ }
211
+ }
encoder/src/demo/app.py CHANGED
@@ -36,6 +36,7 @@ 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,
@@ -380,6 +381,11 @@ def _build_integration_tab_contents() -> None:
380
  gr.HTML(render_encoder_integration())
381
 
382
 
 
 
 
 
 
383
  def create_app(
384
  model: EncoderDemoModel,
385
  data: DemoData,
@@ -396,6 +402,8 @@ def create_app(
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"):
 
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.cold_start import render_cold_start
40
  from encoder.src.demo.inference import EncoderDemoModel
41
  from encoder.src.demo.render import (
42
  format_amount_predictions,
 
381
  gr.HTML(render_encoder_integration())
382
 
383
 
384
+ def _build_cold_start_tab_contents() -> None:
385
+ """Render the Cold Start tab content (HTML self-supervised-pretraining result)."""
386
+ gr.HTML(render_cold_start())
387
+
388
+
389
  def create_app(
390
  model: EncoderDemoModel,
391
  data: DemoData,
 
402
  with gr.Tabs():
403
  with gr.Tab("Demo"):
404
  _build_demo_tab_contents(model, data, decoder, merchant_catalog, app)
405
+ with gr.Tab("Cold Start"):
406
+ _build_cold_start_tab_contents()
407
  with gr.Tab("Why Liquid"):
408
  _build_why_liquid_tab_contents()
409
  with gr.Tab("Integration"):
encoder/src/demo/cold_start.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cold-start (self-supervised pretraining) result tab for the encoder demo.
2
+
3
+ A third piece of demo-specific content alongside `render_why_encoder` and
4
+ `render_encoder_integration` in render.py. It presents one result: on a newly
5
+ onboarded merchant with almost no fraud labels, adding a brief self-supervised
6
+ pretraining stage ("SSL") catches far more fraud at the same approval rate
7
+ while declining fewer good customers. The lift concentrates where label history
8
+ is scarce (new merchants, new markets) and shrinks as history accumulates.
9
+
10
+ Design choices, and why:
11
+ - Numbers are read at render time from the recall-at-approval JSON produced by
12
+ `encoder/scripts/recall_at_approval.py`. That artifact is the single source
13
+ of truth; nothing here is hardcoded, so the tab can never drift from the
14
+ measured numbers.
15
+ - The figure is base64-embedded as a data URI so the tab is self-contained
16
+ HTML, matching the other content tabs (no Gradio static-file-serving config).
17
+ - Design tokens are imported from render.py so there is one design system.
18
+
19
+ Scope of claim: every number is synthetic-data, held-out test split. The tab
20
+ labels it a demonstration throughout. It is never a production or customer
21
+ guarantee; the pilot produces the number on the customer's portfolio.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import json
28
+ import logging
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ # Single source of truth for the design system lives in render.py. Importing the
33
+ # tokens (rather than redefining them) keeps this tab visually identical to the
34
+ # Why Liquid / Integration tabs.
35
+ from encoder.src.demo.render import (
36
+ _BG_CARD,
37
+ _BG_CARD_ALT,
38
+ _BORDER,
39
+ _BORDER_SUBTLE,
40
+ _CONTAINER_WIDTH,
41
+ _FONT_MONO,
42
+ _RADIUS_CARD,
43
+ _TEXT,
44
+ _TEXT_DIM,
45
+ _TEXT_MUTED,
46
+ )
47
+
48
+ log = logging.getLogger(__name__)
49
+
50
+ # Repo-root-relative defaults (robust to the process cwd). This file lives at
51
+ # encoder/src/demo/, so parents[3] is the repository root.
52
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
53
+ # Neutral figure (no customer name in caption) for the generic Gradio demo.
54
+ # The dLocal-specific figure (docs/figures/dlocal_cold_start.png) is used in
55
+ # the dLocal deck only; pass figure_path explicitly to switch.
56
+ _FIG_DEFAULT = _REPO_ROOT / "docs" / "figures" / "cold_start_demo.png"
57
+ _JSON_DEFAULT = _REPO_ROOT / "docs" / "figures" / "recall_at_approval.json"
58
+
59
+ # Teal = "with SSL" in the figure; gray = "without". Reuse the same mapping for
60
+ # the table so the tab's color language matches the embedded chart.
61
+ _SSL = "#0F766E" # teal-700, with SSL
62
+ _SSL_BG = "rgba(15,118,110,0.06)"
63
+ _NO = "#6B7280" # gray-500, without SSL
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Small formatters and JSON accessors
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _img_data_uri(path: Path) -> str:
72
+ """Base64-encode a PNG as an inline data URI. Returns '' if the file is absent.
73
+
74
+ The figure is cosmetic; a missing file degrades to the table-only view with
75
+ a logged warning rather than crashing the app.
76
+ """
77
+ try:
78
+ b64 = base64.b64encode(path.read_bytes()).decode("ascii")
79
+ except OSError as exc:
80
+ log.warning("cold-start figure not found at %s (%s); rendering without it", path, exc)
81
+ return ""
82
+ return f"data:image/png;base64,{b64}"
83
+
84
+
85
+ def _op(runs: Any, tag: str, appr: int, field: str) -> float: # noqa: ANN401 (json is Any)
86
+ """One operating-point field (recall / false_decline_rate / precision) from the JSON."""
87
+ return float(runs[tag]["operating_points"][f"appr{appr}"][field])
88
+
89
+
90
+ def _pct(x: float) -> str:
91
+ """Recall / percentage with one decimal, e.g. 0.3601 -> '36.0%'."""
92
+ return f"{x * 100:.1f}%"
93
+
94
+
95
+ def _fdr(x: float) -> str:
96
+ """False-decline rate; small values keep two decimals so they don't read as zero."""
97
+ p = x * 100
98
+ if p >= 1.0:
99
+ return f"{p:.1f}%"
100
+ if p > 0.0:
101
+ return f"{p:.2f}%"
102
+ return "0.0%"
103
+
104
+
105
+ def _lift(ssl: float, base: float) -> str:
106
+ """Multiplicative lift as 'N.N×'.
107
+
108
+ Computed from the displayed (1-decimal-percent) recall values, not the raw
109
+ fractions, so the lift column equals SSL% / no-SSL% when a reader divides the
110
+ two columns by eye (and matches the talk track). Guarded against divide-by-zero.
111
+ """
112
+ s = round(ssl * 100, 1)
113
+ b = round(base * 100, 1)
114
+ return f"{s / max(b, 1e-9):.1f}×"
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # HTML fragments
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def _stat_card(big: str, big_color: str, label: str, detail: str) -> str:
123
+ """One headline stat: a large colored number, a label, and a supporting line."""
124
+ return f"""
125
+ <div style="padding: 14px 16px; background: {_BG_CARD_ALT}; border: 1px solid {_BORDER};
126
+ border-radius: {_RADIUS_CARD};">
127
+ <div style="font-size: 28px; font-weight: 700; color: {big_color};
128
+ letter-spacing: -0.02em; line-height: 1;">{big}</div>
129
+ <div style="font-size: 12px; font-weight: 600; color: {_TEXT}; margin: 8px 0 4px 0;">
130
+ {label}</div>
131
+ <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.45;">{detail}</div>
132
+ </div>"""
133
+
134
+
135
+ def _table_header(cols: list[str]) -> str:
136
+ ths = ""
137
+ for i, c in enumerate(cols):
138
+ align = "left" if i == 0 else "right"
139
+ ths += (
140
+ f'<th style="padding: 6px 10px; font-size: 10px; color: {_TEXT_DIM};'
141
+ f' text-transform: uppercase; letter-spacing: 0.05em; text-align: {align};'
142
+ f' font-weight: 600;">{c}</th>'
143
+ )
144
+ return f"<tr style='border-bottom: 1px solid {_BORDER};'>{ths}</tr>"
145
+
146
+
147
+ def _table_row(cells: list[str], highlight: bool = False) -> str:
148
+ bg = f"background: {_SSL_BG};" if highlight else ""
149
+ tds = ""
150
+ for i, c in enumerate(cells):
151
+ align = "left" if i == 0 else "right"
152
+ weight = "600" if highlight and i == 0 else "400"
153
+ tds += (
154
+ f'<td style="padding: 6px 10px; font-family: {_FONT_MONO}; font-size: 11px;'
155
+ f' color: {_TEXT}; text-align: {align}; font-weight: {weight};">{c}</td>'
156
+ )
157
+ return f"<tr style='border-bottom: 1px solid {_BORDER_SUBTLE}; {bg}'>{tds}</tr>"
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Main render
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def render_cold_start(
166
+ json_path: Path | None = None,
167
+ figure_path: Path | None = None,
168
+ ) -> str:
169
+ """Render the Cold Start (SSL result) tab content as an HTML string.
170
+
171
+ Reads every number from the recall-at-approval JSON (single source of truth)
172
+ and base64-embeds the result figure so the tab is self-contained.
173
+
174
+ Args:
175
+ json_path: recall_at_approval.json. Defaults to docs/figures/ in the repo.
176
+ figure_path: dlocal_cold_start.png. Defaults to docs/figures/ in the repo.
177
+
178
+ Returns:
179
+ HTML string suitable for a ``gr.HTML`` block.
180
+ """
181
+ json_path = json_path or _JSON_DEFAULT
182
+ figure_path = figure_path or _FIG_DEFAULT
183
+
184
+ raw: Any = json.loads(json_path.read_text())
185
+ runs: Any = raw["runs"]
186
+ n_test = int(raw["n_test"])
187
+ base_rate = float(raw["base_rate"])
188
+
189
+ # Headline (cold start = 1% labels, 95% approval).
190
+ cs_ssl = _op(runs, "ssl_1pct", 95, "recall")
191
+ cs_no = _op(runs, "nossl_1pct", 95, "recall")
192
+ cs_fdr_ssl = _op(runs, "ssl_1pct", 95, "false_decline_rate")
193
+ cs_fdr_no = _op(runs, "nossl_1pct", 95, "false_decline_rate")
194
+ # Whole-book bonus at full labels, tight 1% decline budget.
195
+ wb_ssl = _op(runs, "ssl_100pct", 99, "recall")
196
+ wb_no = _op(runs, "nossl_100pct", 99, "recall")
197
+ wb_delta_pts = (wb_ssl - wb_no) * 100
198
+
199
+ stats = "".join([
200
+ _stat_card(
201
+ _lift(cs_ssl, cs_no), _SSL,
202
+ "more fraud caught on a new merchant",
203
+ f"At a 95% approval rate with only 1% of labels: {_pct(cs_ssl)} of fraud "
204
+ f"caught vs {_pct(cs_no)} without pretraining.",
205
+ ),
206
+ _stat_card(
207
+ f"&minus;{(cs_fdr_no - cs_fdr_ssl) * 100:.1f} pts", _SSL,
208
+ "fewer good customers declined",
209
+ f"False-decline rate {_fdr(cs_fdr_ssl)} vs {_fdr(cs_fdr_no)} at the same "
210
+ f"approval rate. More fraud caught and fewer false declines, together.",
211
+ ),
212
+ _stat_card(
213
+ f"+{wb_delta_pts:.0f} pts", _TEXT,
214
+ "whole-book bonus at full labels",
215
+ f"Even with full label history, {_pct(wb_ssl)} vs {_pct(wb_no)} fraud caught "
216
+ "at a tight 1% decline budget. Not only a new-entity trick.",
217
+ ),
218
+ ])
219
+
220
+ # Business-units table. (label, frac_tag, approval, highlight-as-cold-start)
221
+ specs: list[tuple[str, str, int, bool]] = [
222
+ ("1% &mdash; new entity", "1pct", 99, True),
223
+ ("1% &mdash; new entity", "1pct", 95, True),
224
+ ("10%", "10pct", 99, False),
225
+ ("100% &mdash; full", "100pct", 99, False),
226
+ ("100% &mdash; full", "100pct", 95, False),
227
+ ]
228
+ rows = ""
229
+ for label, frac, appr, hl in specs:
230
+ ssl_r = _op(runs, f"ssl_{frac}", appr, "recall")
231
+ no_r = _op(runs, f"nossl_{frac}", appr, "recall")
232
+ ssl_f = _op(runs, f"ssl_{frac}", appr, "false_decline_rate")
233
+ no_f = _op(runs, f"nossl_{frac}", appr, "false_decline_rate")
234
+ rows += _table_row(
235
+ [label, f"{appr}%", _pct(ssl_r), _pct(no_r), _lift(ssl_r, no_r),
236
+ _fdr(ssl_f), _fdr(no_f)],
237
+ highlight=hl,
238
+ )
239
+
240
+ # Ranking quality, for the evaluator who wants AUC alongside operating points.
241
+ roc_1 = (float(runs["nossl_1pct"]["roc_auc"]), float(runs["ssl_1pct"]["roc_auc"]))
242
+ roc_100 = (float(runs["nossl_100pct"]["roc_auc"]), float(runs["ssl_100pct"]["roc_auc"]))
243
+ pr_1 = (float(runs["nossl_1pct"]["pr_auc"]), float(runs["ssl_1pct"]["pr_auc"]))
244
+ pr_100 = (float(runs["nossl_100pct"]["pr_auc"]), float(runs["ssl_100pct"]["pr_auc"]))
245
+
246
+ fig_uri = _img_data_uri(figure_path)
247
+ if fig_uri:
248
+ figure_block = f"""
249
+ <div style="padding: 12px; background: {_BG_CARD}; border: 1px solid {_BORDER};
250
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px; text-align: center;">
251
+ <img src="{fig_uri}" alt="Cold-start fraud-catch lift from self-supervised pretraining"
252
+ style="max-width: 100%; height: auto; border-radius: 8px;" />
253
+ </div>"""
254
+ else:
255
+ figure_block = ""
256
+
257
+ return f"""
258
+ <div style="max-width: {_CONTAINER_WIDTH}; margin: 0 auto; padding: 16px;
259
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;">
260
+
261
+ <!-- Lead -->
262
+ <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700;
263
+ letter-spacing: -0.02em;">
264
+ Cold Start: the Fraud-Catch Lift Where You Have No History Yet
265
+ </h2>
266
+ <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 20px 0; line-height: 1.55;">
267
+ A newly onboarded merchant or market has almost no fraud labels, so a model trained
268
+ only on labels has nothing to stand on. Adding one brief self-supervised pretraining
269
+ stage &mdash; before any labels &mdash; anchors the model on transaction <i>behavior</i>
270
+ learned across the rest of the book. The result: it catches far more fraud the day you
271
+ enter a new market, at the same approval rate, while turning away fewer good customers.
272
+ </p>
273
+
274
+ <!-- Headline stats -->
275
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 16px;">
276
+ {stats}
277
+ </div>
278
+
279
+ <!-- Figure -->
280
+ {figure_block}
281
+
282
+ <!-- Operating-point table -->
283
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
284
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
285
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
286
+ Fraud Caught at a Fixed Approval Rate
287
+ </h3>
288
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.55; margin: 0 0 10px 0;">
289
+ What a fraud team actually runs: hold the approval rate fixed (decline the riskiest
290
+ tail), then measure how much fraud you catch and how many good customers you decline.
291
+ Label history stands in for entity age &mdash; <b>1% = a new entity</b>, 100% = a
292
+ mature one. The lift is largest where history is scarce and shrinks as it accumulates.
293
+ </p>
294
+ <table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
295
+ {_table_header(["Label history", "Approval", "Fraud caught (SSL)",
296
+ "Fraud caught (no SSL)", "Lift", "False decl. (SSL)", "(no SSL)"])}
297
+ {rows}
298
+ </table>
299
+ <p style="font-size: 11px; color: {_TEXT_DIM}; margin: 0; line-height: 1.5;">
300
+ Ranking quality (test split): ROC-AUC {roc_1[0]:.3f} &rarr; <b>{roc_1[1]:.3f}</b> at 1%
301
+ labels, {roc_100[0]:.3f} &rarr; <b>{roc_100[1]:.3f}</b> at full labels.
302
+ PR-AUC {pr_1[0]:.3f} &rarr; <b>{pr_1[1]:.3f}</b> and
303
+ {pr_100[0]:.3f} &rarr; <b>{pr_100[1]:.3f}</b> &mdash; PR-AUC is the honest metric at a
304
+ {base_rate:.1%} fraud base rate.
305
+ </p>
306
+ </div>
307
+
308
+ <!-- How it works -->
309
+ <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER};
310
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
311
+ <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;">
312
+ How It Works: One Added Pretraining Stage
313
+ </h3>
314
+ <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0;">
315
+ The encoder originally trained in a single supervised pass with randomly-initialized
316
+ per-feature embeddings. We add a stage-1 self-supervised step &mdash; next-feature
317
+ prediction over the transaction corpus, run through the frozen LFM2.5 base &mdash; that
318
+ anchors the encoder&rsquo;s value tables <i>before</i> the supervised fine-tune. It is
319
+ the same connector-pretraining stage LFM2.5-Audio and LFM2.5-VL already use, which the
320
+ transaction encoder had skipped. The base stays frozen and the shared-base,
321
+ many-surfaces story is unchanged; this adds an unsupervised warm-up, not a new model.
322
+ </p>
323
+ </div>
324
+
325
+ <!-- Scope / caveat -->
326
+ <div style="padding: 14px 18px; background: {_BG_CARD_ALT}; border: 1px solid {_BORDER};
327
+ border-radius: {_RADIUS_CARD}; margin-bottom: 16px;">
328
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM};
329
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
330
+ margin-bottom: 6px;">Scope of claim</div>
331
+ <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0;">
332
+ Synthetic data, held-out test split (n={n_test:,}, fraud base rate {base_rate:.1%}).
333
+ Label-scarcity (1% &asymp; 1,700 labeled sequences) stands in for a newly onboarded
334
+ entity before label history accumulates. <b>The absolute numbers don&rsquo;t
335
+ transfer; the shape and the mechanism do.</b> The pilot produces the real number on
336
+ the customer&rsquo;s portfolio &mdash; hold out a recently onboarded market, score fraud
337
+ on it cold against the current baseline, at the customer&rsquo;s approval rate.
338
+ </p>
339
+ </div>
340
+
341
+ <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;">
342
+ Same architecture, same frozen base, same fine-tune &mdash; trained with vs without the
343
+ self-supervised stage. One variable changed.
344
+ </div>
345
+ </div>
346
+ """
encoder/src/demo/copilot_app_unified.py CHANGED
@@ -42,6 +42,7 @@ 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,
@@ -94,6 +95,8 @@ def build_unified_ui(
94
  multihead_merchant_catalog,
95
  app,
96
  )
 
 
97
  with gr.Tab("Why Liquid"):
98
  _build_why_liquid_tab_contents()
99
  with gr.Tab("Integration"):
@@ -116,12 +119,12 @@ def _load_multihead(
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,
@@ -148,7 +151,7 @@ def _load_copilots(
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,
@@ -157,7 +160,7 @@ def _load_copilots(
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,
@@ -166,7 +169,7 @@ def _load_copilots(
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,
 
42
  from src.demo.merchant_catalog import DemoMerchantCatalog
43
 
44
  from encoder.src.demo.app import (
45
+ _build_cold_start_tab_contents,
46
  _build_demo_tab_contents,
47
  _build_integration_tab_contents,
48
  _build_why_liquid_tab_contents,
 
95
  multihead_merchant_catalog,
96
  app,
97
  )
98
+ with gr.Tab("Cold Start"):
99
+ _build_cold_start_tab_contents()
100
  with gr.Tab("Why Liquid"):
101
  _build_why_liquid_tab_contents()
102
  with gr.Tab("Integration"):
 
119
  device: str,
120
  ) -> tuple[EncoderDemoModel, DemoData, TransactionDecoder, DemoMerchantCatalog]:
121
  """Load the original multi-head V3 model + curated test data."""
122
+ print("[multihead] schema + data ...")
123
  schema_cfg = load_schema(schema)
124
  data = DemoData(data_dir, schema_cfg)
125
  merchant_catalog = DemoMerchantCatalog(schema_cfg)
126
  decoder = TransactionDecoder(schema_cfg, merchant_catalog)
127
+ print("[multihead] loading EncoderDemoModel ...")
128
  model = EncoderDemoModel(
129
  model_config_path=config,
130
  schema_path=schema,
 
151
  device: torch.device,
152
  ) -> tuple[CopilotModel, CollectionsCopilotModel, FraudPatternCopilotModel]:
153
  """Load the three Co-Pilot surfaces. Each has its own backbone copy."""
154
+ print("[copilot 1/3] loading Dispute ...")
155
  dispute_model = CopilotModel.from_paths(
156
  checkpoint_path=dispute_checkpoint,
157
  model_config_path=dispute_config,
 
160
  cast_path=dispute_cast,
161
  device=device,
162
  )
163
+ print("[copilot 2/3] loading Collections ...")
164
  collections_model = CollectionsCopilotModel.from_paths(
165
  checkpoint_path=collections_checkpoint,
166
  model_config_path=collections_config,
 
169
  cast_path=collections_cast,
170
  device=device,
171
  )
172
+ print("[copilot 3/3] loading Fraud ...")
173
  fraud_model = FraudPatternCopilotModel.from_paths(
174
  checkpoint_path=fraud_checkpoint,
175
  model_config_path=fraud_config,