Skip to content

Example — Cross-package integration

How pipekit-train composes with the rest of the GeoStack — the patterns that emerge once the package is in the workspace.

Train → Cycle (DA)

The strongest integration. Train an emulator with pipekit-train, drop it into a DACycle from pipekit-cycle, run a 30-day operational forecast cycle with daily assimilation.

import pipekit_train as pt
import pipekit_cycle as pc

emulator_op, _ = pt.TrainingLoop(...).run()
emulator_forward = pc.NeuralForward(model_op=emulator_op, dt=86400.0)

# Same DACycle that would have used the expensive forward model.
da_cycle = pc.DACycle(
    forward_model=emulator_forward,
    obs_op=tropomi_obs_op,
    analysis_step=enkf_analysis,
    obs_err_cov=R,
)
trajectory = da_cycle.run(initial_state, observations_30days, n_cycles=30)

Train → Experiment registry → Inference YAML

A "regulatory replay" workflow. The trained model is content-addressed; the inference pipeline pins its hash; the whole pipeline is then a YAML round-trip.

import pipekit as pk
import pipekit_train as pt
import pipekit_experiment as pe

# Train.
registry = pe.LocalModelRegistry(root="./models")
trained_op, artifact = pt.TrainingLoop(
    ...,
    callbacks=(pt.Checkpoint(..., registry=registry),),
).run()
artifact.save("./artifacts/train_v3.json")

# Build the inference pipeline.
inference = pk.Sequential([
    preprocess,
    trained_op,
    postprocess,
])

# Pin the model hash into an InferenceArtifact for byte-identical replay.
inf_artifact = pe.InferenceArtifact(
    pipeline_yaml=pk.dumps(inference),
    pinned_model_hashes={"step[1]": artifact.trained_model_hash},
)
inf_artifact.save("./artifacts/inference_v3.json")

# Months later, on a fresh machine:
loaded = pe.InferenceArtifact.load("./artifacts/inference_v3.json")
models = loaded.reload_models(registry)
# `models["step[1]"]` is byte-identical to `trained_op` above.

Train with VarDA (vardax)

Variational data assimilation networks (VarDANet from vardax) trained as a TrainingLoop. The Equinox adapter's task= argument lets the user bypass the synthesised loss and supply a domain-specific TrainTask directly — vardax.training.train_loss is exactly the right shape.

import pipekit_train as pt
from pipekit_train.adapters.equinox import TrainTask
from vardax.model import VarDANet2D
from vardax.training import train_loss

class VarDATrainTask:
    """Adapts vardax.train_loss to the Equinox TrainTask Protocol."""
    def loss_fn(self, model, batch, key):
        loss = train_loss(model, batch)
        return loss, {"reconstruction_mse": loss}

assert isinstance(VarDATrainTask(), TrainTask)   # structural check

loop = pt.TrainingLoop(
    model_op=pj.JaxModelOp(VarDANet2D(...)),
    dataset=...,
    task=VarDATrainTask(),       # ← overrides the auto-synthesised path
    optimizer_config={"name": "adam", "lr": 1e-3},
    max_steps=50_000,
    backend="equinox",
    ...
)
trained_vardanet, _ = loop.run()

This pattern works for any external Equinox model whose loss has the shape loss_fn(model, batch, key) → (loss, aux) — somax, vardax, pyrox-nn feature extractors, custom equinox models. The adapter sees a TrainTask; pipekit-train sees an Operator.

Train a somax ocean model

Learn ocean model parameters by differentiating through the solver. Same pattern as VarDA — supply a TrainTask that closes over the somax solver call.

import pipekit_train as pt
from pipekit_train.adapters.equinox import TrainTask
import somax

class OceanParamTask:
    """Learn ocean model parameters by differentiating through the solver."""
    def loss_fn(self, model, batch, key):
        trajectory = model.integrate(batch["state0"], t0=0, t1=T, dt=dt)
        loss = jnp.mean((trajectory.ys - batch["observations"])**2)
        return loss, {"mse": loss}

loop = pt.TrainingLoop(
    model_op=pj.JaxModelOp(somax.SomaxModel(...)),
    dataset=...,
    task=OceanParamTask(),
    optimizer_config={"name": "adam", "lr": 1e-3},
    max_steps=5_000,
    backend="equinox",
)
trained_somax, _ = loop.run()

Custom MetricWriter (W&B)

The MetricWriter Protocol is ten lines for any backend tracker:

import wandb

class WandbWriter:
    """MetricWriter implementation for W&B."""

    def __init__(self, project: str, name: str) -> None:
        wandb.init(project=project, name=name)

    def write(self, step: int, metrics: dict[str, float]) -> None:
        wandb.log(metrics, step=step)

    def close(self) -> None:
        wandb.finish()

loop = pt.TrainingLoop(..., metric_writer=WandbWriter("plumes", "v3"))

When pipekit-experiment ships a WandbTracker adapter (planned in its master plan §3.4), the same flow goes via LogToExperiment instead and the W&B artifact URI lands in the TrainingArtifact.

Composition pattern summary

Pattern Components Use case
Direct supervised CatalogDataset + TrainingLoop Cloud mask, super-resolution
Emulator training SimulationDataset(cycle=…) + TrainingLoop Forward-model surrogates
Amortized inference SimulationDataset(obs_op=…) + TrainingLoop SBI, neural posterior, NRE
Pretrain → fine-tune Sequential([TrainingLoop, TrainingLoop]) Self-supervised → supervised
Train → Cycle handoff TrainingLoop → NeuralForward → Cycle Fast operational forecast
Train → DA handoff TrainingLoop → NeuralForward → DACycle ML-augmented data assimilation
Train → Registry → Inference TrainingLoop → ModelRegistry → Sequential Regulatory replay, audit trail
Domain-specific loss TrainingLoop(task=CustomTrainTask()) VarDA, somax, pyrox-nn
Hyperparameter sweep (v0.2) HyperSweep + LogToExperiment + Optuna Architecture search