"""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 # Single source of truth for the design system lives in render.py. Importing the # tokens (rather than redefining them) keeps this tab visually identical to the # Why Liquid / Integration tabs. 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-relative defaults (robust to the process cwd). This file lives at # encoder/src/demo/, so parents[3] is the repository root. _REPO_ROOT = Path(__file__).resolve().parents[3] # Neutral figure (no customer name in caption) for the generic Gradio demo. # The dLocal-specific figure (docs/figures/dlocal_cold_start.png) is used in # the dLocal deck only; pass figure_path explicitly to switch. _FIG_DEFAULT = _REPO_ROOT / "docs" / "figures" / "cold_start_demo.png" _JSON_DEFAULT = _REPO_ROOT / "docs" / "figures" / "recall_at_approval.json" # Teal = "with SSL" in the figure; gray = "without". Reuse the same mapping for # the table so the tab's color language matches the embedded chart. _SSL = "#0F766E" # teal-700, with SSL _SSL_BG = "rgba(15,118,110,0.06)" _NO = "#6B7280" # gray-500, without SSL # --------------------------------------------------------------------------- # Small formatters and JSON accessors # --------------------------------------------------------------------------- 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: # noqa: ANN401 (json is Any) """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}×" # --------------------------------------------------------------------------- # HTML fragments # --------------------------------------------------------------------------- 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"""
{big}
{label}
{detail}
""" def _table_header(cols: list[str]) -> str: ths = "" for i, c in enumerate(cols): align = "left" if i == 0 else "right" ths += ( f'{c}' ) return f"{ths}" 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'{c}' ) return f"{tds}" # --------------------------------------------------------------------------- # Main render # --------------------------------------------------------------------------- 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"]) # Headline (cold start = 1% labels, 95% approval). 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") # Whole-book bonus at full labels, tight 1% decline budget. 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.", ), ]) # Business-units table. (label, frac_tag, approval, highlight-as-cold-start) 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, ) # Ranking quality, for the evaluator who wants AUC alongside operating points. 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"""
Cold-start fraud-catch lift from self-supervised pretraining
""" else: figure_block = "" return f"""

Cold Start: the Fraud-Catch Lift Where You Have No History Yet

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 behavior 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.

{stats}
{figure_block}

Fraud Caught at a Fixed Approval Rate

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 — 1% = a new entity, 100% = a mature one. The lift is largest where history is scarce and shrinks as it accumulates.

{_table_header(["Label history", "Approval", "Fraud caught (SSL)", "Fraud caught (no SSL)", "Lift", "False decl. (SSL)", "(no SSL)"])} {rows}

Ranking quality (test split): ROC-AUC {roc_1[0]:.3f} → {roc_1[1]:.3f} at 1% labels, {roc_100[0]:.3f} → {roc_100[1]:.3f} at full labels. PR-AUC {pr_1[0]:.3f} → {pr_1[1]:.3f} and {pr_100[0]:.3f} → {pr_100[1]:.3f} — PR-AUC is the honest metric at a {base_rate:.1%} fraud base rate.

How It Works: One Added Pretraining Stage

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 before 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.

Scope of claim

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. The absolute numbers don’t transfer; the shape and the mechanism do. 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.

Same architecture, same frozen base, same fine-tune — trained with vs without the self-supervised stage. One variable changed.
"""