What is actually inside the model behind the Neural Net Predictions page — what a neural network is, how this one was trained on twelve years of radar, every experiment that failed on the way (the failures taught us the most), and how the final model runs on a Raspberry Pi. This page assumes you enjoy this stuff. It holds nothing back.
Weather radar measures rainfall over every 1×1 km square, every hour. There are 297 such squares above the Ponca gauge (152 of them also drain past Boxley). Feed a prediction model 48 hours of history and it is looking at 48 frames × 2,304 pixels ≈ 110,000 rainfall numbers per prediction — plus where each pixel sits relative to the gauge, how wet the ground under it already is, and what the river is doing right now.
Our earlier models (see the other math pages) compress all of that into a handful of hand-built summaries: rain in the last 6 hours, distance-weighted totals, storm-motion vectors. We tried very hard to make that approach hit our accuracy target — gradient-boosted trees over 111 engineered features, a two-stage false-alarm-killer cascade, per-pixel soil-moisture features — and we could measure the ceiling: after nine experiments, improvements were smaller than the statistical noise of the validation. The information the hand-built features were throwing away (exact storm shape, position, and evolution) needed a model that could read the raw maps itself. That is the one thing neural networks are genuinely, unreasonably good at.
Strip away the mystique and a neural network is a long chain of two alternating operations: weighted sums, and simple nonlinear squashes. One “neuron” computes:
where the w’s (weights) are numbers the training process chooses, and f is a nonlinearity (ours is SiLU, f(x)=x·sigmoid(x)) — without it, stacking layers would collapse into one big linear formula, and rivers are anything but linear. Layers of these feed into more layers; “deep” just means several layers deep. Our network has exactly 138,610 weights. Training means: show the network a historical hour, let it predict, measure how wrong it was with a loss function, then nudge every weight a tiny step in the direction that would have made it less wrong (gradient descent — the direction comes from calculus, each weight’s partial derivative of the loss). Repeat a few hundred thousand times. Nobody programs the rules of hydrology into it; the rules emerge in the weights because they reduce the loss.
Each hourly radar frame (a 48×48 grid, rainfall log-scaled) enters with three static maps stapled on as extra channels: the Ponca basin mask, the nested Boxley mask, and each pixel’s distance to the gauge — so the net always knows where rain fell relative to what we care about. A convolution slides a tiny 3×3 stencil of learned weights across the whole map: one pattern-detector, applied at every location (that weight-sharing is why it works — a rain cell looks like a rain cell whether it’s over Terrapin Branch or Beech Creek). Three convolution stages, each halving the map:
Every hour of radar collapses to a 64-number summary — but a learned summary, chosen by training to preserve exactly what predicts the river, instead of what we guessed would.
A GRU (gated recurrent unit) reads those 64-number summaries in time order, carrying a 128-number memory forward hour by hour. At each hour, learned “gates” decide how much of the old memory to keep and what from the new hour to write in — that is how it represents things like storm three hours ago, still traveling toward the gauge. Crucially (this was our single biggest accuracy jump — see v2 below), the GRU doesn’t just watch the rain: at every hour it also sees both gauges’ discharge at that hour. Cause and response, side by side, as the event unfolds:
The final memory is joined with 16 site-state numbers (current flow, stage, rates of change, 30-day baseflow, 7/14-day rainfall, season) and fanned out into every answer we want at once:
Multi-task matters with rare events: twelve years hold only ~165 high-water and ~109 flood events at Ponca. Making one shared body answer many related questions (including Boxley’s, a second gauge inside the same basin) multiplies the supervision squeezed from each storm.
The level heads do not output “the river will be at 212 cfs.” They output the log-ratio change from right now:
Why: a river is astonishingly easy to predict on a calm day — “same as now” (persistence) is nearly perfect, and our first level model embarrassed itself trying to relearn that from scratch (v4 below). Predicting the change makes “nothing happens” the built-in default (δ=0), so every one of the 138,610 weights works on the only hard part: deviations — the rises.
102,452 consecutive hours (Nov 2014 → Jul 2026) of 1 km hourly radar rainfall (MRMS RadarOnly, the same product the live site uses), stitched from two archives — NOAA’s and Iowa State’s — because the radar algorithm changed in Oct 2020, the model gets told which era each hour came from. Truth comes from the USGS gauges themselves: 12 years of 15-minute discharge, resampled to hourly with the exact same clock conventions the live system uses (a detail that sounds boring and is actually where data-leakage bugs live: the model at hour t may see nothing from after t — we audited this explicitly). And the house rule holds everywhere: inputs are rain that has already fallen. No forecast rainfall, ever, in training or in operation.
Two loss functions, added together. The crossing heads use cross-entropy (the standard penalty for probability predictions — confidently wrong costs a lot, honestly uncertain costs a little), with crossing hours weighted up because they are ~1% of the data; batches are also sampled event-heavy (rare rise hours drawn ~20× more often) so the net doesn’t spend its life staring at a flat river. The level heads use pinball loss, one per quantile — the small trick that makes honest ranges possible:
For q=0.75 it charges 3× more for predicting too low than too high — the minimizing answer is literally the 75th percentile. Same idea for 0.25 and 0.50. That’s the whole “likely range”: two asymmetric penalties.
The optimizer is AdamW (gradient descent with per-weight adaptive step sizes), gradients clipped, with early stopping on a held-out year to quit before memorization sets in — with this little data the net can memorize; on our epoch-finding run, validation accuracy visibly collapsed two epochs past the sweet spot. Hardware: a single consumer RTX 5090. One training epoch takes 2–6 seconds; the entire 12-fold validation gauntlet runs in ~35 minutes — which is exactly why we could afford to iterate nine times in two days.
Leave-one-year-out: to grade year Y, train a model on the other eleven years and let it predict Y cold. Do that twelve times, so every prediction in the scorebook comes from a model that never saw that year. This kills the classic self-deception where a model “predicts” a flood it trained on. Crossings are scored at event level (any alarm in the 6 h before a crossing counts once; contiguous false-alarm hours count as one false episode), and the headline numbers carry bootstrap confidence intervals — resampling whole years, since river years are internally correlated.
Research honesty: the null results shaped the final model more than the wins did.
CNN + GRU on the frames, site state bolted on at the end. On flood-tier events it immediately beat every engineered-feature model we had (storm shape matters most for the biggest rains). On the everyday high-water tier it lost badly to the boosted trees — a small net is mediocre at precise threshold logic on tabular numbers like “flow is 740, threshold is 800.”
A two-stage “false-alarm killer” trained only on alarm hours: no gain — stage two saw the same inputs as stage one, and you cannot filter noise with a copy of the signal. Architecture was not the bottleneck; information was. Hand-built per-pixel ground-wetness features: mostly redundant with basin-average wetness the models already had. Requiring 2 consecutive alarm hours before alerting: strictly worse — our false alarms are not blips, they are multi-hour episodes where the basin genuinely looked primed and quietly absorbed the rain instead. That last null told us what the remaining errors are: missing knowledge about what the karst ground swallows.
Feeding both gauges’ hourly discharge into the GRU alongside the rain frames — cause and response in the same sequence — transformed the flood tier: 90.8% of flood events caught with 73.3% of alarms real (previous best at that recall: 56%), about 3 false episodes per year, standing alone with no ensemble tricks.
Each pixel’s 7-day exponentially-decaying rain memory as a second image the CNN sees under the rain. Helped some cuts, hurt others — mostly redundant once v2’s hydrograph context existed.
Added the hour-by-hour level heads. On quiet hours the level predictions lost to persistence — the dumbest possible baseline — by 20× (0.44 vs 0.02 log-error), with occasional absurd blow-ups. On rising hours they beat it soundly. Both facts pointed at the same fix. Always fight your model against the dumbest baseline first; rivers mostly do nothing, and doing-nothing must be free.
Levels re-expressed as changes from now (see architecture §4), classification loss re-weighted. Calm hours now tie persistence exactly; rising hours beat it by 25–40% at every horizon on both gauges; the 25th–75th band contains the truth ~60–70% of the time (a touch conservative — recalibration is queued once live storms accrue); and the crossing skill of v2 survived intact.
| What | Result (12-year leave-one-year-out) |
|---|---|
| Ponca level, rising hours, +3 h | log-error 0.58 vs persistence 1.02 (≈43% better) |
| Ponca level, rising hours, +6 h | 0.71 vs 1.19 (≈40% better); calm hours: dead tie |
| Boxley level, rising hours, +1…+4 h | 0.61–0.66 vs 0.80–1.12 |
| Flood crossings (≥1,600 cfs), net alone | 90.8% caught [CI 86–96] at 73.3% precision [70–77], ~3 false episodes/yr |
| High-water crossings (≥800 cfs) | the net alone trails our tree ensemble here; research ensembles reach 90% recall @ ~80% precision and 75% @ 94% — folding that into the live page is on the roadmap |
The probabilities on the live page come from the neural net alone — the conservative reading of the high-water numbers above applies. Every hourly issuance is also auto-graded on the public scorecard, so you never have to take this page’s word for it.
The 5090 is only the classroom. The final model is 530 KB of weights, and the whole forward pass — convolutions, GRU, heads — was rewritten in plain NumPy (~150 lines, no ML frameworks) so it runs on the same Raspberry Pi that powers the rest of the site: ~2 seconds per hourly prediction. Before deployment the NumPy engine was parity-tested against the trained network on 200 randomized inputs — maximum disagreement 5×10⁻⁷, i.e., bit-noise. Each hour the Pi crops the basin window from the site’s shared radar store, pulls the gauges’ recent history, runs the net, and publishes. If radar hours are missing or a gauge feed drops, the page says degraded or stale instead of guessing.
It projects, it does not prophesy. Inputs are rain that has already fallen — if more rain is coming, treat every number as a floor. The tight, high-confidence operating points fire late by design (typically ~2–3 h of lead rather than the full 6). Sudden convective cells over bone-dry karst remain the hardest case for every model we have ever built, this one included. And it is experimental: the scorecard is accumulating its public track record right now, one graded hour at a time. Trust the main dashboard, the ranger, and the river itself first.