Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Spatiotemporal Preprocessing — Geo, Time, and the Pandas Pipeline

Open In Colab

Real spatiotemporal data arrives in awkward shapes:

  • Geographic coordinates wrap around at ±180°\pm 180° longitude and live on a sphere — a raw (lon,lat)(lon, lat) MLP has to learn the seam from scratch and never quite manages.
  • Time covariates carry strong periodicities (daily, weekly, yearly) that a raw scalar t cannot express compactly.
  • DataFrames mix all of this with column names and need a fit-once, transform-often lifecycle.

pyrox.nn and pyrox.preprocessing provide composable layers for the first two concerns and a pandas-side facade for the third. This notebook walks through:

  1. Geographic encoders — Deg2Rad, Cartesian3DEncoder, SphericalHarmonicEncoder, LonLatScale. Same downstream MLP, different encoders — show the seam vanish.
  2. Time / seasonal encoders — FourierFeatures, SeasonalFeatures and the underlying pure-JAX helpers. A linear regression on encoded time fits multiperiodic signals.
  3. Pandas-side fit_spatiotemporal — one call builds a fitted bundle of layers from a DataFrame; the bundle re-encodes test rows with the same time_min.

Setup

Python implementation: CPython
Python version       : 3.13.5
IPython version      : 9.10.0

jax       : 0.9.2
equinox   : 0.13.6
numpyro   : 0.20.1
pyrox     : 0.0.6
pandas    : 3.0.2
matplotlib: 3.10.8

Compiler    : GCC 11.2.0
OS          : Linux
Release     : 6.8.0-1044-azure
Machine     : x86_64
Processor   : x86_64
CPU cores   : 16
Architecture: 64bit

1. Geographic encoders — the lon/lat seam

The seam, formally

Geographic coordinates parameterise the sphere as

Φ:[−180°,180°]×[−90°,90°]→S2,(λ,ϕ)↦(cos⁡ϕcos⁡λ, cos⁡ϕsin⁡λ, sin⁡ϕ).\Phi : [-180°, 180°] \times [-90°, 90°] \to S^2, \qquad (\lambda, \phi) \mapsto (\cos\phi\cos\lambda,\ \cos\phi\sin\lambda,\ \sin\phi).

This map is not injective at the boundary:

  • Φ(−180°,ϕ)=Φ(+180°,ϕ)\Phi(-180°, \phi) = \Phi(+180°, \phi) for every ϕ (the antimeridian — the date line),
  • Φ(λ,+90°)\Phi(\lambda, +90°) is the same point for every λ (the north pole; analogously the south pole).

A raw (λ,ϕ)(\lambda, \phi) MLP sees the chart, not the sphere: λ=−180°\lambda = -180° and λ=+180°\lambda = +180° are the two ends of a 360°360°-wide interval, and the model has no built-in notion that they are the same physical point. Two costs follow:

  1. Discontinuity at the antimeridian. A continuous function f:S2→Rf : S^2 \to \mathbb{R} pulled back to the chart need not be continuous in λ at ±180°\pm 180° — a function value f(λ⋆)f(\lambda^\star) near the date line has nothing constraining it from the “other side.” A standard MLP has to learn the equivalence from data.
  2. Polar singularity. At ϕ=±90°\phi = \pm 90°, the entire interval λ∈[−180°,180°]\lambda \in [-180°, 180°] collapses to a single point, so the angular metric is wildly distorted near the poles.

The fix is to compose with Φ and feed the unit-Cartesian coordinates instead:

x∈S2⊂R3,x=(cos⁡ϕcos⁡λ, cos⁡ϕsin⁡λ, sin⁡ϕ),  ∥x∥2=1.x \in S^2 \subset \mathbb{R}^3, \qquad x = (\cos\phi \cos\lambda,\ \cos\phi \sin\lambda,\ \sin\phi),\ \ \|x\|_2 = 1.

pyrox.nn.Cartesian3DEncoder is exactly Φ. Both date-line points land on the same xx, and the polar singularity disappears (the north and south poles are simply (0,0,±1)(0, 0, \pm 1)).

From S2S^2 to a smooth basis: real spherical harmonics

R3\mathbb{R}^3-coordinates are continuous on the sphere, but for regression we usually want an orthonormal basis of L2(S2)L^2(S^2) — the spherical analogue of the Fourier basis on a circle. The real spherical harmonics {Yℓm}\{Y_\ell^m\} for degree ℓ≥0\ell \ge 0 and order m∈{−ℓ,…,ℓ}m \in \{-\ell, \ldots, \ell\} are

Yℓm(θ,ϕ)={2 Nℓ∣m∣ Pℓ∣m∣(cos⁡θ) sin⁡(∣m∣ ϕ)m<0,Nℓ0 Pℓ0(cos⁡θ)m=0,2 Nℓm Pℓm(cos⁡θ) cos⁡(m ϕ)m>0,Nℓm=(2ℓ+1)(ℓ−m)!4π(ℓ+m)!,Y_\ell^m(\theta, \phi) = \begin{cases} \sqrt{2}\, N_\ell^{|m|}\, P_\ell^{|m|}(\cos\theta)\,\sin(|m|\,\phi) & m < 0, \\ N_\ell^0\, P_\ell^0(\cos\theta) & m = 0, \\ \sqrt{2}\, N_\ell^m\, P_\ell^m(\cos\theta)\,\cos(m\,\phi) & m > 0, \end{cases} \qquad N_\ell^m = \sqrt{\frac{(2\ell + 1)(\ell - m)!}{4\pi (\ell + m)!}},

where PℓmP_\ell^m is the associated Legendre polynomial and (θ,ϕ)=(π/2−ϕlat, λ)(\theta, \phi) = (\pi/2 - \phi_{\mathrm{lat}},\, \lambda) are the colatitude / longitude angles. Truncating at ℓ≤ℓmax\ell \le \ell_\text{max} gives a basis of dimension (ℓmax+1)2(\ell_\text{max} + 1)^2 — a spherical analogue of a band-limited Fourier basis. SphericalHarmonicEncoder(l_max=3) produces (ℓmax+1)2=16(\ell_\text{max} + 1)^2 = 16 features.

Any square-integrable function on the sphere expands as f(x)=∑ℓ,mcℓ,m Yℓm(x)f(x) = \sum_{\ell, m} c_{\ell, m}\, Y_\ell^m(x), so a linear head on truncated SH features is the optimal least-squares fit when the target lies in that span — which is exactly the experiment we run below.

We build a synthetic target that’s a low-degree real spherical harmonic so we know the right basis to fit it.

Both pipelines have comparable parameter counts (the MLP has more if anything), so any difference in test loss is the encoder, not the capacity.

Raw (lon,lat) MLP final MSE:  0.0003
Sphere SH-encoder MSE:        3.8223e-06

The spherical-harmonic head fits to numerical zero in 2000 steps — it has the right basis. The raw (lon,lat)(lon, lat) MLP plateaus several orders of magnitude higher because it has to discover the wraparound and the spherical basis from scratch.

Let’s look at where the residual error lives on the globe.

<Figure size 1800x500 with 6 Axes>

The raw MLP’s residual concentrates near λ=±180°\lambda = \pm 180° (the seam) and at the poles — exactly the regions where a flat (lon,lat)(lon, lat) representation lies most about the underlying geometry. The spherical-harmonic encoder makes the seam invisible.

2. Time encoders — periodic signals

Seasonal features = truncated Fourier series on a circle

A function with known period τ is a function on the circle R/τZ\mathbb{R} / \tau\mathbb{Z}, and its Fourier series is

f(t)=a0+∑h=1∞[ahcos⁡(2πht/τ)+bhsin⁡(2πht/τ)].f(t) = a_0 + \sum_{h=1}^\infty \Bigl[a_h \cos(2\pi h t / \tau) + b_h \sin(2\pi h t / \tau)\Bigr].

Truncating at h=Hh = H gives a 2H+12H + 1-dimensional basis that exactly represents the band-limited part of ff. When several periods τ1,…,τP\tau_1, \ldots, \tau_P are present (daily, weekly, yearly) the joint basis is a direct sum:

Φ(t)=⨁p=1P{cos⁡(2πht/τp), sin⁡(2πht/τp)}h=1Hp,\Phi(t) = \bigoplus_{p=1}^P \bigl\{\cos(2\pi h t / \tau_p),\ \sin(2\pi h t / \tau_p)\bigr\}_{h=1}^{H_p},

of dimension ∑p2Hp\sum_p 2 H_p. pyrox.nn.SeasonalFeatures(periods=(τ_1, …, τ_P), harmonics=(H_1, …, H_P)) is exactly this Φ. No constant column — add a separate intercept if you want one.

Once f≈Φ(t) βf \approx \Phi(t)\, \beta is linear in β∈R2∑Hp\beta \in \mathbb{R}^{2 \sum H_p}, OLS recovers β in closed form: β^=(Φ⊤Φ)−1Φ⊤y\hat\beta = (\Phi^\top \Phi)^{-1} \Phi^\top y. No deep learning required for the periodic part of the signal.

Dyadic Fourier features for unknown frequencies

When the period is not known, pyrox.nn.FourierFeatures evaluates dyadic frequencies

ϕdcos⁡(t)=cos⁡(2π⋅2d⋅t),ϕdsin⁡(t)=sin⁡(2π⋅2d⋅t),d=0,1,…,D−1,\phi_d^{\cos}(t) = \cos(2\pi \cdot 2^d \cdot t), \qquad \phi_d^{\sin}(t) = \sin(2\pi \cdot 2^d \cdot t), \quad d = 0, 1, \ldots, D - 1,

i.e. frequencies {1,2,4,8,…,2D−1}\{1, 2, 4, 8, \ldots, 2^{D-1}\} — geometrically spaced rather than linearly spaced. This is the deterministic cousin of random Fourier features (Rahimi & Recht 2007): the geometric spacing covers many decades of frequency with DD features instead of needing O(2D)\mathcal{O}(2^D) linear-spaced ones. Using rescale=True divides each pair (cos⁡d,sin⁡d)(\cos_d, \sin_d) by d+1d + 1, giving a 1/k1/k-prior on frequency that biases the model toward smoother solutions.

Demo

We synthesize a series with two known periods (daily τ1=24\tau_1 = 24 and weekly τ2=168\tau_2 = 168, in hours) and a small linear trend,

y(t)=1.5sin⁡(2πt/24)+0.5cos⁡(4πt/24)+0.8sin⁡(2πt/168)+0.005 t+ε,ε∼N(0,0.01),y(t) = 1.5\sin(2\pi t / 24) + 0.5\cos(4\pi t / 24) + 0.8\sin(2\pi t / 168) + 0.005\, t + \varepsilon,\qquad \varepsilon \sim \mathcal{N}(0, 0.01),

and fit it with ordinary least squares on a Φ(t)\Phi(t) design matrix that has H1=2H_1 = 2 harmonics for the daily cycle, H2=1H_2 = 1 for the weekly cycle, plus an intercept and a trend column. Six seasonal features + 2 trend columns = an 8-dim linear model.

Seasonal feature matrix shape: (1000, 6)
In-sample R²: 0.9955
<Figure size 1800x500 with 2 Axes>

Six features explain the signal almost completely (high R2R^2). The feature heatmap shows the cos/sin pairs at the two daily harmonics and the single weekly cycle.

Pure-JAX helpers

The same encoders are exposed as pure functions in pyrox.nn (no module state, no NumPyro registration) for callers that need to compose them inside a lax.scan or a custom guide. They are what the layers above wrap internally.

Layer output and pure-function output match — they're literally the same math.

3. Pandas-side fit_spatiotemporal

Real workflows live in DataFrames. pyrox.preprocessing.fit_spatiotemporal builds a single immutable SpatiotemporalFit bundle from a DataFrame in one call. The bundle holds:

  • the standardization layer:

    x~n,j=xn,j−μjσj,μj=1N∑nxn,j,σj2=1N∑n(xn,j−μj)2,\tilde x_{n, j} = \frac{x_{n, j} - \mu_j}{\sigma_j}, \qquad \mu_j = \tfrac{1}{N}\sum_n x_{n, j},\quad \sigma_j^2 = \tfrac{1}{N}\sum_n (x_{n, j} - \mu_j)^2,
  • the requested FourierFeatures, SeasonalFeatures, and InteractionFeatures layers,

  • the time-axis constants (tmin⁡,s)(t_{\min}, s) used by encode_time_column:

    t~n=s⋅(tn−tmin⁡),\tilde t_n = s \cdot (t_n - t_{\min}),

    where s=1s = 1 for integer time and s=1/(ns per unit)s = 1/(\text{ns per unit}) for datetime time so that t~\tilde t is in the unit you specified (freq="H" ⇒ hours, etc.).

The crucial invariant: at test time, re-use the same μ,σ,tmin⁡,s\mu, \sigma, t_{\min}, s that the training-time fit produced. Re-fitting them on the test split would silently shift the encoding and corrupt the predictions. SpatiotemporalFit is equinox-immutable so the constants cannot be accidentally re-fit.

Loading...
time_min = 473352.0
time_scale = 2.777777777777778e-13
feature_cols = ('t', 'lon', 'lat')

Encode a held-out hour using the stored time_min so train and test align.

First train rows (hours since fit.time_min): [0. 1. 2.]
Held-out test rows (hours since fit.time_min): [475. 476. 477. 478. 479.]
Block widths — standardize:3 fourier:16 seasonal:10 interactions:1

That SpatiotemporalFit is the building block that the BNFEstimator family (pyrox.api.BNFEstimator) consumes — but the bundle is useful on its own when you want to drive a custom model with the same standardize / Fourier / seasonal / interaction stack.

Takeaways

  • Geographic encoders absorb the chart non-injectivity of (λ,ϕ)(\lambda, \phi): the date-line equivalence and the polar singularity disappear once you compose with Φ:(λ,ϕ)↦(cos⁡ϕcos⁡λ,cos⁡ϕsin⁡λ,sin⁡ϕ)\Phi : (\lambda, \phi) \mapsto (\cos\phi\cos\lambda, \cos\phi\sin\lambda, \sin\phi). Real spherical harmonics {Yℓm}ℓ≤ℓmax\{Y_\ell^m\}_{\ell \le \ell_\text{max}} on top give a dimension-(ℓmax+1)2(\ell_\text{max} + 1)^2 orthonormal basis of L2(S2)L^2(S^2), so a linear head suffices for any band-limited spherical signal.
  • Seasonal / Fourier features reduce regression on a periodic time axis to OLS on a truncated Fourier basis. Use SeasonalFeatures(periods=…, harmonics=…) when the periodicities are known (the basis is exactly the truncated Fourier series on R/τZ\mathbb{R}/\tau\mathbb{Z}); use FourierFeatures(degrees=D) for dyadic frequencies {1,2,…,2D−1}\{1, 2, \ldots, 2^{D-1}\} when they are not.
  • fit_spatiotemporal keeps pandas at the boundary and freezes (μj,σj,tmin⁡,s)(\mu_j, \sigma_j, t_{\min}, s) at training time as a single immutable SpatiotemporalFit of pure JAX layers + scalars. Re-use the same bundle at predict time — re-fitting any of those constants on the test split silently shifts the encoding and corrupts predictions.