guide / src /next_action /train.py
sangram kumar yerra
phase 4 - NextAction MLP & Document Processor
b861cd9
Raw
History Blame Contribute Delete
7.23 kB
"""
NextActionPredictor training script.
Data source: ~6 000 synthetic (domain, entity_flags, prior_contact β†’ action)
examples generated in-memory from DOMAIN_ACTION_PRIORS.
No download required. Trains in < 30 seconds on CPU.
Label assignment rules (derived from DOMAIN_ACTION_PRIORS):
prior_contact = 0 β†’ company_support (always try company first)
prior_contact = 1 β†’ priors[domain][1] (domain regulatory authority)
prior_contact = 1
+ has_AMOUNT + has_REF_ID
+ domain has a tertiary priors[2]
+ 20% chance β†’ priors[domain][2] (escalate to legal / ombudsman)
This creates a distribution the MLP learns naturally: company_support is the
dominant class (~50%) while the 5 escalation authorities split the rest.
CLI usage:
python -m src.next_action.train --output_path models/next_action/model.pt
"""
from __future__ import annotations
import argparse
import logging
import os
import random
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from src.next_action.model import (
ACTION2ID,
DOMAIN_LABELS_ORDERED,
ENTITY_NAMES,
FEATURE_DIM,
GUIDE_MLP,
NUM_ACTIONS,
ACTION_LABELS,
build_feature_vector,
)
from src.next_action.priors import DOMAIN_ACTION_PRIORS
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Label assignment
# ---------------------------------------------------------------------------
def _label_for(
domain: str,
entity_flags: list[float],
prior_contact: float,
rng: random.Random,
) -> int:
"""Assign a training label from the priors and feature context."""
priors = DOMAIN_ACTION_PRIORS[domain] # ordered list of action strings
if prior_contact < 0.5:
action = priors[0] # always "company_support"
else:
has_amount = entity_flags[1] > 0.5 # ENTITY_NAMES[1] = "AMOUNT"
has_ref_id = entity_flags[3] > 0.5 # ENTITY_NAMES[3] = "REF_ID"
strong_case = has_amount and has_ref_id
if strong_case and len(priors) >= 3 and rng.random() < 0.20:
action = priors[2] # tertiary (legal / secondary ombudsman)
else:
action = priors[1] # primary regulatory authority
return ACTION2ID[action]
# ---------------------------------------------------------------------------
# Dataset builder
# ---------------------------------------------------------------------------
def build_synthetic_dataset(
n_samples: int = 6000,
seed: int = 42,
) -> tuple[list[list[float]], list[int]]:
"""
Generate *n_samples* (feature_vector, label_id) pairs in memory.
Distribution:
~50% prior_contact = 0 β†’ company_support label
~50% prior_contact = 1 β†’ domain authority or legal label
Entity flags are sampled independently with p=0.55 of being set (biased
toward having evidence, which is realistic for complaint scenarios).
"""
rng = random.Random(seed)
X: list[list[float]] = []
y: list[int] = []
for _ in range(n_samples):
domain = rng.choice(DOMAIN_LABELS_ORDERED)
entity_flags = [float(rng.random() < 0.55) for _ in ENTITY_NAMES]
prior_contact = float(rng.random() < 0.50)
fv = build_feature_vector(domain, entity_flags, prior_contact)
label = _label_for(domain, entity_flags, prior_contact, rng)
X.append(fv)
y.append(label)
# Log class distribution for transparency
counts: dict[int, int] = {}
for lbl in y:
counts[lbl] = counts.get(lbl, 0) + 1
dist = ", ".join(
f"{ACTION_LABELS[k]}={v}" for k, v in sorted(counts.items())
)
logger.info("Synthetic dataset: %d examples β€” %s", n_samples, dist)
return X, y
# ---------------------------------------------------------------------------
# Training entry point
# ---------------------------------------------------------------------------
def train(args: argparse.Namespace) -> None:
"""Train GUIDE_MLP and save checkpoint to args.output_path."""
logging.basicConfig(level=logging.INFO)
# 1. Data
X, y = build_synthetic_dataset(n_samples=args.n_samples)
X_t = torch.tensor(X, dtype=torch.float32)
y_t = torch.tensor(y, dtype=torch.long)
dataset = TensorDataset(X_t, y_t)
loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True)
# 2. Model, loss, optimiser
model = GUIDE_MLP()
criterion = nn.CrossEntropyLoss()
optimiser = torch.optim.Adam(
model.parameters(), lr=args.lr, weight_decay=1e-4
)
# 3. Training loop (pure PyTorch β€” no Trainer β€” keeps startup time minimal)
model.train()
for epoch in range(1, args.epochs + 1):
epoch_loss = 0.0
correct = 0
for xb, yb in loader:
optimiser.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
optimiser.step()
epoch_loss += loss.item() * len(yb)
correct += (logits.argmax(1) == yb).sum().item()
if epoch % 10 == 0 or epoch == args.epochs:
acc = correct / len(dataset)
logger.info(
"Epoch %3d/%d β€” loss: %.4f acc: %.3f",
epoch, args.epochs,
epoch_loss / len(dataset),
acc,
)
# 4. Evaluate final accuracy
model.eval()
with torch.no_grad():
all_logits = model(X_t)
final_acc = (all_logits.argmax(1) == y_t).float().mean().item()
logger.info("Training complete β€” final train acc: %.3f", final_acc)
# 5. Save checkpoint
os.makedirs(os.path.dirname(os.path.abspath(args.output_path)), exist_ok=True)
torch.save(
{
"state_dict": model.state_dict(),
"feature_dim": FEATURE_DIM,
"num_actions": NUM_ACTIONS,
"action_labels": ACTION_LABELS,
"domain_labels": DOMAIN_LABELS_ORDERED,
"entity_names": ENTITY_NAMES,
},
args.output_path,
)
logger.info("NextActionPredictor checkpoint saved to %s", args.output_path)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Train NextActionPredictor MLP")
p.add_argument(
"--output_path", default="models/next_action/model.pt",
help="Path to save the .pt checkpoint",
)
p.add_argument(
"--n_samples", type=int, default=6000,
help="Number of synthetic training examples",
)
p.add_argument(
"--epochs", type=int, default=60,
help="Training epochs (default 60 β†’ well under 30 s on CPU)",
)
p.add_argument(
"--batch_size", type=int, default=128,
help="Mini-batch size",
)
p.add_argument(
"--lr", type=float, default=3e-3,
help="Adam learning rate",
)
return p.parse_args()
if __name__ == "__main__":
train(parse_args())