| """Cold-start (self-supervised pretraining) result tab for the encoder demo. |
| |
| A third piece of demo-specific content alongside `render_why_encoder` and |
| `render_encoder_integration` in render.py. It presents one result: on a newly |
| onboarded merchant with almost no fraud labels, adding a brief self-supervised |
| pretraining stage ("SSL") catches far more fraud at the same approval rate |
| while declining fewer good customers. The lift concentrates where label history |
| is scarce (new merchants, new markets) and shrinks as history accumulates. |
| |
| Design choices, and why: |
| - Numbers are read at render time from the recall-at-approval JSON produced by |
| `encoder/scripts/recall_at_approval.py`. That artifact is the single source |
| of truth; nothing here is hardcoded, so the tab can never drift from the |
| measured numbers. |
| - The figure is base64-embedded as a data URI so the tab is self-contained |
| HTML, matching the other content tabs (no Gradio static-file-serving config). |
| - Design tokens are imported from render.py so there is one design system. |
| |
| Scope of claim: every number is synthetic-data, held-out test split. The tab |
| labels it a demonstration throughout. It is never a production or customer |
| guarantee; the pilot produces the number on the customer's portfolio. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| import json |
| import logging |
| from pathlib import Path |
| from typing import Any |
|
|
| |
| |
| |
| from encoder.src.demo.render import ( |
| _BG_CARD, |
| _BG_CARD_ALT, |
| _BORDER, |
| _BORDER_SUBTLE, |
| _CONTAINER_WIDTH, |
| _FONT_MONO, |
| _RADIUS_CARD, |
| _TEXT, |
| _TEXT_DIM, |
| _TEXT_MUTED, |
| ) |
|
|
| log = logging.getLogger(__name__) |
|
|
| |
| |
| _REPO_ROOT = Path(__file__).resolve().parents[3] |
| |
| |
| |
| _FIG_DEFAULT = _REPO_ROOT / "docs" / "figures" / "cold_start_demo.png" |
| _JSON_DEFAULT = _REPO_ROOT / "docs" / "figures" / "recall_at_approval.json" |
|
|
| |
| |
| _SSL = "#0F766E" |
| _SSL_BG = "rgba(15,118,110,0.06)" |
| _NO = "#6B7280" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _img_data_uri(path: Path) -> str: |
| """Base64-encode a PNG as an inline data URI. Returns '' if the file is absent. |
| |
| The figure is cosmetic; a missing file degrades to the table-only view with |
| a logged warning rather than crashing the app. |
| """ |
| try: |
| b64 = base64.b64encode(path.read_bytes()).decode("ascii") |
| except OSError as exc: |
| log.warning("cold-start figure not found at %s (%s); rendering without it", path, exc) |
| return "" |
| return f"data:image/png;base64,{b64}" |
|
|
|
|
| def _op(runs: Any, tag: str, appr: int, field: str) -> float: |
| """One operating-point field (recall / false_decline_rate / precision) from the JSON.""" |
| return float(runs[tag]["operating_points"][f"appr{appr}"][field]) |
|
|
|
|
| def _pct(x: float) -> str: |
| """Recall / percentage with one decimal, e.g. 0.3601 -> '36.0%'.""" |
| return f"{x * 100:.1f}%" |
|
|
|
|
| def _fdr(x: float) -> str: |
| """False-decline rate; small values keep two decimals so they don't read as zero.""" |
| p = x * 100 |
| if p >= 1.0: |
| return f"{p:.1f}%" |
| if p > 0.0: |
| return f"{p:.2f}%" |
| return "0.0%" |
|
|
|
|
| def _lift(ssl: float, base: float) -> str: |
| """Multiplicative lift as 'N.N×'. |
| |
| Computed from the displayed (1-decimal-percent) recall values, not the raw |
| fractions, so the lift column equals SSL% / no-SSL% when a reader divides the |
| two columns by eye (and matches the talk track). Guarded against divide-by-zero. |
| """ |
| s = round(ssl * 100, 1) |
| b = round(base * 100, 1) |
| return f"{s / max(b, 1e-9):.1f}×" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _stat_card(big: str, big_color: str, label: str, detail: str) -> str: |
| """One headline stat: a large colored number, a label, and a supporting line.""" |
| return f""" |
| <div style="padding: 14px 16px; background: {_BG_CARD_ALT}; border: 1px solid {_BORDER}; |
| border-radius: {_RADIUS_CARD};"> |
| <div style="font-size: 28px; font-weight: 700; color: {big_color}; |
| letter-spacing: -0.02em; line-height: 1;">{big}</div> |
| <div style="font-size: 12px; font-weight: 600; color: {_TEXT}; margin: 8px 0 4px 0;"> |
| {label}</div> |
| <div style="font-size: 11px; color: {_TEXT_DIM}; line-height: 1.45;">{detail}</div> |
| </div>""" |
|
|
|
|
| def _table_header(cols: list[str]) -> str: |
| ths = "" |
| for i, c in enumerate(cols): |
| align = "left" if i == 0 else "right" |
| ths += ( |
| f'<th style="padding: 6px 10px; font-size: 10px; color: {_TEXT_DIM};' |
| f' text-transform: uppercase; letter-spacing: 0.05em; text-align: {align};' |
| f' font-weight: 600;">{c}</th>' |
| ) |
| return f"<tr style='border-bottom: 1px solid {_BORDER};'>{ths}</tr>" |
|
|
|
|
| def _table_row(cells: list[str], highlight: bool = False) -> str: |
| bg = f"background: {_SSL_BG};" if highlight else "" |
| tds = "" |
| for i, c in enumerate(cells): |
| align = "left" if i == 0 else "right" |
| weight = "600" if highlight and i == 0 else "400" |
| tds += ( |
| f'<td style="padding: 6px 10px; font-family: {_FONT_MONO}; font-size: 11px;' |
| f' color: {_TEXT}; text-align: {align}; font-weight: {weight};">{c}</td>' |
| ) |
| return f"<tr style='border-bottom: 1px solid {_BORDER_SUBTLE}; {bg}'>{tds}</tr>" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def render_cold_start( |
| json_path: Path | None = None, |
| figure_path: Path | None = None, |
| ) -> str: |
| """Render the Cold Start (SSL result) tab content as an HTML string. |
| |
| Reads every number from the recall-at-approval JSON (single source of truth) |
| and base64-embeds the result figure so the tab is self-contained. |
| |
| Args: |
| json_path: recall_at_approval.json. Defaults to docs/figures/ in the repo. |
| figure_path: dlocal_cold_start.png. Defaults to docs/figures/ in the repo. |
| |
| Returns: |
| HTML string suitable for a ``gr.HTML`` block. |
| """ |
| json_path = json_path or _JSON_DEFAULT |
| figure_path = figure_path or _FIG_DEFAULT |
|
|
| raw: Any = json.loads(json_path.read_text()) |
| runs: Any = raw["runs"] |
| n_test = int(raw["n_test"]) |
| base_rate = float(raw["base_rate"]) |
|
|
| |
| cs_ssl = _op(runs, "ssl_1pct", 95, "recall") |
| cs_no = _op(runs, "nossl_1pct", 95, "recall") |
| cs_fdr_ssl = _op(runs, "ssl_1pct", 95, "false_decline_rate") |
| cs_fdr_no = _op(runs, "nossl_1pct", 95, "false_decline_rate") |
| |
| wb_ssl = _op(runs, "ssl_100pct", 99, "recall") |
| wb_no = _op(runs, "nossl_100pct", 99, "recall") |
| wb_delta_pts = (wb_ssl - wb_no) * 100 |
|
|
| stats = "".join([ |
| _stat_card( |
| _lift(cs_ssl, cs_no), _SSL, |
| "more fraud caught on a new merchant", |
| f"At a 95% approval rate with only 1% of labels: {_pct(cs_ssl)} of fraud " |
| f"caught vs {_pct(cs_no)} without pretraining.", |
| ), |
| _stat_card( |
| f"−{(cs_fdr_no - cs_fdr_ssl) * 100:.1f} pts", _SSL, |
| "fewer good customers declined", |
| f"False-decline rate {_fdr(cs_fdr_ssl)} vs {_fdr(cs_fdr_no)} at the same " |
| f"approval rate. More fraud caught and fewer false declines, together.", |
| ), |
| _stat_card( |
| f"+{wb_delta_pts:.0f} pts", _TEXT, |
| "whole-book bonus at full labels", |
| f"Even with full label history, {_pct(wb_ssl)} vs {_pct(wb_no)} fraud caught " |
| "at a tight 1% decline budget. Not only a new-entity trick.", |
| ), |
| ]) |
|
|
| |
| specs: list[tuple[str, str, int, bool]] = [ |
| ("1% — new entity", "1pct", 99, True), |
| ("1% — new entity", "1pct", 95, True), |
| ("10%", "10pct", 99, False), |
| ("100% — full", "100pct", 99, False), |
| ("100% — full", "100pct", 95, False), |
| ] |
| rows = "" |
| for label, frac, appr, hl in specs: |
| ssl_r = _op(runs, f"ssl_{frac}", appr, "recall") |
| no_r = _op(runs, f"nossl_{frac}", appr, "recall") |
| ssl_f = _op(runs, f"ssl_{frac}", appr, "false_decline_rate") |
| no_f = _op(runs, f"nossl_{frac}", appr, "false_decline_rate") |
| rows += _table_row( |
| [label, f"{appr}%", _pct(ssl_r), _pct(no_r), _lift(ssl_r, no_r), |
| _fdr(ssl_f), _fdr(no_f)], |
| highlight=hl, |
| ) |
|
|
| |
| roc_1 = (float(runs["nossl_1pct"]["roc_auc"]), float(runs["ssl_1pct"]["roc_auc"])) |
| roc_100 = (float(runs["nossl_100pct"]["roc_auc"]), float(runs["ssl_100pct"]["roc_auc"])) |
| pr_1 = (float(runs["nossl_1pct"]["pr_auc"]), float(runs["ssl_1pct"]["pr_auc"])) |
| pr_100 = (float(runs["nossl_100pct"]["pr_auc"]), float(runs["ssl_100pct"]["pr_auc"])) |
|
|
| fig_uri = _img_data_uri(figure_path) |
| if fig_uri: |
| figure_block = f""" |
| <div style="padding: 12px; background: {_BG_CARD}; border: 1px solid {_BORDER}; |
| border-radius: {_RADIUS_CARD}; margin-bottom: 16px; text-align: center;"> |
| <img src="{fig_uri}" alt="Cold-start fraud-catch lift from self-supervised pretraining" |
| style="max-width: 100%; height: auto; border-radius: 8px;" /> |
| </div>""" |
| else: |
| figure_block = "" |
|
|
| return f""" |
| <div style="max-width: {_CONTAINER_WIDTH}; margin: 0 auto; padding: 16px; |
| font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;"> |
| |
| <!-- Lead --> |
| <h2 style="margin: 0 0 4px 0; color: {_TEXT}; font-size: 22px; font-weight: 700; |
| letter-spacing: -0.02em;"> |
| Cold Start: the Fraud-Catch Lift Where You Have No History Yet |
| </h2> |
| <p style="color: {_TEXT_DIM}; font-size: 13px; margin: 0 0 20px 0; line-height: 1.55;"> |
| A newly onboarded merchant or market has almost no fraud labels, so a model trained |
| only on labels has nothing to stand on. Adding one brief self-supervised pretraining |
| stage — before any labels — anchors the model on transaction <i>behavior</i> |
| learned across the rest of the book. The result: it catches far more fraud the day you |
| enter a new market, at the same approval rate, while turning away fewer good customers. |
| </p> |
| |
| <!-- Headline stats --> |
| <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 16px;"> |
| {stats} |
| </div> |
| |
| <!-- Figure --> |
| {figure_block} |
| |
| <!-- Operating-point table --> |
| <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER}; |
| border-radius: {_RADIUS_CARD}; margin-bottom: 16px;"> |
| <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;"> |
| Fraud Caught at a Fixed Approval Rate |
| </h3> |
| <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.55; margin: 0 0 10px 0;"> |
| What a fraud team actually runs: hold the approval rate fixed (decline the riskiest |
| tail), then measure how much fraud you catch and how many good customers you decline. |
| Label history stands in for entity age — <b>1% = a new entity</b>, 100% = a |
| mature one. The lift is largest where history is scarce and shrinks as it accumulates. |
| </p> |
| <table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;"> |
| {_table_header(["Label history", "Approval", "Fraud caught (SSL)", |
| "Fraud caught (no SSL)", "Lift", "False decl. (SSL)", "(no SSL)"])} |
| {rows} |
| </table> |
| <p style="font-size: 11px; color: {_TEXT_DIM}; margin: 0; line-height: 1.5;"> |
| Ranking quality (test split): ROC-AUC {roc_1[0]:.3f} → <b>{roc_1[1]:.3f}</b> at 1% |
| labels, {roc_100[0]:.3f} → <b>{roc_100[1]:.3f}</b> at full labels. |
| PR-AUC {pr_1[0]:.3f} → <b>{pr_1[1]:.3f}</b> and |
| {pr_100[0]:.3f} → <b>{pr_100[1]:.3f}</b> — PR-AUC is the honest metric at a |
| {base_rate:.1%} fraud base rate. |
| </p> |
| </div> |
| |
| <!-- How it works --> |
| <div style="padding: 16px 20px; background: {_BG_CARD}; border: 1px solid {_BORDER}; |
| border-radius: {_RADIUS_CARD}; margin-bottom: 16px;"> |
| <h3 style="color: {_TEXT}; margin: 0 0 4px 0; font-size: 15px; font-weight: 600;"> |
| How It Works: One Added Pretraining Stage |
| </h3> |
| <p style="color: {_TEXT_MUTED}; font-size: 13px; line-height: 1.6; margin: 0;"> |
| The encoder originally trained in a single supervised pass with randomly-initialized |
| per-feature embeddings. We add a stage-1 self-supervised step — next-feature |
| prediction over the transaction corpus, run through the frozen LFM2.5 base — that |
| anchors the encoder’s value tables <i>before</i> the supervised fine-tune. It is |
| the same connector-pretraining stage LFM2.5-Audio and LFM2.5-VL already use, which the |
| transaction encoder had skipped. The base stays frozen and the shared-base, |
| many-surfaces story is unchanged; this adds an unsupervised warm-up, not a new model. |
| </p> |
| </div> |
| |
| <!-- Scope / caveat --> |
| <div style="padding: 14px 18px; background: {_BG_CARD_ALT}; border: 1px solid {_BORDER}; |
| border-radius: {_RADIUS_CARD}; margin-bottom: 16px;"> |
| <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; |
| font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; |
| margin-bottom: 6px;">Scope of claim</div> |
| <p style="color: {_TEXT_MUTED}; font-size: 12px; line-height: 1.6; margin: 0;"> |
| Synthetic data, held-out test split (n={n_test:,}, fraud base rate {base_rate:.1%}). |
| Label-scarcity (1% ≈ 1,700 labeled sequences) stands in for a newly onboarded |
| entity before label history accumulates. <b>The absolute numbers don’t |
| transfer; the shape and the mechanism do.</b> The pilot produces the real number on |
| the customer’s portfolio — hold out a recently onboarded market, score fraud |
| on it cold against the current baseline, at the customer’s approval rate. |
| </p> |
| </div> |
| |
| <div style="font-family: {_FONT_MONO}; font-size: 10px; color: {_TEXT_DIM}; text-align: center;"> |
| Same architecture, same frozen base, same fine-tune — trained with vs without the |
| self-supervised stage. One variable changed. |
| </div> |
| </div> |
| """ |
|
|