prteek commited on
Commit
b84c286
·
verified ·
1 Parent(s): d7e564c

Model code and training recipe

Browse files
Files changed (3) hide show
  1. README.md +120 -0
  2. example.py +39 -0
  3. model.py +149 -0
README.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-4.0
3
+ library_name: pytorch
4
+ tags:
5
+ - time-series
6
+ - panel-data
7
+ - missing-data
8
+ - smoothing
9
+ - imputation
10
+ - air-quality
11
+ - pytorch
12
+ datasets:
13
+ - prteek/sparse-panel-demo
14
+ ---
15
+
16
+ # Prevailing level network for sparse, gappy panels
17
+
18
+ A small PyTorch network that estimates a smooth **prevailing level** for every location and day of a panel,
19
+ for two related quantities at once, when the observations are noisy daily means with long gaps and some series
20
+ missing entirely. It was developed for the post
21
+ [PM2.5, PM10 ~ station + time](https://prteek.github.io/regression_room/posts/beijing_air_quality/) and powers the
22
+ [sparse panel demo Space](https://huggingface.co/spaces/prteek/sparse-panel-demo).
23
+
24
+ **This repo holds the code and the training recipe, not trained weights.** The network learns an embedding for
25
+ each location and a time basis over a fixed date range, so a fitted model only applies to the panel it was fitted
26
+ on. To use it, fit it to your own panel: on the 12-station, four-year Beijing data a fit takes a few seconds on a
27
+ CPU.
28
+
29
+ ## Files
30
+
31
+ - `model.py`: the encoding, the network, the loss and `fit` / `predict`
32
+ - `example.py`: fits the network to a small synthetic panel and predicts every cell (runs as is)
33
+
34
+ ## Model
35
+
36
+ Each row is one location and day, with up to two targets $y_1, y_2$ (log values, standardised).
37
+
38
+ $$
39
+ \begin{aligned}
40
+ x &= \big[\, e_{\text{area}} + e_{\text{station}},\ b(t) \,\big] \\
41
+ h &= \operatorname{SiLU}\!\big(W_2 \operatorname{SiLU}(W_1 x)\big) \\
42
+ \mu_1 &= w_1^\top h, \quad \mu_2 = w_2^\top h
43
+ \end{aligned}
44
+ $$
45
+
46
+ - **Location**: an 8-dim area embedding plus a station offset that starts near zero and is penalised, so a
47
+ station with little data falls back to its area.
48
+ - **Time**: fixed Gaussian bumps $b_k(t) = \exp\{-\tfrac12((t - c_k)/w)^2\}$ with centres every $w$ days, so the
49
+ level is a smooth function of time.
50
+ - **Shared trunk, two heads**: two layers of 64 with SiLU, then one linear head per target. A day where only one
51
+ target was observed still trains everything the other depends on.
52
+
53
+ **Loss.** An observed value is a mean of $n$ readings, so its variance around the level is $\sigma_j^2 / n + \tau_j^2$,
54
+ with $\sigma_j$, $\tau_j$ learned per target. The Gaussian negative log-likelihood is averaged over observed cells
55
+ only: a missing target is **masked**, not dropped. Added to it:
56
+
57
+ - a **curvature penalty** $\lambda \cdot \overline{(\mu_{t+1} - 2\mu_t + \mu_{t-1})^2}$ over every location's
58
+ predicted series on the **whole** day grid, observed or not, which keeps the level smooth through gaps;
59
+ - a small L2 penalty on the station offsets.
60
+
61
+ ## Training recipe
62
+
63
+ Full-batch Adam with a cosine learning-rate schedule. The settings below were chosen by look-ahead
64
+ cross-validation in the post:
65
+
66
+ | Setting | Value |
67
+ |---|---|
68
+ | curvature penalty $\lambda$ (`rough`) | 1e5 |
69
+ | bump spacing $w$ (`spacing`) | 30 days |
70
+ | hidden width | 64 |
71
+ | learning rate | 5e-3 |
72
+ | weight decay | 1e-4 |
73
+ | station offset L2 (`station_l2`) | 1e-3 |
74
+ | schedule length | 1000 |
75
+ | training steps | 100 |
76
+ | seed | 0 |
77
+
78
+ For a single-target model, pass `use=["PM2.5"]` (or the other target) to `fit`.
79
+
80
+ ## Input format
81
+
82
+ Long format, one row per location × date × target that has data:
83
+
84
+ | column | meaning |
85
+ |---|---|
86
+ | `station` | location id |
87
+ | `area` | group the location belongs to (it falls back to this when its own data is thin) |
88
+ | `date` | daily timestamp |
89
+ | `pollutant` | target name, one of `model.TARGETS` |
90
+ | `value` | the daily mean (> 0; modelled on the log scale) |
91
+ | `n` | number of readings behind that mean (weights the observation) |
92
+
93
+ The two target names are set in `model.TARGETS` (`["PM2.5", "PM10"]`); change them for other quantities.
94
+
95
+ ```python
96
+ from model import Encoding, fit, predict
97
+
98
+ enc = Encoding.fit(panel, end=panel["date"].max())
99
+ model = fit(enc, enc.wide(panel), CFG, steps=100)
100
+ level = predict(model, enc, enc.grid()) # every location x day, observed or not
101
+ ```
102
+
103
+ ## Limitations
104
+
105
+ - **Not transferable.** Location embeddings and the time basis are specific to one panel and date range. Refit
106
+ for new locations or dates.
107
+ - **Smoothness is a choice.** Cross-validation that scores against noisy readings rewards following short
108
+ episodes, so it tends to under-smooth; the post discusses this. Treat `rough` and `spacing` as decisions about
109
+ how smooth the level should be.
110
+ - **Log-scale bias.** The model is fitted to logs of means over few readings, so its level sits slightly below a
111
+ moving average of full data (Jensen's inequality).
112
+ - **A GAM is a strong alternative.** In the post, a mixed-effects GAM with hand-set smoothness does as well or
113
+ better.
114
+ - **Memory.** Training is full batch and the penalty covers every location × day, so memory grows with panel size.
115
+
116
+ ## Data used in the post
117
+
118
+ Beijing Multi-Site Air Quality, Song Chen (2017), UCI Machine Learning Repository,
119
+ [doi:10.24432/C5RK5G](https://doi.org/10.24432/C5RK5G), CC BY 4.0. The demo's sparse readings and predictions
120
+ are in the dataset [prteek/sparse-panel-demo](https://huggingface.co/datasets/prteek/sparse-panel-demo).
example.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fit the prevailing-level network to a panel of your own and predict a smooth level for every cell.
2
+
3
+ Input is long format, one row per station x date x pollutant that has data:
4
+ station, area, date, pollutant (one of model.TARGETS), value (> 0, a daily mean), n (readings behind it)
5
+ Here a small synthetic panel stands in for real data.
6
+ """
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from model import TARGETS, Encoding, fit, predict
11
+
12
+ # Settings chosen by look-ahead cross-validation in the post
13
+ CFG = dict(rough=1e5, spacing=30, lr=5e-3, hidden=64, weight_decay=1e-4, station_l2=1e-3, schedule=1000, seed=0)
14
+ STEPS = 100
15
+
16
+
17
+ def synthetic_panel(seed: int = 0) -> pd.DataFrame:
18
+ rng = np.random.default_rng(seed)
19
+ days = pd.date_range("2020-01-01", periods=365)
20
+ stations = {"s1": "urban", "s2": "urban", "s3": "suburban", "s4": "suburban"}
21
+ t = np.arange(len(days))
22
+ rows = []
23
+ for s, area in stations.items():
24
+ level = 4 + 0.5 * np.sin(2 * np.pi * t / 365) + (0.3 if area == "urban" else 0) + rng.normal(0, 0.1)
25
+ for j, pollutant in enumerate(TARGETS):
26
+ n = rng.poisson(1.2, len(days)) # hours observed per day; 0 means no reading
27
+ keep = n > 0
28
+ value = np.exp(level + 0.4 * j + rng.normal(0, 0.5 / np.sqrt(np.maximum(n, 1))))
29
+ rows.append(pd.DataFrame({"station": s, "area": area, "date": days[keep], "pollutant": pollutant,
30
+ "value": value[keep], "n": n[keep]}))
31
+ return pd.concat(rows, ignore_index=True)
32
+
33
+
34
+ panel = synthetic_panel()
35
+ enc = Encoding.fit(panel, end=panel["date"].max())
36
+ model = fit(enc, enc.wide(panel), CFG, STEPS) # joint model; use=["PM2.5"] for a single-target one
37
+ level = predict(model, enc, enc.grid()) # every station x day, observed or not
38
+ print(level.head())
39
+ print(f"{len(panel)} readings in, {len(level)} predicted cells out")
model.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Joint two-target network for a smooth 'prevailing' pollution level per station and day.
2
+
3
+ Copied from the regression_room post (posts/beijing_air_quality/model.py):
4
+ - one row per station x day with up to two targets (log PM2.5, log PM10); a missing target is masked, not dropped;
5
+ - location = area embedding + small station offset (station falls back to its area when data is thin);
6
+ - time enters only through fixed Gaussian bumps, so the level is a smooth function of time;
7
+ - loss = Gaussian NLL of an observed daily mean of n hours, variance sigma^2 / n + tau^2;
8
+ - a roughness penalty on the second difference of every station's predicted series over the full day grid.
9
+ """
10
+ from dataclasses import dataclass
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import torch
15
+ from torch import nn
16
+
17
+ TARGETS = ["PM2.5", "PM10"]
18
+ KEY = ["station", "date"]
19
+
20
+
21
+ @dataclass
22
+ class Encoding:
23
+ stations: list
24
+ areas: list
25
+ station_area: dict
26
+ start: pd.Timestamp
27
+ n_days: int
28
+ y_mean: np.ndarray
29
+ y_std: np.ndarray
30
+
31
+ @classmethod
32
+ def fit(cls, df: pd.DataFrame, end: pd.Timestamp) -> "Encoding":
33
+ start = df["date"].min()
34
+ logv = np.log(df["value"])
35
+ return cls(stations=sorted(df["station"].unique()), areas=sorted(df["area"].unique()),
36
+ station_area=df.drop_duplicates("station").set_index("station")["area"].to_dict(),
37
+ start=start, n_days=(end - start).days + 1,
38
+ y_mean=np.array([logv[df["pollutant"] == t].mean() for t in TARGETS]),
39
+ y_std=np.array([logv[df["pollutant"] == t].std() for t in TARGETS]))
40
+
41
+ def wide(self, df: pd.DataFrame) -> pd.DataFrame:
42
+ w = df.pivot_table(index=KEY, columns="pollutant", values=["value", "n"], aggfunc="first")
43
+ w.columns = [f"{v}_{p}" for v, p in w.columns]
44
+ for t in TARGETS:
45
+ for v in ["value", "n"]:
46
+ if f"{v}_{t}" not in w:
47
+ w[f"{v}_{t}"] = np.nan
48
+ return w.reset_index()
49
+
50
+ def inputs(self, keys: pd.DataFrame) -> dict:
51
+ s_idx = {s: i for i, s in enumerate(self.stations)}
52
+ a_idx = {a: i for i, a in enumerate(self.areas)}
53
+ return {"station": torch.tensor(keys["station"].map(s_idx).to_numpy()),
54
+ "area": torch.tensor(keys["station"].map(self.station_area).map(a_idx).to_numpy()),
55
+ "day": torch.tensor((keys["date"] - self.start).dt.days.to_numpy())}
56
+
57
+ def targets(self, w: pd.DataFrame, use=TARGETS):
58
+ val = w[[f"value_{t}" for t in TARGETS]].to_numpy(float)
59
+ n = w[[f"n_{t}" for t in TARGETS]].to_numpy(float)
60
+ mask = ~np.isnan(val)
61
+ mask[:, [t not in use for t in TARGETS]] = False # single-target models ignore the other pollutant
62
+ y = (np.log(np.where(mask, val, 1.0)) - self.y_mean) / self.y_std
63
+ return (torch.tensor(np.where(mask, y, 0.0), dtype=torch.float32), torch.tensor(mask),
64
+ torch.tensor(np.where(mask, n, 1.0), dtype=torch.float32))
65
+
66
+ def to_value(self, mu: torch.Tensor) -> np.ndarray:
67
+ return np.exp(mu.detach().numpy() * self.y_std + self.y_mean)
68
+
69
+ def grid(self) -> pd.DataFrame:
70
+ days = pd.date_range(self.start, periods=self.n_days)
71
+ return pd.DataFrame({"station": self.stations}).merge(pd.DataFrame({"date": days}), how="cross")
72
+
73
+
74
+ class TimeBasis(nn.Module):
75
+ def __init__(self, n_days: int, spacing: int):
76
+ super().__init__()
77
+ self.register_buffer("centres", torch.arange(-spacing, n_days + 2 * spacing, spacing, dtype=torch.float32))
78
+ self.width = float(spacing)
79
+
80
+ def forward(self, day):
81
+ return torch.exp(-0.5 * ((day.float()[:, None] - self.centres[None, :]) / self.width) ** 2)
82
+
83
+
84
+ class PrevailingNet(nn.Module):
85
+ def __init__(self, enc: Encoding, dim_loc: int = 8, hidden: int = 64, spacing: int = 30):
86
+ super().__init__()
87
+ self.time = TimeBasis(enc.n_days, spacing)
88
+ self.area = nn.Embedding(len(enc.areas), dim_loc)
89
+ nn.init.normal_(self.area.weight, std=0.1) # small init so the area vectors are actually learned
90
+ self.station = nn.Embedding(len(enc.stations), dim_loc)
91
+ nn.init.normal_(self.station.weight, std=0.01)
92
+ self.trunk = nn.Sequential(nn.Linear(dim_loc + len(self.time.centres), hidden), nn.SiLU(),
93
+ nn.Linear(hidden, hidden), nn.SiLU())
94
+ self.heads = nn.ModuleList([nn.Linear(hidden, 1) for _ in TARGETS])
95
+ self.log_sigma = nn.Parameter(torch.zeros(len(TARGETS)))
96
+ self.log_tau = nn.Parameter(torch.full((len(TARGETS),), -1.0))
97
+ self.register_buffer("area_of", torch.tensor([enc.areas.index(enc.station_area[s]) for s in enc.stations]))
98
+ self.n_days = enc.n_days
99
+
100
+ def forward(self, x):
101
+ loc = self.area(x["area"]) + self.station(x["station"])
102
+ h = self.trunk(torch.cat([loc, self.time(x["day"])], dim=1))
103
+ return torch.cat([head(h) for head in self.heads], dim=1)
104
+
105
+ def all_series(self):
106
+ """Predictions for every station over the full day grid, shape (stations, days, targets)."""
107
+ s = torch.arange(len(self.area_of))
108
+ x = {"station": s.repeat_interleave(self.n_days), "area": self.area_of[s].repeat_interleave(self.n_days),
109
+ "day": torch.arange(self.n_days).repeat(len(s))}
110
+ return self(x).view(len(s), self.n_days, len(TARGETS))
111
+
112
+
113
+ def nll(mu, model, y, mask, n):
114
+ var = torch.exp(2 * model.log_sigma) / n + torch.exp(2 * model.log_tau)
115
+ out = 0.5 * (torch.log(var) + (y - mu) ** 2 / var)
116
+ return (out * mask).sum() / mask.sum()
117
+
118
+
119
+ def roughness(model):
120
+ mu = model.all_series()
121
+ return (mu[:, 2:] - 2 * mu[:, 1:-1] + mu[:, :-2]).pow(2).mean()
122
+
123
+
124
+ def fit(enc, w, cfg, steps, use=TARGETS, on_checkpoint=None, checkpoints=()):
125
+ """Full-batch Adam on a cosine schedule spanning `cfg['schedule']` steps (same meaning in CV and final fit)."""
126
+ torch.manual_seed(cfg.get("seed", 0))
127
+ model = PrevailingNet(enc, hidden=cfg["hidden"], spacing=cfg["spacing"])
128
+ opt = torch.optim.Adam(model.parameters(), lr=cfg["lr"], weight_decay=cfg["weight_decay"])
129
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, cfg["schedule"])
130
+ x, (y, mask, n) = enc.inputs(w), enc.targets(w, use)
131
+ for step in range(1, steps + 1):
132
+ model.train()
133
+ loss = (nll(model(x), model, y, mask, n) + cfg["rough"] * roughness(model)
134
+ + cfg["station_l2"] * model.station.weight.pow(2).mean())
135
+ opt.zero_grad()
136
+ loss.backward()
137
+ opt.step()
138
+ sched.step()
139
+ if on_checkpoint and step in checkpoints:
140
+ model.eval()
141
+ on_checkpoint(step, model)
142
+ model.eval()
143
+ return model
144
+
145
+
146
+ def predict(model, enc, keys: pd.DataFrame) -> pd.DataFrame:
147
+ with torch.no_grad():
148
+ v = enc.to_value(model(enc.inputs(keys)))
149
+ return pd.concat([keys[KEY].assign(pollutant=t, pred=v[:, i]) for i, t in enumerate(TARGETS)], ignore_index=True)