The Pipeline class in this toolkit is an in-process orchestrator: it runs
your steps in one Python process, in dependency order, right now. That is exactly
what you want for development, CI, batch jobs that fit in memory, and embedding a
pipeline inside a larger service. It is *not* a scheduler, a distributed executor,
or a UI.
This guide compares three ways to run the same Step graph and shows how to move
between them with minimal rewrites.
3. Option A: the bare Pipeline
6. Keeping step logic portable
Does the whole job fit in one process and finish in a single run?
|
+-- YES: Do you need scheduling, retries across machines, or a UI?
| |
| +-- NO -> Option A: bare Pipeline (this toolkit). Stop here.
| +-- YES -> Is your team already on a scheduler?
| |
| +-- Airflow shop -> Option B
| +-- Greenfield / Pythonic / dynamic -> Option C (Prefect)
|
+-- NO (data too big / steps need different machines / long-running):
Use a distributed engine (Spark, Ray, Dask) for the heavy step and
orchestrate the stages with Airflow or Prefect. The bare Pipeline can
still run *inside* a single task.
The honest default for most tabular problems is Option A. Reach for B or C
only when you actually need scheduling, cross-process retries, backfills, or a
control-plane UI -- not before.
| Concern | Bare Pipeline | Airflow | Prefect |
|---|---|---|---|
| Execution model | In-process, synchronous | Distributed workers | Local or distributed workers |
| Scheduling | None (you trigger it) | Cron / data-interval scheduler | Schedules + event triggers |
| Retries | Manual / fail_fast | Per-task retries + backoff | Per-task retries + backoff |
| UI | Console / logs | Rich web UI | Cloud / OSS UI |
| Dynamic graphs | Trivial (it's Python) | Limited (TaskFlow / mapping) | First-class (native Python) |
| Infra to operate | None | Scheduler + DB + workers | Server/agent (or Prefect Cloud) |
| Local dev / unit tests | Instant | Heavier | Light |
| Best fit | Dev, CI, embedded batch | Scheduled enterprise data ops | Pythonic modern workflows |
No extra infrastructure -- import, build, run.
from ml_pipeline import build_standard_pipeline, load_config
pipeline = build_standard_pipeline(load_config("configs/pipeline.yaml"))
run = pipeline.run() # executes ingest -> preprocess -> train -> evaluate
if not run.succeeded:
raise SystemExit(run.report())
print(run["evaluate"].to_markdown())Run it on a schedule with anything that can launch a process -- cron, a CI job, a
systemd timer, a Kubernetes CronJob:
# Retrain every night at 02:00 and write the evaluation report.
0 2 * * * cd /opt/ml && /opt/ml/.venv/bin/python examples/run_pipeline.py --config configs/pipeline.yamlUse when: the job fits in memory, runs start-to-finish, and you do not need a
scheduler/UI. Outgrow it when: you need cross-machine retries, backfills, SLAs,
or a control plane.
Airflow is a mature, battle-tested scheduler. Map each Step to an Airflow task
so Airflow owns scheduling, retries, and observability while your step logic stays
unchanged.
# dags/ml_pipeline_dag.py
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator
from ml_pipeline import load_config
from ml_pipeline.steps import (
IngestionStep, PreprocessingStep, TrainingStep, EvaluationStep,
)
CONFIG = load_config("/opt/airflow/configs/pipeline.yaml")
def run_ingest(**ctx):
ds = IngestionStep("ingest", **CONFIG["ingestion"]).run({})
ctx["ti"].xcom_push(key="dataset", value=ds) # small data only; see note
with DAG(
"ml_pipeline",
start_date=datetime(2024, 1, 1),
schedule="0 2 * * *",
catchup=False,
default_args={"retries": 2},
) as dag:
ingest = PythonOperator(task_id="ingest", python_callable=run_ingest)
# ... define preprocess / train / evaluate similarly ...
ingest >> preprocess >> train >> evaluateXCom note: Airflow passes data between tasks via XCom, which is sized for
small metadata, not DataFrames or model objects. In practice each task should
persist its real output to shared storage (S3/GCS/a database) and push only the
*path* through XCom. The Dataset / PreprocessedData / TrainedModel objects
make natural artifacts to serialize at task boundaries.
Strengths: robust scheduler, backfills, mature UI, huge operator ecosystem.
Costs: you run a scheduler, a metadata DB, and workers; dynamic graphs are
more awkward than in plain Python.
Prefect feels like decorating normal Python, which pairs naturally with this
toolkit's plain-callable steps.
# flows/ml_pipeline_flow.py
from prefect import flow, task
from ml_pipeline import load_config
from ml_pipeline.steps import (
IngestionStep, PreprocessingStep, TrainingStep, EvaluationStep,
)
CONFIG = load_config("configs/pipeline.yaml")
@task(retries=2, retry_delay_seconds=30)
def ingest():
return IngestionStep("ingest", **CONFIG["ingestion"]).run({})
@task
def preprocess(dataset):
return PreprocessingStep("preprocess", dataset_step="ingest",
**CONFIG["preprocessing"]).run({"ingest": dataset})
@task
def train(pre):
return TrainingStep("train", features_step="preprocess").run({"preprocess": pre})
@task
def evaluate(model, pre):
return EvaluationStep("evaluate", model_step="train",
features_step="preprocess").run({"train": model, "preprocess": pre})
@flow(name="ml-pipeline")
def ml_pipeline():
ds = ingest()
pre = preprocess(ds)
model = train(pre)
return evaluate(model, pre)
if __name__ == "__main__":
ml_pipeline()Because the flow is ordinary Python, Prefect infers the dependency graph from how
the task results are passed -- and the data is passed in memory between tasks, so
there is no XCom-style size limit when running in a single environment.
Strengths: Pythonic, first-class dynamic graphs, schedules and event triggers,
gentle local-to-cloud path. Costs: a newer ecosystem; distributed runs still
need workers/agents.
The single most important rule: **put real work in Step subclasses, keep the
orchestrator thin.** Every option above calls the *same* Step.run(context). If
your logic lives in steps (not in Airflow operators or Prefect tasks), switching
orchestrators is glue code, not a rewrite.
Concretely:
context and return a plain, serializable-ishobject. They should not call print for results, reach for globals, or assume
a particular scheduler.
the same class works from YAML, Airflow, or Prefect.
output to shared storage and pass references, not the objects themselves.
When you graduate from the bare Pipeline to Airflow or Prefect:
joblibor pickle for fitted models/transformers).
boundary that crosses a process or machine.
retries=...) andstop relying on fail_fast alone.
configs/pipeline.yaml path.
ingest/train should overwrite, not append.tests/test_pipeline.py green -- the bare Pipeline remains thefastest way to unit-test step wiring before it ever reaches a scheduler.
This guide explains how the templates are structured, why they are split the way
they are, and how to extend them. Read it once and you will be able to add a new
stage, swap a model, or wire the pipeline into a larger system with confidence.
6. Data flow through the context
7. Why a DAG instead of a linear script
9. Design decisions and trade-offs
The package is deliberately split into two layers with different dependency
profiles:
+--------------------------------------------------------------+
| Orchestration core (pure standard library) |
| pipeline.py - Pipeline, PipelineRun, topological_order |
| steps.py - Step, FunctionStep, StepResult, StepStatus |
+--------------------------------------------------------------+
| ML stages (pandas / numpy, scikit-learn optional) |
| ingestion.py - Dataset + load_csv / parquet / sql |
| preprocessing.py - impute / scale / encode + split |
| training.py - cross-validation + estimators |
| evaluation.py - metrics + EvaluationReport |
+--------------------------------------------------------------+
The core never imports pandas or scikit-learn. That keeps it importable
anywhere, makes the orchestrator unit-testable in milliseconds, and means you can
reuse Pipeline for non-ML work too. The ML stages are imported lazily (see
__init__.py), so import ml_pipeline stays cheap until you actually touch a
stage.
A step is the atomic unit of work. Every step has three things:
run(context) method that does the work.from ml_pipeline import Step
class NormalizeStep(Step):
def __init__(self, name, source):
super().__init__(name, inputs=[source])
self.source = source
def run(self, context):
data = self.require(context, self.source) # safe dependency fetch
return [x / max(data) for x in data]require() fetches a dependency's output from the context and raises a clear
StepError if it is missing -- you never index a raw dict and get a cryptic
KeyError.
For quick, functional steps there is FunctionStep, which adapts a plain
callable. By default it passes each dependency's output as a positional argument:
from ml_pipeline import FunctionStep
load = FunctionStep("load", lambda: [1, 2, 3]) # load()
total = FunctionStep("total", sum, inputs=["load"]) # sum(load_output)Pass pass_context=True if your function would rather receive the whole context
mapping and read several upstream outputs by name.
Pipeline takes a set of steps and runs them. On run() it:
1. Validates the graph -- duplicate names, dangling dependencies, and cycles
all raise specific PipelineError subclasses *before* anything executes.
2. Topologically sorts the steps with Kahn's algorithm. Among steps that are
simultaneously ready, the lexicographically smallest name runs first, so the
order is deterministic and easy to assert in tests.
3. Executes each step in order, threading a shared context dict (step name
-> output) through them, timing each one, and capturing failures.
The result is a PipelineRun:
run = pipeline.run()
run.succeeded # bool: did every step succeed?
run.order # ['ingest', 'preprocess', 'train', 'evaluate']
run["evaluate"] # the evaluate step's output (an EvaluationReport)
print(run.report()) # a per-step status / timing summaryWith fail_fast=True (the default) the first failure stops the run and the
remaining steps are marked SKIPPED; with fail_fast=False independent branches
keep running.
The bundled steps mirror the canonical supervised-learning lifecycle:
| Step | Class | Input(s) | Output |
|---|---|---|---|
ingest | IngestionStep | -- | Dataset |
preprocess | PreprocessingStep | ingest | PreprocessedData |
train | TrainingStep | preprocess | TrainedModel |
evaluate | EvaluationStep | train, preprocess | EvaluationReport |
Note that evaluate depends on both train (for the model) and
preprocess (for the held-out test matrix). That makes the graph a small diamond
rather than a straight line:
ingest
|
preprocess
/ \
train \
\ /
evaluate
build_standard_pipeline(config) constructs exactly this graph from a config
dict (see configs/pipeline.yaml).
Every loader returns a Dataset -- a thin wrapper around a pandas DataFrame plus
the name of the target column. Downstream stages depend on Dataset, never on a
file path, so adding a new source means writing one load_* function:
from ml_pipeline import load_dataset
ds = load_dataset("data/train.csv", target="label") # csv (suffix inferred)
ds = load_dataset("data/train.parquet", target="label") # parquet
ds = load_dataset("SELECT * FROM t", fmt="sql",
connection=conn, target="label") # sql
ds.features # DataFrame without the target
ds.y # the target Series (or None)
ds.feature_names # column names excluding the targetOutputs are passed implicitly through the run context, keyed by step name:
ingest --> context["ingest"] = Dataset
preprocess --> context["preprocess"] = PreprocessedData(X_train, X_test, y_train, y_test, ...)
train --> context["train"] = TrainedModel(estimator, cv, ...)
evaluate --> context["evaluate"] = EvaluationReport(task, metrics, ...)
The feature transformer is fit on the training split only and then applied to
the test split, so test-set statistics never leak into training. Unknown
categories that appear only at test time map to an all-zero one-hot segment.
A flat script (load(); clean(); train(); score()) works until it doesn't:
Modelling the pipeline as a DAG gives you explicit dependencies, deterministic
ordering, per-step status/timing, validation before execution, and a structure
that maps one-to-one onto Airflow/Prefect tasks when you outgrow a single
process (see orchestration-options.md).
Add a feature-engineering stage between preprocess and train by subclassing
Step, reading context["preprocess"], returning a modified PreprocessedData,
and pointing train at your new step instead.
Swap the model by changing one config line -- a built-in baseline
(ridge/mean/majority) or any scikit-learn estimator via a dotted path:
training:
estimator: "sklearn:sklearn.ensemble.GradientBoostingRegressor"
params: { n_estimators: 200, max_depth: 3 }Reuse the orchestrator for non-ML work -- ETL, report generation, anything
with dependencies -- since Pipeline/Step know nothing about ML.
cost is that ML helpers live in separate modules rather than on Pipeline.
cross_validate takes a zero-argcallable so each fold gets a fresh model without needing sklearn.clone; this
is what makes the CV utility backend-agnostic.
scikit-learn installed (and the test suite proves it). scikit-learn remains the
recommended production backend and is used automatically when present.
simple, debuggable, and ideal for datasets that fit in memory. For
distributed/scheduled execution, wrap the steps in Airflow or Prefect rather
than reinventing a scheduler here.