| """End-to-end: long-format rows -> lattice -> N-D model -> a trained model. |
| |
| python examples/train_nd.py |
| |
| torch-dimensions ships no trainer, on purpose — optimizers, schedules and loops |
| belong to you. What it does guarantee is that its models are ordinary |
| ``nn.Module``s that train with ordinary PyTorch. This file is that guarantee |
| made runnable: every line below the model construction is plain torch, and |
| none of it is special. |
| |
| The task is one-step-ahead forecasting over a (region x product) lattice where |
| one combination is never sold, so the lattice is genuinely sparse. |
| """ |
|
|
| import torch |
| import torch.nn as nn |
| from torch.utils.data import DataLoader |
|
|
| import torch_dimensions as td |
|
|
| REGIONS = ("north", "south", "east") |
| PRODUCTS = ("widget", "gadget") |
| NEVER_SOLD = ("east", "gadget") |
|
|
|
|
| def synthetic_rows(n_months=120, seed=0): |
| """Long-format rows, the shape a database actually hands you.""" |
| g = torch.Generator().manual_seed(seed) |
| coords, times, values = [], [], [] |
| phase = {c: torch.rand(1, generator=g).item() * 6.28 for c in REGIONS + PRODUCTS} |
| for t in range(n_months): |
| for region in REGIONS: |
| for product in PRODUCTS: |
| if (region, product) == NEVER_SOLD: |
| continue |
| season = torch.sin(torch.tensor(t / 6.0 + phase[region])).item() |
| trend = t / n_months |
| noise = torch.randn(1, generator=g).item() * 0.05 |
| coords.append((region, product)) |
| times.append(t) |
| values.append([season + trend + noise]) |
| return coords, times, values |
|
|
|
|
| def main() -> None: |
| torch.manual_seed(0) |
|
|
| |
| |
| table = td.data.from_table(*synthetic_rows(), names=("region", "product")) |
| print(table) |
| print("lattice:", table.lattice) |
|
|
| |
| |
| windows = td.data.LatticeWindow(len(table), input_len=12, horizon=1) |
| train_w, test_w = windows.split(96) |
| print(f"windows: {len(train_w)} train, {len(test_w)} test") |
|
|
| source = td.data.TensorSource(table.series, table.lattice) |
| loader = DataLoader( |
| td.data.LatticeDataset(source, train_w), |
| batch_size=16, |
| shuffle=True, |
| collate_fn=td.data.collate_lattice, |
| ) |
|
|
| |
| |
| model = td.LSTM( |
| d_model=32, |
| n_layers=6, |
| lattice=table.lattice, |
| d_input=table.n_features, |
| bidirectional=("region", "product"), |
| ) |
| head = nn.Linear(32, table.n_features) |
| print("plan:", model.plan) |
| print( |
| "params:", td.spec(model)["model"]["n_params"] + sum(p.numel() for p in head.parameters()) |
| ) |
|
|
| |
| opt = torch.optim.Adam([*model.parameters(), *head.parameters()], lr=3e-3) |
| mask = table.lattice.mask(torch.float32) |
|
|
| for epoch in range(15): |
| total, n = 0.0, 0 |
| for batch in loader: |
| pred = head(model(batch.x))[:, -1:] |
| loss = ((pred - batch.y) * mask).pow(2).sum() / (mask.sum() * pred.shape[0]) |
| opt.zero_grad() |
| loss.backward() |
| opt.step() |
| total += loss.item() |
| n += 1 |
| if epoch % 3 == 0 or epoch == 14: |
| print(f"epoch {epoch:2d} train mse {total / n:.5f}") |
|
|
| |
| model.eval() |
| test_loader = DataLoader( |
| td.data.LatticeDataset(source, test_w), |
| batch_size=16, |
| collate_fn=td.data.collate_lattice, |
| ) |
| with torch.no_grad(): |
| errs = [ |
| ((head(model(b.x))[:, -1:] - b.y) * mask).pow(2).sum().item() |
| / (mask.sum().item() * b.x.shape[0]) |
| for b in test_loader |
| ] |
| print(f"held-out mse {sum(errs) / len(errs):.5f}") |
|
|
| |
| with torch.no_grad(): |
| out = head(model(next(iter(test_loader)).x)) * mask |
| absent = out.masked_select(~table.lattice.mask().expand_as(out)) |
| print(f"absent-cell outputs: max |x| = {absent.abs().max().item():.1e}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|