File size: 8,826 Bytes
ecc81b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """Forecasting on a sparse lattice — the experiment nobody has published.
python -m examples.repro.forecast_sparse --arm masked --arm zeros
python -m examples.repro.forecast_sparse --arm masked --arm cafa
Air quality over Beijing: 12 monitoring stations x 6 pollutants, hourly, a
genuinely 2-D lattice (station and pollutant are different kinds of axis, not
a reshaped sequence). The task is to forecast the next hour from the previous
24 at every cell.
**Two claims get numbers here.**
*Absent cells.* Every source implementation of an N-D sequence model assumes a
full grid. When some series do not exist — a station that never measured
ozone — the only options they leave are zero-filling or dropping the axis.
This library marks them absent instead, and the claim is that the difference
matters. So: same data, same model, same budget, and the `masked` arm gets a
validity mask while the `zeros` arm gets a dense lattice with zeros in the
same positions. Both are scored on present cells only, so the comparison is
about *representation*, not about which cells count.
The sparsity is **induced**, and that is deliberate rather than a compromise:
a naturally sparse dataset confounds "is missing" with "is different", while
removing cells at random from a complete dataset isolates the one variable.
The `--drop` fraction lands in every recorded row.
*Method of multidimensionality.* The other arms swap `nd_method` and change
nothing else — `axial_scan` sweeps a mixer along each axis, `cafa` builds
per-axis kernels across space while the mixer owns time. That comparison is
one flag here and a fork of somebody's repository anywhere else, which is the
entire argument for the library.
No published baseline exists for this task in this form. These rows are the
baseline; they are reported with hardware, seeds, and the exact split, so
somebody can beat them.
"""
from __future__ import annotations
import argparse
import torch
import torch.nn as nn
import torch_dimensions as td
from .data import beijing
from .harness import Config, pick_device, record, sync, train_regressor
INPUT_LEN = 24
HORIZON = 1
class Forecaster(nn.Module):
"""Body over the lattice, last timestep read out, one value per cell."""
def __init__(self, body: nn.Module, d_model: int):
super().__init__()
self.body = body
self.head = nn.Linear(d_model, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor: # (B, T, *lat, 1)
return self.head(self.body(x)[:, -1:]) # (B, 1, *lat, 1)
def prepare(drop: float, seed: int) -> dict:
"""Standardize, impute, window, split — and choose which cells exist.
Standardization statistics come from the training window only. Gaps in the
real data are filled with the training mean *after* standardizing, i.e.
with zero, and the same imputation is used by every arm so it cannot
explain a difference between them.
"""
raw = beijing()
series = raw["series"] # (T, station, pollutant), NaN where unmeasured
n_time = series.shape[0]
cut = int(n_time * 0.8)
train_part = series[:cut]
mean = torch.nanmean(train_part, dim=0)
centered = train_part - mean
std = torch.sqrt(torch.nanmean(centered * centered, dim=0)).clamp_min(1e-6)
values = (series - mean) / std
values = torch.nan_to_num(values, nan=0.0)
shape = tuple(values.shape[1:])
g = torch.Generator().manual_seed(seed + 777)
keep = torch.ones(shape, dtype=torch.bool)
if drop > 0:
n_drop = int(round(drop * keep.numel()))
idx = torch.randperm(keep.numel(), generator=g)[:n_drop]
keep.reshape(-1)[idx] = False
return {
"values": values.unsqueeze(-1), # (T, station, pollutant, 1)
"keep": keep,
"shape": shape,
"names": raw["names"],
"cut": cut,
}
def windows(values: torch.Tensor, lo: int, hi: int, batch: int, device: str) -> list:
"""Fixed, non-overlapping-by-stride windows as ready-to-use batches."""
starts = list(range(lo, hi - INPUT_LEN - HORIZON, 4))
out = []
for i in range(0, len(starts), batch):
chunk = starts[i : i + batch]
if not chunk:
continue
x = torch.stack([values[s : s + INPUT_LEN] for s in chunk])
y = torch.stack([values[s + INPUT_LEN : s + INPUT_LEN + HORIZON] for s in chunk])
out.append((x.to(device), y.to(device)))
return out
ARMS = {
"masked": "sparse lattice, absent cells marked",
"zeros": "dense lattice, absent cells zero-filled",
"cafa": "sparse lattice + td.cafa across space",
"attention": "sparse lattice + td.axial_attention across space",
}
def build_arm(arm: str, prep: dict, d_model: int, n_layers: int):
"""One arm's model. Every arm differs in exactly one thing, named above."""
shape, names, keep = prep["shape"], prep["names"], prep["keep"]
valid = None if arm == "zeros" else keep
lattice = td.Lattice(shape=shape, names=names, valid=valid, time=True)
axes = ("time", *names)
kw: dict = {"d_model": d_model, "lattice": lattice, "d_input": 1}
if arm in ("cafa", "attention"):
# The kernel family owns the spatial axes; the mixer sweeps time. The
# plan still names every axis, because the plan is what tells the block
# which axes are spatial.
plan = td.ScanPlan.cyclic(axes, n_layers=n_layers, warn=False)
method = td.cafa if arm == "cafa" else td.axial_attention
body = td.LSTM(plan=plan, method=method, **kw)
else:
plan = td.ScanPlan.cyclic(axes, n_layers=n_layers, bidirectional=names, warn=False)
body = td.LSTM(plan=plan, **kw)
return Forecaster(body, d_model)
def make_step(prep: dict, device: str):
"""MSE over *present* cells only, identical for every arm.
The scored set never changes with the arm — otherwise "masking helps"
could just mean "we stopped scoring the hard cells".
"""
mask = prep["keep"].to(device).reshape(1, 1, *prep["shape"], 1).float()
denom = mask.sum().clamp_min(1.0)
def step(model: nn.Module, batch) -> torch.Tensor:
x, y = batch
pred = model(x * mask)
return (((pred - y) * mask) ** 2).sum() / (denom * x.shape[0])
return step
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--arm", action="append", choices=sorted(ARMS), help="repeatable")
ap.add_argument("--drop", type=float, default=0.3, help="fraction of cells made absent")
ap.add_argument("--epochs", type=int, default=8)
ap.add_argument("--batch", type=int, default=32)
ap.add_argument("--d-model", type=int, default=48)
ap.add_argument("--n-layers", type=int, default=4)
ap.add_argument("--lr", type=float, default=3e-3)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--device", default="auto")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
arms = args.arm or ["masked", "zeros"]
device = pick_device(args.device)
prep = prepare(args.drop, args.seed)
values = prep["values"]
train = windows(values, 0, prep["cut"], args.batch, device)
test = windows(values, prep["cut"], values.shape[0], args.batch, device)
step = make_step(prep, device)
print(
f"lattice {prep['shape']} {prep['names']}, "
f"{int(prep['keep'].sum())}/{prep['keep'].numel()} cells present "
f"(drop={args.drop}), {len(train)} train / {len(test)} test batches"
)
for arm in arms:
def build(arm=arm):
return build_arm(arm, prep, args.d_model, args.n_layers)
if args.dry_run:
import time
model = build().to(device)
step(model, train[0]).backward()
sync(device)
t0 = time.time()
step(model, train[0]).backward()
sync(device) # without this, the number is the dispatch queue's
n = sum(p.numel() for p in model.parameters())
print(
f"{arm}: {n:,} params, {time.time() - t0:.2f}s/batch "
f"-> {(time.time() - t0) * len(train) / 60:.1f} min/epoch"
)
continue
cfg = Config(
name=f"beijing forecast · {arm}",
task=f"air quality 12×6 lattice, {int(args.drop * 100)}% cells absent",
model=f"{arm} ({ARMS[arm]}), d_model={args.d_model} n_layers={args.n_layers}",
epochs=args.epochs,
batch=args.batch,
lr=args.lr,
seed=args.seed,
device=args.device,
extra={"arm": arm, "drop": args.drop, "input_len": INPUT_LEN, "horizon": HORIZON},
)
record(train_regressor(build, step, train, test, cfg, log_every=200))
if __name__ == "__main__":
main()
|