"""Interactive inference demo for the LFM2 Transaction Foundation Model. Demonstrates multi-head predictions (fraud, next merchant, amount range, MCC) on synthetic payment sequences. Includes side-by-side pretrained-vs-random-init comparison showing the value of self-supervised pretraining. Usage: python -m src.demo.app --checkpoint PATH [--data-dir PATH] [--port PORT] Runs on CPU. No GPU or internet required. Inference < 100ms per customer. """ from __future__ import annotations import argparse import time from pathlib import Path from typing import Any import gradio as gr import numpy as np import torch import torch.nn.functional as F from src.data.schema import SchemaConfig, load_schema from src.demo.decode import TransactionDecoder from src.demo.merchant_catalog import DemoMerchantCatalog from src.demo.profile_inference import format_profile_html, infer_profile from src.demo.render import ( format_amount_predictions, format_fraud_score, format_mcc_predictions, format_merchant_predictions, format_timeline, render_comparison_header, render_integration_guide, render_production_architecture, render_why_liquid, ) from src.model.lfm2_small import LFM2Small, ModelConfig from src.model.task_heads import ( AnyHead, DownstreamHead, HeadConfig, MultiHeadModel, TiedEmbeddingHead, ) # --------------------------------------------------------------------------- # Data loading # --------------------------------------------------------------------------- class DemoData: """Loads test-set sequences and labels into memory.""" def __init__(self, data_dir: Path, schema: SchemaConfig) -> None: token_ids = np.load(data_dir / "token_ids.npy", mmap_mode="r") seq_labels = np.load(data_dir / "sequence_labels.npy") splits = np.load(data_dir / "split_indices.npz") test_idx = splits["test"] self.token_ids = np.array(token_ids[test_idx]) self.labels = seq_labels[test_idx].astype(int) self.num_customers = len(test_idx) self.fraud_indices = np.where(self.labels == 1)[0] self.legit_indices = np.where(self.labels == 0)[0] self._curated = self._find_curated_examples() def _find_curated_examples(self) -> dict[str, int]: """Pick interesting examples for quick navigation. Five legitimate profiles to show breadth of normal spending patterns, three fraud profiles representing the main attack archetypes. """ examples: dict[str, int] = {} # Legitimate customers — varied indices for behavioral variety if len(self.legit_indices) > 0: examples["Typical Customer"] = int(self.legit_indices[0]) if len(self.legit_indices) > 100: examples["Frequent Shopper"] = int(self.legit_indices[100]) if len(self.legit_indices) > 200: examples["Weekend Spender"] = int(self.legit_indices[200]) if len(self.legit_indices) > 300: examples["High-Spend Loyalist"] = int(self.legit_indices[300]) if len(self.legit_indices) > 500: examples["Low Activity"] = int(self.legit_indices[500]) # Fraud archetypes if len(self.fraud_indices) > 0: examples["Fraud: Card Testing"] = int(self.fraud_indices[0]) if len(self.fraud_indices) > 50: examples["Fraud: Account Takeover"] = int(self.fraud_indices[50]) if len(self.fraud_indices) > 100: examples["Fraud: High Value"] = int(self.fraud_indices[100]) return examples @property def curated_names(self) -> list[str]: return list(self._curated.keys()) def get_curated_index(self, name: str) -> int: return self._curated[name] # --------------------------------------------------------------------------- # Model loading # --------------------------------------------------------------------------- def build_model( model_yaml: Path, schema: SchemaConfig, finetune_yaml: Path, ) -> MultiHeadModel: """Construct MultiHeadModel with 4 downstream heads.""" import yaml backbone = LFM2Small(ModelConfig.from_yaml(model_yaml), schema) with open(finetune_yaml) as f: ft_config = yaml.safe_load(f) heads: dict[str, AnyHead] = {} for name, hcfg in ft_config["heads"].items(): config = HeadConfig( name=name, output_dim=hcfg["output_dim"], loss_type=hcfg["loss"], pool_strategy=hcfg["pool"], target_type=hcfg["target"], weight=hcfg.get("weight", 1.0), mlp_hidden=hcfg.get("mlp_hidden", 128), dropout=hcfg.get("dropout", 0.1), ) if hcfg.get("tied", False): heads[name] = TiedEmbeddingHead( config, backbone.config.hidden_size, schema.num_features, backbone.embedding.value_tables, ) else: heads[name] = DownstreamHead( config, backbone.config.hidden_size, schema.num_features, ) return MultiHeadModel(backbone, heads) def load_model_checkpoint(model: MultiHeadModel, checkpoint_path: Path | None) -> str: """Load fine-tuned weights. Returns status message.""" if checkpoint_path is None: return "No checkpoint loaded" if not checkpoint_path.exists(): raise FileNotFoundError( f"Checkpoint not found: {checkpoint_path}. " f"Run with --checkpoint PATH or --checkpoint none to skip." ) ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) model.load_state_dict(ckpt["model_state_dict"], strict=True) step = ckpt.get("step", "?") return f"step {step}" # --------------------------------------------------------------------------- # Inference # --------------------------------------------------------------------------- @torch.no_grad() def run_inference( model: MultiHeadModel, token_ids: np.ndarray, ) -> dict[str, np.ndarray]: """Run all 4 heads on a single customer sequence.""" tensor = torch.from_numpy(token_ids).unsqueeze(0).long() predictions = model(tensor) results: dict[str, np.ndarray] = {} for name, logits in predictions.items(): if name == "fraud": prob = torch.sigmoid(logits).squeeze().numpy() results[name] = np.array([float(prob)]) else: probs = F.softmax(logits, dim=-1).squeeze(0).numpy() results[name] = probs return results # --------------------------------------------------------------------------- # App builder # --------------------------------------------------------------------------- def create_app( pretrained_model: MultiHeadModel, random_model: MultiHeadModel, data: DemoData, decoder: TransactionDecoder, merchant_catalog: DemoMerchantCatalog, checkpoint_status: str, ) -> gr.Blocks: """Build the Gradio app with pretrained vs random-init comparison.""" pretrained_model.eval() random_model.eval() def on_customer_select( curated_name: str | None, customer_idx: int, mode: str, ) -> tuple[str, str, str, str, str, str, str, str, str, str, str]: """Run both models, return all outputs for comparison.""" if mode == "Curated examples" and curated_name: idx = data.get_curated_index(curated_name) else: idx = int(customer_idx) idx = max(0, min(idx, data.num_customers - 1)) token_ids = data.token_ids[idx] is_fraud = bool(data.labels[idx]) summary = decoder.summarize_customer(token_ids, is_fraud) t0 = time.perf_counter() pre_results = run_inference(pretrained_model, token_ids) rand_results = run_inference(random_model, token_ids) latency_ms = (time.perf_counter() - t0) * 1000 timeline_html = format_timeline(decoder, token_ids) # Pretrained predictions pre_fraud = format_fraud_score(float(pre_results["fraud"][0])) pre_merchant = format_merchant_predictions( pre_results["next_merchant"], merchant_catalog, k=5, ) pre_amount = format_amount_predictions(pre_results["amount_range"], k=5) pre_mcc = format_mcc_predictions(pre_results["mcc"], k=5) # Random-init predictions rand_fraud = format_fraud_score(float(rand_results["fraud"][0])) rand_merchant = format_merchant_predictions( rand_results["next_merchant"], merchant_catalog, k=5, ) rand_amount = format_amount_predictions(rand_results["amount_range"], k=5) rand_mcc = format_mcc_predictions(rand_results["mcc"], k=5) # Behavioral profile profile_match = infer_profile(token_ids) profile_html = format_profile_html(profile_match) # Combined comparison HTML for each head fraud_compare = _side_by_side("Fraud Score", pre_fraud, rand_fraud) merchant_compare = _side_by_side("Next Merchant", pre_merchant, rand_merchant) amount_compare = _side_by_side("Amount Range", pre_amount, rand_amount) mcc_compare = _side_by_side("Merchant Category", pre_mcc, rand_mcc) latency_str = ( f"Inference: {latency_ms:.1f}ms (both models) on CPU | " f"Customer #{idx} | Ground truth: {'FRAUD' if is_fraud else 'Legitimate'}" ) return ( summary, timeline_html, profile_html, fraud_compare, merchant_compare, amount_compare, mcc_compare, latency_str, ) with gr.Blocks( title="LFM2 Transaction Foundation Model", ) as app: gr.HTML("""

LFM2 Transaction Foundation Model

Liquid AI · LFM2.5 Architecture · Multi-Head Inference Demo

""") with gr.Tabs(): # ===== Tab 1: Interactive Predictions ===== with gr.Tab("Predictions"): gr.HTML("""
How to read this: Select a customer, see their transaction history, then compare predictions from the pretrained model (left) vs random initialization (right). Same architecture, same fine-tuning data. The only difference is self-supervised pretraining on unlabeled sequences.
""") with gr.Accordion("Reference model details", open=False): gr.HTML(f"""
arch: LFM2-small 9.8M params layers: 5 conv + 3 attn input: 64 tx × 15 feat = 960 tokens checkpoint: {checkpoint_status} data: 200K synthetic sequences, 15 features/tx
""") # Customer Selection. Use Radio (not Checkbox) for the # curated-vs-browse toggle: the two modes are mutually exclusive # and Radio's visual state is more reliable across Gradio versions. _CURATED_MODE = "Curated examples" _BROWSE_MODE = "Browse all customers" with gr.Row(): with gr.Column(scale=1): gr.HTML("

Select Customer

") selection_mode = gr.Radio( choices=[_CURATED_MODE, _BROWSE_MODE], value=_CURATED_MODE, label="Selection mode", info="Curated: hand-picked legitimate and fraud examples. " "Browse: pick any of 20,000 test customers by index.", elem_classes="liquid-radio", ) curated_dropdown = gr.Dropdown( choices=data.curated_names, value=data.curated_names[0] if data.curated_names else None, label="Curated Examples", info="5 legitimate profiles, 3 fraud archetypes", ) customer_slider = gr.Slider( minimum=0, maximum=data.num_customers - 1, step=1, value=0, label=f"Customer Index (0-{data.num_customers - 1})", info=f"Direct access to any of {data.num_customers:,} test-set customers. " f"~3.7% are fraud, rest are legitimate.", visible=False, ) run_btn = gr.Button( "Run Inference", variant="primary", size="lg", elem_id="run-inference-btn", ) with gr.Column(scale=2): summary_text = gr.Textbox( label="Customer Profile", interactive=False, lines=2, ) profile_output = gr.HTML(label="Behavioral Profile") latency_text = gr.Textbox( label="Performance", interactive=False, lines=1, ) def toggle_selector(mode: str) -> tuple[Any, Any]: use_cur = (mode == _CURATED_MODE) return gr.update(visible=use_cur), gr.update(visible=not use_cur) selection_mode.change( toggle_selector, inputs=[selection_mode], outputs=[curated_dropdown, customer_slider], ) # Transaction Timeline gr.HTML("""

Transaction History

64 most recent transactions. Tx 63 (highlighted) is the most recent. The model predicts what comes next based on this full sequence.
""") timeline_output = gr.HTML() # Side-by-side predictions gr.HTML("

Model Predictions: Pretrained vs Random Init

") gr.HTML(render_comparison_header()) fraud_output = gr.HTML() merchant_output = gr.HTML() amount_output = gr.HTML() mcc_output = gr.HTML() # Wire callbacks outputs = [ summary_text, timeline_output, profile_output, fraud_output, merchant_output, amount_output, mcc_output, latency_text, ] run_btn.click( on_customer_select, inputs=[curated_dropdown, customer_slider, selection_mode], outputs=outputs, ) curated_dropdown.change( on_customer_select, inputs=[curated_dropdown, customer_slider, selection_mode], outputs=outputs, ) # ===== Tab 2: Architecture Deep Dive ===== with gr.Tab("Architecture"): gr.HTML(render_production_architecture()) # ===== Tab 3: Why Liquid ===== with gr.Tab("Why Liquid AI"): gr.HTML(render_why_liquid()) # ===== Tab 4: Integration Guide ===== with gr.Tab("Integration"): gr.HTML(render_integration_guide()) return app def _side_by_side(title: str, pretrained_html: str, random_html: str) -> str: """Render pretrained vs random-init predictions side by side.""" _mono = "JetBrains Mono, ui-monospace, monospace" return f"""
{title}
✓ Pretrained
{pretrained_html}
✗ Random Init
{random_html}
""" # --------------------------------------------------------------------------- # CLI entrypoint # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser( description="LFM2 Transaction Foundation Model — Interactive Inference Demo", ) parser.add_argument( "--checkpoint", type=Path, default=Path("experiments/v2_tied/finetune_20260516_190905/checkpoints/step_004999.pt"), help="Path to fine-tuned MultiHeadModel checkpoint (.pt file)", ) parser.add_argument( "--data-dir", type=Path, default=Path("data/synthetic"), help="Directory containing token_ids.npy, sequence_labels.npy, split_indices.npz", ) parser.add_argument( "--model-config", type=Path, default=Path("configs/model.yaml"), help="Model backbone YAML config", ) parser.add_argument( "--schema", type=Path, default=Path("data/schema.yaml"), help="Feature schema YAML", ) parser.add_argument( "--finetune-config", type=Path, default=Path("experiments/v2_tied/finetune_20260516_190905/finetune_config.yaml"), help="Fine-tune head config YAML", ) parser.add_argument("--port", type=int, default=7860) parser.add_argument("--share", action="store_true", help="Create public Gradio link") args = parser.parse_args() print("Loading schema and merchant catalog...") schema = load_schema(args.schema) merchant_catalog = DemoMerchantCatalog(schema) print("Loading test data...") demo_data = DemoData(args.data_dir, schema) print(f" {demo_data.num_customers} test customers " f"({len(demo_data.fraud_indices)} fraud, {len(demo_data.legit_indices)} legitimate)") print("Building pretrained model...") pretrained_model = build_model(args.model_config, schema, args.finetune_config) checkpoint_status = load_model_checkpoint(pretrained_model, args.checkpoint) print(f" Pretrained: {checkpoint_status}") print("Building random-init baseline model...") random_model = build_model(args.model_config, schema, args.finetune_config) print(" Random-init: fresh weights (no checkpoint)") total_params = sum(p.numel() for p in pretrained_model.parameters()) print(f" Parameters per model: {total_params:,}") print("Building decoder...") decoder = TransactionDecoder(schema, merchant_catalog) print("Launching demo...") app = create_app( pretrained_model, random_model, demo_data, decoder, merchant_catalog, checkpoint_status, ) _liquid_theme = gr.themes.Soft( primary_hue="neutral", secondary_hue="neutral", neutral_hue="neutral", font=gr.themes.GoogleFont("Inter"), font_mono=gr.themes.GoogleFont("JetBrains Mono"), ).set( body_background_fill="#f5f5f5", body_background_fill_dark="#f5f5f5", body_text_color="#171717", body_text_color_dark="#171717", body_text_color_subdued="#737373", body_text_color_subdued_dark="#737373", block_background_fill="#ffffff", block_background_fill_dark="#ffffff", block_border_color="rgba(0,0,0,0.1)", block_border_color_dark="rgba(0,0,0,0.1)", block_label_background_fill="#f5f5f5", block_label_background_fill_dark="#f5f5f5", block_label_text_color="#525252", block_label_text_color_dark="#525252", block_title_text_color="#171717", block_title_text_color_dark="#171717", block_shadow="0 1px 3px rgba(0,0,0,0.04)", block_shadow_dark="0 1px 3px rgba(0,0,0,0.04)", input_background_fill="#ffffff", input_background_fill_dark="#ffffff", input_background_fill_focus="#ffffff", input_background_fill_focus_dark="#ffffff", input_border_color="rgba(0,0,0,0.1)", input_border_color_dark="rgba(0,0,0,0.1)", input_border_color_focus="#171717", input_border_color_focus_dark="#171717", input_placeholder_color="#a3a3a3", input_placeholder_color_dark="#a3a3a3", panel_background_fill="#fafafa", panel_background_fill_dark="#fafafa", panel_border_color="rgba(0,0,0,0.06)", panel_border_color_dark="rgba(0,0,0,0.06)", border_color_primary="rgba(0,0,0,0.1)", border_color_primary_dark="rgba(0,0,0,0.1)", button_primary_background_fill="#171717", button_primary_background_fill_dark="#171717", button_primary_background_fill_hover="#404040", button_primary_background_fill_hover_dark="#404040", button_primary_text_color="#ffffff", button_primary_text_color_dark="#ffffff", button_secondary_background_fill="#ffffff", button_secondary_background_fill_dark="#ffffff", button_secondary_text_color="#525252", button_secondary_text_color_dark="#525252", button_secondary_border_color="rgba(0,0,0,0.1)", button_secondary_border_color_dark="rgba(0,0,0,0.1)", checkbox_background_color="#ffffff", checkbox_background_color_dark="#ffffff", checkbox_border_color="rgba(0,0,0,0.2)", checkbox_border_color_dark="rgba(0,0,0,0.2)", checkbox_background_color_selected="#171717", checkbox_background_color_selected_dark="#171717", checkbox_label_background_fill="#ffffff", checkbox_label_background_fill_dark="#ffffff", checkbox_label_text_color="#171717", checkbox_label_text_color_dark="#171717", slider_color="#171717", slider_color_dark="#171717", table_border_color="rgba(0,0,0,0.06)", table_border_color_dark="rgba(0,0,0,0.06)", table_even_background_fill="#fafafa", table_even_background_fill_dark="#fafafa", table_odd_background_fill="#ffffff", table_odd_background_fill_dark="#ffffff", shadow_spread="0px", shadow_spread_dark="0px", color_accent_soft="rgba(0,0,0,0.04)", color_accent_soft_dark="rgba(0,0,0,0.04)", ) _liquid_css = """ /* Force light mode regardless of system preference */ :root, .dark { color-scheme: light !important; } .gradio-container { background: #f5f5f5 !important; max-width: 1280px !important; margin: auto !important; padding: 1.5rem !important; } /* Tabs: pill-style matching Liquid design system */ .tabs { background: transparent !important; } .tab-nav { background: #f5f5f5 !important; border: none !important; border-bottom: 1px solid rgba(0,0,0,0.1) !important; gap: 4px !important; padding: 4px 0 !important; } .tab-nav button { font-weight: 500 !important; font-size: 14px !important; color: #737373 !important; background: transparent !important; border: none !important; border-bottom: 2px solid transparent !important; padding: 8px 16px !important; border-radius: 0 !important; transition: all 0.15s ease !important; } .tab-nav button:hover { color: #171717 !important; background: rgba(0,0,0,0.03) !important; } .tab-nav button.selected { color: #171717 !important; font-weight: 600 !important; border-bottom: 2px solid #171717 !important; background: transparent !important; } /* Block/component overrides */ .block { border-radius: 12px !important; } .block.padded { background: #ffffff !important; } /* Input, textarea, dropdown */ input, textarea, select, .wrap { background: #ffffff !important; color: #171717 !important; border-color: rgba(0,0,0,0.1) !important; } .secondary-wrap, .wrap-inner { background: #ffffff !important; } /* Labels */ label, .label-wrap, span.svelte-1gfkn6j { color: #171717 !important; } .info { color: #737373 !important; } /* Action buttons: pill style applied only to explicit primary actions. Scoped by elem_id so it doesn't bleed into Gradio radio/checkbox options (which Gradio also renders as