cdotsanghvi's picture
add multi-head demo as 4th-6th tabs; restore Why Liquid + Integration
083b138
Raw
History Blame Contribute Delete
6.14 kB
"""Inference path for the encoder demo.
Loads V3 (Option A: nocompress + mean-pool + attn-LoRA) and exposes a
single `run_inference(token_ids)` function returning multi-head
predictions in a UI-friendly shape.
Single model, no side-by-side — different from the parent's
pretrained-vs-random comparison. The encoder demo's narrative is about
the architecture itself, not the value of pretraining.
Runs on CPU (fp32) for HF Spaces basic tier and local Mac smoke tests.
On the production H100 (192.222.55.165 / demos.liquid.ai surface), the
caller can pass `dtype=torch.bfloat16, device="cuda"` for the bf16 fast
path.
Performance note: even on CPU, ~64-tx inference is dominated by the
backbone's 16-layer forward pass over 960 pseudo-tokens. Expect
~3-8 seconds per inference on a Mac M-series CPU. For an interactive
demo we cache the model in memory and accept the latency; the user
selects a customer, clicks Run, waits ~5 seconds, sees outputs. GPU
deployment cuts this to <100ms.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
import torch
import torch.nn.functional as F
import yaml
from src.data.schema import SchemaConfig, load_schema
from src.training.trainer_utils import load_checkpoint
from encoder.src.model.transaction_fm import build_transaction_fm
class EncoderDemoModel:
"""Wraps the V3 stack with cached inference for the demo.
Construction:
model = EncoderDemoModel(
model_config_path="encoder/configs/model_nocompress.yaml",
schema_path="data/schema.yaml",
checkpoint_path="encoder/experiments/.../step_004999.pt",
dtype=torch.float32,
device="cpu",
)
Inference:
results = model.run_inference(token_ids) # (64, 15) int64
# results: dict[str, np.ndarray]
# fraud: shape (1,) — sigmoid probability in [0, 1]
# next_merchant: shape (10003,) — softmax over merchant_id vocab
# amount_range: shape (16,) — softmax over 16 amount buckets
# mcc: shape (103,) — softmax over MCC vocab
"""
def __init__(
self,
model_config_path: str | Path,
schema_path: str | Path,
checkpoint_path: str | Path | None = None,
dtype: torch.dtype = torch.float32,
device: str = "cpu",
) -> None:
self.device = torch.device(device)
self.dtype = dtype
with open(model_config_path) as f:
mcfg = yaml.safe_load(f)
self.schema: SchemaConfig = load_schema(schema_path)
self.head_configs = mcfg["heads"]
# device_map=None + manual .to(device) below. We deliberately don't
# use device_map="auto" because the encoder/projector/heads are
# constructed separately and would otherwise stay on CPU.
self.model = build_transaction_fm(
schema=self.schema,
head_configs=self.head_configs,
model_path=mcfg["backbone"]["hf_path"],
architecture_cfg=mcfg.get("architecture"),
encoder_cfg=mcfg.get("encoder"),
projector_cfg=mcfg.get("projector"),
lora_cfg=mcfg["backbone"]["lora"],
dtype=dtype,
device_map=None,
).to(self.device)
self.model.eval()
self.checkpoint_status = "no checkpoint (random-init)"
if checkpoint_path is not None:
ckpt_path = Path(checkpoint_path)
if not ckpt_path.exists():
raise FileNotFoundError(
f"Checkpoint not found: {ckpt_path}",
)
# Peek at the checkpoint to detect slim variant (LFM base
# stripped). Slim checkpoints load with strict=False because
# the freshly-loaded LFM base keys are missing from the
# state_dict — but the runtime values are identical to what
# was originally in the checkpoint, so the math is unchanged.
ckpt_peek = torch.load(ckpt_path, map_location="cpu", weights_only=False)
is_slim = ckpt_peek.get("model_state_dict_slim", False)
del ckpt_peek # release memory before load_checkpoint re-reads
ckpt = load_checkpoint(ckpt_path, self.model, strict=not is_slim)
step = ckpt.get("step", "?")
slim_note = " (slim — LFM base from HF cache)" if is_slim else ""
self.checkpoint_status = f"step {step}{slim_note}"
@torch.no_grad()
def run_inference(self, token_ids: np.ndarray) -> dict[str, np.ndarray]:
"""Run all heads on one customer sequence.
Args:
token_ids: (64, 15) int64 numpy array — one customer's
64-transaction history.
Returns:
dict mapping head name to its prediction array. Fraud is a
sigmoid probability; the rest are softmax-normalized class
distributions.
"""
tensor = torch.from_numpy(token_ids).unsqueeze(0).long().to(self.device)
# tensor: (1, 64, 15)
predictions = self.model(tensor)
# predictions: dict[head_name, logits]
# fraud: (1, 1)
# next_merchant: (1, 10003)
# amount_range: (1, 16)
# mcc: (1, 103)
results: dict[str, np.ndarray] = {}
for name, logits in predictions.items():
if name == "fraud":
prob = torch.sigmoid(logits).squeeze().cpu().numpy()
# Cast to scalar then back to 1-element array for uniform downstream handling
results[name] = np.array([float(prob)])
else:
probs = F.softmax(logits, dim=-1).squeeze(0).cpu().numpy()
results[name] = probs
return results
def num_params(self) -> dict[str, int]:
"""Param breakdown for the architecture-display card in the UI."""
total = sum(p.numel() for p in self.model.parameters())
trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
return {"total": total, "trainable": trainable}