Operators & Pipelines — a keras-style tour¶
This notebook walks through the core composition primitives in xrtoolz —
Operator, the | pipe, Sequential, two-input metric Operators, and a
user-defined plot Operator — using the small air_temperature tutorial
dataset that ships with xarray.
The keras analogy. If you've used keras, the mapping is:
| keras | xrtoolz |
|---|---|
Layer |
Operator |
Sequential |
Sequential |
| Functional API | Graph (teaser at the end) |
model.get_config() |
op.get_config() |
| metric / loss | two-input Operator |
Lambda / callback |
pass-through Operator |
Setup¶
Colab-friendly install + imports.
import subprocess
import sys
try:
import google.colab # noqa: F401
IN_COLAB = True
except ImportError:
IN_COLAB = False
if IN_COLAB:
subprocess.run(
[sys.executable, "-m", "pip", "install", "-q", "xrtoolz", "pooch", "netCDF4"],
check=True,
)
import json
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from pipekit import Sequential, Signature
from xrops import Operator
from xrops.anomaly import AddClimatology, CalculateClimatology, RemoveClimatology
from xrops.cf import ValidateCoords
from xrops.geom import SubsetBBox, SubsetTime
from xrops.interpolate.operators import ResampleTime
from xrops.metrics import RMSE, Bias, Correlation
plt.rcParams["figure.dpi"] = 100
_ = xr.set_options( # HTML repr, every section collapsed
display_style="html",
display_expand_attrs=False,
display_expand_coords=False,
display_expand_data=False,
display_expand_data_vars=False,
display_expand_groups=False,
display_expand_indexes=False,
)
1. Load a tiny dataset¶
We use xr.tutorial.open_dataset("air_temperature") — NCEP/NCAR 2 m air
temperature on a 2.5° grid, 4× daily for 2013–2014. Pooch caches it to
~/.cache/xarray_tutorial_data after the first download (~4 MB).
ds = xr.tutorial.open_dataset("air_temperature")
ds
<xarray.Dataset> Size: 31MB Dimensions: (time: 2920, lat: 25, lon: 53) Coordinates: (3) Data variables: (1) Attributes: (5)
fig, ax = plt.subplots(figsize=(7, 3))
ds.air.isel(time=0).plot(ax=ax, cmap="RdBu_r")
ax.set_title("air @ first timestep (raw 0-360 deg lon)")
plt.show()
2. An Operator is just a configured callable¶
Like a keras layer, an Operator is an object that holds configuration
and exposes __call__. The same instance can be reused on many datasets.
validate = ValidateCoords()
print(repr(validate))
print("config:", validate.get_config())
ds_clean = validate(ds)
print("lon range after validate:", float(ds_clean.lon.min()), float(ds_clean.lon.max()))
ValidateCoords()
config: {}
lon range after validate: -160.0 -30.0
Note how ValidateCoords flipped the longitudes from [0, 360) to
(-180, 180]. The rest of the notebook works in the validated convention.
3. Composing with |¶
The pipe operator builds a Sequential left-to-right. Each step is a
single-input transform on a Dataset.
preprocess = (
ValidateCoords()
| SubsetBBox(lon_bnds=(-125, -65), lat_bnds=(25, 50)) # CONUS
| ResampleTime(freq="1D", method="mean") # 4×/day → daily
)
print(type(preprocess).__name__)
ds_pre = preprocess(ds)
ds_pre
Sequential
<xarray.Dataset> Size: 2MB Dimensions: (lat: 11, lon: 25, time: 730) Coordinates: (3) Data variables: (1) Attributes: (5)
fig, axes = plt.subplots(1, 2, figsize=(11, 3.2))
ds.air.isel(time=0).plot(ax=axes[0], cmap="RdBu_r")
axes[0].set_title("raw")
ds_pre.air.isel(time=0).plot(ax=axes[1], cmap="RdBu_r")
axes[1].set_title("after preprocess (CONUS, daily)")
plt.tight_layout()
plt.show()
4. Sequential as a "model"¶
Sequential([...]) is the explicit form. It exposes .describe() (a la
model.summary()) and .get_config() for serialization.
pipeline = Sequential(
[
ValidateCoords(),
SubsetBBox(lon_bnds=(-125, -65), lat_bnds=(25, 50)),
ResampleTime(freq="1D", method="mean"),
]
)
print(pipeline.describe())
# Per-step shape inference. describe() shows the plan; summary()
# threads a Signature through each step to show how dims evolve.
input_signature = Signature(dims=tuple(ds.air.sizes.items()), dtype=str(ds.air.dtype))
print()
print(pipeline.summary(input_signature))
Sequential([ ValidateCoords(), SubsetBBox(lon_bnds=[-125, -65], lat_bnds=[25, 50], lon='lon', lat='lat'), ResampleTime(freq='1D', method='mean', time='time', interp_method='linear'), ]) Operator Input sig Output sig ------------------------------------------------------------------------------------------------------------------ [0] ValidateCoords Signature((time=2920, lat=25, lon=53) float64) Signature((time=2920, lat=25, lon=53) float64) [1] SubsetBBox Signature((time=2920, lat=25, lon=53) float64) Signature((time=2920, lat=?, lon=?) float64) [2] ResampleTime Signature((time=2920, lat=?, lon=?) float64) Signature((time=?, lat=?, lon=?) float64)
print(json.dumps(pipeline.get_config(), indent=2))
{
"operators": [
{
"module": "xrops.cf.operators",
"class": "ValidateCoords",
"config": {}
},
{
"module": "xrops.geom.operators",
"class": "SubsetBBox",
"config": {
"lon_bnds": [
-125,
-65
],
"lat_bnds": [
25,
50
],
"lon": "lon",
"lat": "lat"
}
},
{
"module": "xrops.interpolate.operators",
"class": "ResampleTime",
"config": {
"freq": "1D",
"method": "mean",
"time": "time",
"interp_method": "linear"
}
}
]
}
5. Nesting pipelines¶
Because Sequential is itself an Operator, pipelines compose. We split
the data into a 2013 "training" year (used to fit a day-of-year
climatology) and a 2014 "test" year, then build an anomaly pipeline that
stacks the preprocessor with the climatology removal.
train = SubsetTime("2013-01-01", "2013-12-31")(ds_pre)
test = SubsetTime("2014-01-01", "2014-12-31")(ds_pre)
clim = CalculateClimatology(freq="day")(train)
print("climatology dims:", dict(clim.sizes))
anomaly_pipeline = preprocess | RemoveClimatology(clim)
print(anomaly_pipeline.describe())
# Same shape inference for the composed anomaly pipeline.
anomaly_signature = Signature(dims=tuple(ds.air.sizes.items()), dtype=str(ds.air.dtype))
print()
print(anomaly_pipeline.summary(anomaly_signature))
climatology dims: {'dayofyear': 365, 'lat': 11, 'lon': 25}
Sequential([
ValidateCoords(),
SubsetBBox(lon_bnds=[-125, -65], lat_bnds=[25, 50], lon='lon', lat='lat'),
ResampleTime(freq='1D', method='mean', time='time', interp_method='linear'),
RemoveClimatology(climatology='<xr object>', time='time'),
])
Operator Input sig Output sig
---------------------------------------------------------------------------------------------------------------------
[0] ValidateCoords Signature((time=2920, lat=25, lon=53) float64) Signature((time=2920, lat=25, lon=53) float64)
[1] SubsetBBox Signature((time=2920, lat=25, lon=53) float64) Signature((time=2920, lat=?, lon=?) float64)
[2] ResampleTime Signature((time=2920, lat=?, lon=?) float64) Signature((time=?, lat=?, lon=?) float64)
[3] RemoveClimatology Signature((time=?, lat=?, lon=?) float64) Signature((time=?, lat=?, lon=?) float64)
anomalies_2014 = (SubsetTime("2014-01-01", "2014-12-31") | RemoveClimatology(clim))(
ds_pre
)
fig, ax = plt.subplots(figsize=(8, 3.5))
anomalies_2014.air.mean(dim="lon").plot(
ax=ax, x="time", y="lat", cmap="RdBu_r", center=0, robust=True
)
ax.set_title("2014 zonal-mean air-temp anomaly (Hovmöller)")
plt.show()
6. Metric Operators are just two-input Operators¶
xrtoolz ships pixel metrics (MSE, RMSE, MAE, Bias, Correlation,
R2Score, PSDScore) as Operator subclasses. Their _apply takes two
datasets — a prediction and a reference — so the call site looks like
metric(pred, ref) rather than the single-arg pipeline form.
We build a deliberately weak baseline: predict 2014 with the 2013 climatology. (The point is the mechanics, not skill.)
# Build a prediction by adding the climatology onto a zero dataset
# that already has the 2014 time axis.
pred_2014 = AddClimatology(clim)(xr.zeros_like(test))
ref_2014 = test
# Reduce over space → daily RMSE timeseries
rmse_t = RMSE(variable="air", dims=["lat", "lon"])(pred_2014, ref_2014)
# Reduce over time → spatial RMSE map
rmse_map = RMSE(variable="air", dims="time")(pred_2014, ref_2014)
print("daily RMSE timeseries dims:", dict(rmse_t.sizes))
print("spatial RMSE map dims:", dict(rmse_map.sizes))
print("config:", RMSE(variable="air", dims=["lat", "lon"]).get_config())
daily RMSE timeseries dims: {'time': 365}
spatial RMSE map dims: {'lat': 11, 'lon': 25}
config: {'variable': 'air', 'dims': ['lat', 'lon']}
metrics = {
"RMSE": RMSE("air", ["lat", "lon", "time"]),
"Bias": Bias("air", ["lat", "lon", "time"]),
"Corr": Correlation("air", ["lat", "lon", "time"]),
}
scores = {
name: float(np.asarray(op(pred_2014, ref_2014))) for name, op in metrics.items()
}
scores
{'RMSE': 6.026034023457552,
'Bias': -0.27575987546699765,
'Corr': 0.8639636737518275}
fig, ax = plt.subplots(figsize=(8, 3))
rmse_t.plot(ax=ax)
ax.set_ylabel("RMSE [K]")
ax.set_title("Daily spatial RMSE — climatology vs 2014 truth")
plt.show()
7. A plot is also just an Operator¶
Subclassing Operator is the recipe for adding new behavior — exactly the
keras moment when you discover layers are 30 lines. Required parts:
__init__storing config_apply(self, *args)doing the workget_config()returning a JSON-serializable dict
Plot Operators are pass-through: they do a side-effect (render a figure) and return the input unchanged so they slot anywhere inside a chain.
class PlotMap(Operator):
"""Render a 2-D field; return the dataset unchanged."""
def __init__(
self,
variable: str,
*,
cmap: str = "viridis",
title: str | None = None,
):
self.variable = variable
self.cmap = cmap
self.title = title
def _apply(self, ds):
da = ds[self.variable] if isinstance(ds, xr.Dataset) else ds
if "time" in da.dims:
da = da.isel(time=0)
_, ax = plt.subplots(figsize=(6, 3))
da.plot(ax=ax, cmap=self.cmap)
ax.set_title(self.title or self.variable)
plt.show()
return ds
def get_config(self):
return {"variable": self.variable, "cmap": self.cmap, "title": self.title}
Use 1 — drop it into a chain as a debug "print layer". Two figures render in a single pipeline call, before and after the climatology removal.
debug_pipeline = (
SubsetTime("2014-06-01", "2014-08-31")
| PlotMap("air", cmap="RdBu_r", title="raw — JJA 2014, first day")
| RemoveClimatology(clim)
| PlotMap("air", cmap="RdBu_r", title="anomaly — JJA 2014, first day")
)
_ = debug_pipeline(ds_pre)
Use 2 — visualise a metric Operator's output. The spatial RMSE map
from §6 is just another DataArray, so the same PlotMap instance works.
PlotMap(variable="air", cmap="magma", title="spatial RMSE (clim vs 2014)")(rmse_map)
<xarray.DataArray 'air' (lat: 11, lon: 25)> Size: 2kB 4.207 5.909 7.03 7.838 8.687 9.338 9.597 ... 1.756 1.481 1.365 1.348 1.306 1.246 Coordinates: (2) Attributes: (11)
get_config() works the same as the built-in operators.
PlotMap("air", cmap="magma").get_config()
{'variable': 'air', 'cmap': 'magma', 'title': None}
8. Stretch — Graph for branching¶
When the pipeline isn't a straight line — e.g. you want to fan out from a
shared preprocessed input into several heads — switch to the functional
Graph API. Operators are reused; calling them on Nodes builds a DAG
instead of executing eagerly.
from pipekit import Graph, Input
raw = Input(name="raw")
pre_node = preprocess(raw)
test_node = SubsetTime("2014-01-01", "2014-12-31")(pre_node)
anomaly_node = RemoveClimatology(clim)(test_node)
graph = Graph(
inputs={"raw": raw},
outputs={"test": test_node, "anomaly": anomaly_node},
)
results = graph(raw=ds)
print("outputs:", list(results))
print("test air mean:", float(results["test"].air.mean()))
print("anomaly air mean:", float(results["anomaly"].air.mean()))
outputs: ['test', 'anomaly'] test air mean: 286.38612575342466 anomaly air mean: 0.27575987546699765
Wrap-up¶
What we covered, in keras vocabulary:
- Operator — configured callable; same role as a keras
Layer. |— pipe sugar that returns aSequential.Sequential— linear pipeline; itself anOperator, so it nests.get_config()/.describe()— introspection contract every Operator honours, including ones you write.- Two-input Operators — metrics (
RMSE,Bias, ...) take(pred, ref). They have the same config shape as single-input ops. - Pass-through Operators —
PlotMapreturns its input unchanged so it drops into any chain as a debug/visualisation step. Graph— for non-linear pipelines (multiple inputs, fan-out, fan-in).
The recipe for new behavior is always the same: subclass Operator,
implement _apply, return a JSON-serializable get_config(), and you're
automatically composable with everything else in xrtoolz.