Building Temporal Feature Stacks from Image Time Series
Collapse the masked series into percentiles per season, and keep the count of what went into them:
import xarray as xr
masked = ndvi.where(cloud_free) # never reduce unmasked data
season = masked.groupby("time.season")
features = xr.Dataset({
"ndvi_p10": season.quantile(0.10, dim="time"),
"ndvi_p50": season.quantile(0.50, dim="time"),
"ndvi_p90": season.quantile(0.90, dim="time"),
"n_obs": masked.groupby("time.season").count(dim="time"),
})
Percentiles survive residual cloud; means do not. This page belongs to feature engineering for pixel-based models in Raster Machine Learning & Model Inference.
Phenology Is the Strongest Signal in the Data
A single date tells you what a surface looked like on one morning. A year tells you what it does — when it greens up, how long it stays green, whether it is cut twice or grazed continuously. For anything involving vegetation, the temporal shape separates classes that are spectrally identical on any given date.
The engineering problem is that the series is irregular and full of holes. Different pixels have different valid dates, so any feature that assumes an even sampling has to reckon with that first.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
xarray |
>=2023.12 |
Time-indexed reductions and grouping |
rioxarray |
>=0.15 |
Loading the series and writing the feature stack |
dask |
>=2024.1 |
Chunked reduction when the cube exceeds memory |
numpy |
>=1.23 |
Harmonic design matrix and least squares |
pip install "xarray>=2023.12" "rioxarray>=0.15" "dask>=2024.1" "numpy>=1.23"
Complete Working Example
import numpy as np
import xarray as xr
def temporal_features(series: xr.DataArray, *, min_obs: int = 6) -> xr.Dataset:
"""Robust seasonal features from an irregular, masked NDVI series.
series: dims (time, y, x), already masked so invalid observations are NaN.
"""
out = {}
for name, group in series.groupby("time.season"):
q = group.quantile([0.1, 0.5, 0.9], dim="time")
n = group.count(dim="time")
enough = n >= min_obs
out[f"{name}_p10"] = q.sel(quantile=0.1).where(enough).astype("float32")
out[f"{name}_p50"] = q.sel(quantile=0.5).where(enough).astype("float32")
out[f"{name}_p90"] = q.sel(quantile=0.9).where(enough).astype("float32")
out[f"{name}_range"] = (out[f"{name}_p90"] - out[f"{name}_p10"]).astype("float32")
out[f"{name}_n"] = n.astype("float32")
ds = xr.Dataset(out)
# Amplitude and timing across the whole year, independent of season bins
ds["annual_amplitude"] = (series.max("time") - series.min("time")).astype("float32")
ds["peak_doy_sin"], ds["peak_doy_cos"] = _peak_timing(series)
return ds.drop_vars("quantile", errors="ignore")
def _peak_timing(series: xr.DataArray) -> tuple[xr.DataArray, xr.DataArray]:
"""Day-of-year of the maximum, encoded circularly."""
doy = xr.DataArray(series["time"].dt.dayofyear, dims="time")
peak = doy.isel(time=series.fillna(-9).argmax("time"))
angle = 2 * np.pi * peak / 365.25
return np.sin(angle).astype("float32"), np.cos(angle).astype("float32")
Peak timing is encoded circularly for the same reason aspect is: day 360 and day 5 are five days apart on the ground and 355 apart as integers. The fillna(-9) before argmax is a guard — argmax on an all-NaN pixel raises, and those pixels are masked out downstream by the min_obs condition anyway.
Choosing Between Reduction Strategies
The harmonic option deserves a note because it is genuinely better where it applies. Fitting a + b·sin(2πt) + c·cos(2πt) per pixel gives an amplitude and a phase that describe the season in four numbers, and the residual describes how unusual the year was. It needs a reasonably even series, which in practice means a dense archive or an interpolation step first — the techniques in filling gaps in NDVI time series with interpolation apply directly.
Interpolating for percentile features, on the other hand, is actively harmful: it replaces missing observations with a smooth guess, which narrows the distribution and biases the tails inward. The percentiles then describe the interpolation rather than the surface.
Verification
import numpy as np
for name in ds.data_vars:
if name.endswith("_n"):
continue
finite = float(np.isfinite(ds[name]).mean())
print(f"{name:<18} {finite:6.1%} finite")
assert float(ds["DJF_n"].max()) <= len(series.time), "count exceeds available dates"
assert float((ds["JJA_p90"] >= ds["JJA_p10"]).mean(skipna=True)) > 0.999
The ordering assertion is the useful one: a p90 below a p10 means the quantile dimension was selected wrongly, which is easy to do and produces features that look plausible. The finite fractions expose the seasons where cloud left too few observations — in monsoon regions the wet-season features are routinely 40% missing, and the model must be given the count feature so it can discount them rather than treating a noisy percentile as gospel. Where the count falls below the threshold across a whole region rather than scattered pixels, the honest response is to drop that season’s features from the model entirely for that region, because a feature that is absent in half the study area teaches the model to use geography as a proxy for data availability.
Common Errors
ValueError: All-NaN slice encountered
A pixel had no valid observation in a season and a reduction that cannot handle it was used. Guard with the min_obs mask before computing, or use the nan-aware variants throughout.
Features vary with the number of dates in the request
The series was reduced without masking, so cloudy scenes shifted the statistics. Always mask first, using the approach in masking clouds with the Sentinel-2 SCL band.
Memory blows up on a full tile
Percentiles require sorting along time, which Dask cannot do lazily in one pass. Rechunk so each chunk holds the whole time axis for a small spatial block — the guidance in tuning Dask chunk sizes for raster cubes applies directly.
Frequently Asked Questions
Q: Why percentiles rather than means? Because residual cloud survives every mask, and a single bright contaminated observation moves a mean far more than it moves a median or a tenth percentile. Percentiles also describe the shape of the season, which is what separates crop types.
Q: Should I interpolate gaps before computing features? Not for percentile features — they handle gaps natively by ignoring missing dates. Interpolate only when the feature genuinely requires an evenly spaced series, such as a harmonic fit or a rolling window.
Q: How many observations does a pixel need? Enough that the percentile is not the extreme value itself: at least six to eight valid dates per season for a tenth percentile to mean anything. Below that, mask the pixel out rather than letting it contribute a noisy feature.
Q: Should the seasons be calendar quarters or locally defined? Locally defined, wherever the growing season is known. Calendar quarters split a southern-hemisphere growing season across two bins and merge a monsoon onset with the dry period before it. Defining seasons from the local agricultural calendar — or simply from the long-term mean NDVI curve for the region — produces features that line up with what the vegetation is actually doing, and it costs nothing more than a different grouping key.
Related
- Feature Engineering for Pixel-Based Models — where these features join the spectral and terrain stack.
- Temporal Aggregation and Time Series Analysis — the general treatment of reducing image series.
- Creating Monthly NDVI Composites with xarray resample — regular compositing as an alternative reduction.
- Computing Phenology Metrics from NDVI Time Series — the interpretive version of the same reductions.