| """Non-compressing transaction encoder. |
| |
| Mirrors the parent repo's `StructuredEmbedding` pattern exactly: |
| per-feature value tables + feature-type table, summed, expanded to a |
| flat sequence of length T_tx * F. The only difference is the output |
| dimension — we project directly to `d_lfm` (1024 for LFM2.5-350M) |
| instead of parent's `hidden_dim=256`. |
| |
| Why this exists: |
| The compressing encoder (TransactionEncoder) outputs 1 pseudo-token |
| per transaction (sequence length 64 from 64 transactions). That is |
| "encoder-style" — a small MLP collapses 15 features per tx into a |
| single 256-dim vector, then a projector lifts to 1024-dim. Fraud |
| quality is poor (ROC-AUC 0.53 at 100% labels) — likely because |
| intra-transaction feature combinations get averaged into the |
| 256-dim bottleneck. |
| |
| This non-compressing variant keeps the full 64*15 = 960-token |
| stream. No MLP-level compression. The LFM2.5 base sees the same |
| sequence shape parent's structured-feature backbone sees — but on |
| its own 1024-dim hidden space, with frozen text-pretrained weights |
| plus LoRA. Tests whether the compression was the binding constraint |
| on fraud quality. |
| |
| What we GIVE UP relative to the compressing variant: |
| - The 15× sequence-length latency advantage |
| - The "modality-token" framing (now we're really doing per-feature- |
| token, like parent — the recipe is closer to "rebuild parent's |
| embedding layer on a bigger backbone" than to LFM2-VL). |
| |
| What we keep: |
| - Frozen base (cross-customer shared base story) |
| - LoRA on attention |
| - The pretrained text backbone |
| |
| Shape contract: |
| (B, T_tx, F) int64 → (B, T_tx * F, d_lfm) float |
| For default (T_tx=64, F=15): (B, 64, 15) → (B, 960, 1024) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
|
|
| from src.data.schema import SchemaConfig |
|
|
|
|
| class StructuredEncoder(nn.Module): |
| """Per-feature value embeddings + feature-type embeddings, summed. |
| |
| This is the parent repo's `StructuredEmbedding` at `hidden_dim=d_lfm`. |
| Drop-in input-side replacement for the LFM2's `embed_tokens` table — |
| same shape contract. |
| |
| Args: |
| schema: parent's SchemaConfig. |
| d_lfm: LFM2 backbone hidden size. 1024 for LFM2.5-350M, 2048 for |
| LFM2.5-1.2B. Must match the LFM wrapper's d_lfm. |
| |
| Forward: |
| token_ids: (B, T_tx, F) int64 |
| returns: (B, T_tx * F, d_lfm) float, dtype matching the |
| embedding tables (fp32 by default). |
| """ |
|
|
| def __init__(self, schema: SchemaConfig, d_lfm: int) -> None: |
| super().__init__() |
| self.num_features = schema.num_features |
| self.num_transactions = schema.num_transactions |
| self.d_lfm = d_lfm |
|
|
| |
| |
| |
| |
| self.value_tables = nn.ModuleList( |
| [nn.Embedding(f.vocab_size, d_lfm) for f in schema.features], |
| ) |
|
|
| |
| |
| |
| |
| self.type_table = nn.Embedding(schema.num_features, d_lfm) |
|
|
| def forward(self, token_ids: torch.Tensor) -> torch.Tensor: |
| |
| B, T_tx, F = token_ids.shape |
| assert F == self.num_features, ( |
| f"Expected {self.num_features} features, got {F}" |
| ) |
|
|
| |
| |
| type_indices = torch.arange(F, device=token_ids.device) |
| type_emb = self.type_table(type_indices) |
| |
|
|
| |
| |
| feature_embeddings: list[torch.Tensor] = [] |
| for f_idx in range(F): |
| feat_tokens = token_ids[:, :, f_idx] |
| val_emb = self.value_tables[f_idx](feat_tokens) |
| |
| feature_embeddings.append(val_emb + type_emb[f_idx]) |
|
|
| stacked = torch.stack(feature_embeddings, dim=2) |
| |
| return stacked.reshape(B, T_tx * F, self.d_lfm) |
| |
|
|
| def num_embedding_params(self) -> int: |
| """Total params in value + type tables (sanity check).""" |
| val = sum(e.weight.numel() for e in self.value_tables) |
| typ = self.type_table.weight.numel() |
| return val + typ |
|
|