Reproducible Pipelines

A reproducible pipeline preserves the information needed to rerun data preparation, fitting, and evaluation. Exact output bytes, agreement within a numerical tolerance, stable decisions, and replication of a statistical conclusion are different targets. Choose the target before deciding which differences count as a failure.

Floating point is not associative

import numpy as np

g = np.random.default_rng(0)
x = g.normal(size=1_000_000).astype(np.float32)
orders = {"as generated": x, "reversed": x[::-1],
          "sorted": np.sort(x), "shuffled": g.permutation(x)}
sums = {k: float(np.add.reduce(v, dtype=np.float32)) for k, v in orders.items()}
for k, v in sums.items():
    print(f"{k:14s} {v!r}")
print(f"spread between orderings: {max(sums.values()) - min(sums.values()):.6e}")
print(f"float64 reference:        {np.add.reduce(x, dtype=np.float64):.10f}")
# as generated   998.5707397460938
# reversed       998.5708618164062
# sorted         998.5911865234375
# shuffled       998.5706176757812
# spread between orderings: 2.056885e-02
# float64 reference:        998.5706627697

The four float32 reductions differ in this run. Float32 represents a limited set of numbers, so intermediate additions round; changing their grouping can change the result. For a smaller float32 example, \((10^8-10^8)+1=1\) versus \(10^8+(-10^8+1)=0\). NumPy can use partial pairwise summation, so the sorted-array result should not be explained as if it necessarily used one left-to-right running total. The float64 reduction is a higher-precision reference for these stored float32 inputs, not an exact sum of the original unrounded draws.

Changing reduction order can change the result, but need not do so. Threading, chunking, row order, library kernels, and hardware can affect numerical operations. A random seed controls a random-number stream; it does not fix all of these choices.

import numpy as np
from threadpoolctl import threadpool_info, threadpool_limits

g = np.random.default_rng(0)
A = g.normal(size=(1000, 1000)); B = g.normal(size=(1000, 1000))
reference = None
for requested in (1, 2, 4):
    with threadpool_limits(limits=requested, user_api="blas"):
        pools = [p for p in threadpool_info() if p["user_api"] == "blas"]
        if not pools:
            raise RuntimeError("No supported BLAS pool found; thread control was not verified")
        result = A @ B
        if reference is None:
            reference = result.copy()
        print("requested", requested, "reported", [p["num_threads"] for p in pools])
        print("same values", np.array_equal(reference, result),
              "max difference", f"{np.max(np.abs(reference-result)):.3e}")
# requested 1 reported [1]
# same values True max difference 0.000e+00
# requested 2 reported [2]
# same values True max difference 0.000e+00
# requested 4 reported [4]
# same values False max difference 1.066e-13

This block actually changes the supported BLAS pool limit and reports it; the earlier summation block does not use BLAS matrix multiplication. A reported limit is not proof that every operation used that many workers. Results can be identical across limits on some builds. OMP_NUM_THREADS is not a universal BLAS control: OpenBLAS, MKL, and other backends have their own controls, often read at initialization. Record the active runtime as well as requested settings. In this run, one and two threads agree, while four differ by at most about 1.07e-13. This experiment’s numerical outputs are specific to the installed backend; another build can produce a different comparison.

Choose tolerances in terms of the quantity and decision that matter. A tiny change can flip a threshold decision when a score is sufficiently close to its cutoff; bitwise agreement can also be useful for debugging. Parallel execution is not inherently nondeterministic, and serial execution alone does not ensure agreement across platforms.

Reproducible rankings need an explicit tie-break

Even unchanged scores can produce a different selected set if ties have no stable secondary key. This example scores the training rows deliberately to create many ties; it is not a generalization evaluation.

from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier

X, y = make_classification(n_samples=40000, n_features=20, n_informative=8,
                           class_sep=0.6, random_state=0)
p = RandomForestClassifier(200, random_state=0).fit(X, y).predict_proba(X)[:, 1]
print(f"distinct probability values observed: {len(np.unique(p))}")
print(f"rows sharing the maximum value {p.max():.4f}: {np.sum(p == p.max())}")

print(f"{'k':>7} {'top-k overlap after reordering the rows':>40}")
for k in (10, 100, 1000, 5000):
    base = set(np.argsort(-p, kind="stable")[:k])
    ov = []
    for rep in range(20):
        perm = np.random.default_rng(rep).permutation(len(p))
        top = perm[np.argsort(-p[perm], kind="stable")[:k]]         # same rows, different arrival order
        ov.append(len(base & set(top)) / k)
    print(f"{k:7d} {np.mean(ov):40.4f}")
# distinct probability values observed: 172
# rows sharing the maximum value 1.0000: 1363
#       k  top-k overlap after reordering the rows
#      10                                   0.0050
#     100                                   0.0725
#    1000                                   0.7353
#    5000                                   0.9341

The distinct-value count describes these predictions, not the full output range of every 200-tree forest. A forest averages tree leaf class proportions; impure leaves can produce more than a simple grid of vote fractions. Here 1,363 rows share the maximum score, and the mean top-10 overlap after reordering is only 0.0050. The stable sort used here deliberately preserves arrival order within each tie. NumPy’s default argsort is not guaranteed to do that. Overlap is the mean fraction of selected IDs retained over 20 permutations, not a probability estimate for every possible deployment ordering.

Sort by descending score and then an immutable, unique record ID. The IDs below represent identities assigned before reordering; regenerating them from each incoming row position would defeat the fix. Define policies for duplicate IDs and nonfinite scores. A deterministic tie-break makes a decision repeatable, but does not by itself make the tie policy appropriate.

ids = np.arange(len(p))
assert np.unique(ids).size == ids.size and np.isfinite(p).all()
for k in (10, 100, 1000, 5000):
    baseline = ids[np.lexsort((ids, -p))[:k]]
    overlaps = []
    for rep in range(20):
        perm = np.random.default_rng(rep).permutation(len(p))
        selected = ids[perm][np.lexsort((ids[perm], -p[perm]))[:k]]
        assert np.array_equal(baseline, selected)
        overlaps.append(len(set(baseline) & set(selected)) / k)
    print(k, f"stable-ID overlap {np.mean(overlaps):.4f}")
# 10 stable-ID overlap 1.0000
# 100 stable-ID overlap 1.0000
# 1000 stable-ID overlap 1.0000
# 5000 stable-ID overlap 1.0000
GoalWhat it requiresWorth it when
bit-identical outputcontrolled numerical kernels, runtime, inputs and ordering; verified by comparisonexact regression checks or debugging
a stable reported numberdocumented evaluation procedure, repeated-run spread and agreed tolerancescomparing performance across repeated evaluations
someone else can reproduce itpinned versions, data snapshot, the code that ransharing or maintaining a result
deterministic decisionsstable scores, explicit tie-breaks and persistent unique IDsany ranked or top-k output

Save the preprocessing and the prediction rule together

A pipeline chains operations in their execution order. Here missing values are filled with training medians, features are standardized using training statistics, and a logistic model is fitted. Calling predict_proba reuses those fitted transformations. Split the raw rows first; during cross-validation, fit the entire pipeline within each training fold. A pipeline cannot repair a split that already leaks people, time, or future information.

import json, hashlib, tempfile
from pathlib import Path
import joblib
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.datasets import make_classification

Xrun, yrun = make_classification(n_samples=1000, n_features=10, random_state=17)
Xrun[::17, 0] = np.nan
train_ids, test_ids = train_test_split(np.arange(len(yrun)), test_size=.3,
                                      stratify=yrun, random_state=23)
config = {"data_seed": 17, "split_seed": 23, "C": 1.0, "solver": "lbfgs", "max_iter": 1000}
pipe = make_pipeline(SimpleImputer(strategy="median"), StandardScaler(),
                     LogisticRegression(C=config["C"], solver=config["solver"], max_iter=config["max_iter"]))
pipe.fit(Xrun[train_ids], yrun[train_ids])
expected = pipe.predict_proba(Xrun[test_ids])
with tempfile.TemporaryDirectory() as directory:
    root = Path(directory)
    np.savez(root / "data.npz", X=Xrun, y=yrun, train_ids=train_ids, test_ids=test_ids)
    joblib.dump(pipe, root / "pipeline.joblib")
    (root / "config.json").write_text(json.dumps(config, sort_keys=True))
    names = ["data.npz", "pipeline.joblib", "config.json"]
    hashes = {name: hashlib.sha256((root/name).read_bytes()).hexdigest() for name in names}
    assert all(hashlib.sha256((root/name).read_bytes()).hexdigest() == h for name,h in hashes.items())
    with np.load(root / "data.npz") as saved:
        restored = joblib.load(root / "pipeline.joblib")
        actual = restored.predict_proba(saved["X"][saved["test_ids"]])
    print("restored probabilities match", np.array_equal(expected, actual))
    print("saved artifacts", sorted(hashes))
print("training-only median", np.isclose(pipe[0].statistics_[0], np.nanmedian(Xrun[train_ids, 0])))
# restored probabilities match True
# saved artifacts ['config.json', 'data.npz', 'pipeline.joblib']
# training-only median True

The snapshot stores the row order and exact split IDs, while the saved pipeline includes the fitted imputer, scaler, and classifier. This example restores predictions in the same process and environment; it does not prove independent retraining or cross-version compatibility. The temporary directory is deleted when the block finishes. Use a retained, versioned run directory in a real project. Load joblib artifacts only from trusted sources, and retain the compatible environment needed to read them.

A handoff also needs the runnable entry point, source revision and any uncommitted changes, feature names and order, resolved training configuration, metric and threshold definitions, data provenance and snapshot location, and an environment specification. A hash can verify an available artifact but cannot recover a missing one. Seeds alone do not preserve a split if the input rows change order; stable record IDs or the saved split are needed. For a fresh-process check, load the bundle without relying on notebook state, then compare agreed outputs.

References


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.