Reproducibility — pmlab reproduce¶
A result you can't re-run isn't a result.
Every backtest records what it needs to be run again, and pmlab reproduce runs it again
and checks that the same numbers come back. This page is a walkthrough: every python and
bash block below is executed, in order, exactly as written, by the end-to-end test against
the installed wheel (uv run pytest -m e2e).
What a run records¶
| Part | Content | Why |
|---|---|---|
| seeds | python_random, numpy, lightgbm: the value each generator was given |
the part of a result that is chance |
| environment | pmlab, Python, numpy, pandas, lightgbm, scikit-learn, optuna (null if absent), OS, CPU architecture and count |
a library upgrade can change a number without a line of your code changing |
| config | the resolved parameters: model and its LightGBM params with defaults spelled out, stride, min train rows, stake, the full cost model, the full trade policy | a later change of a default must not change what an old record means |
| data | SHA-256 of the panel's content, row count, column list, where it was read from | tells you the data under a path is no longer the data of the run |
| metrics | what the run produced: total_trades, steps, total_pnl, hit_rate, avg_edge |
what a re-run is compared to |
The record is one JSON object (ReproducibilityRecord, schema version 1) stored where runs
are already kept: in the experiment tracker's run file, in champion.json, in the model
registry's archived versions, and in the metrics file pmlab backtest --output writes.
Worked example: run, then reproduce¶
A small synthetic panel (480 rows, 80 decision dates), written to panel.parquet:
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
rows = []
for d in range(80):
day = f"2026-{1 + d // 28:02d}-{d % 28 + 1:02d}"
for k in range(3):
winner = "YES" if rng.random() < 0.5 else "NO"
for label in ("YES", "NO"):
signal = 1.0 if label == winner else -1.0
rows.append(
{
"market_id": f"m{d}_{k}",
"decision_date": day,
"outcome_label": label,
"winning_label": winner,
"market_price": 0.5,
"feature_a": signal * 0.4 + rng.normal(),
"feature_b": rng.normal(),
"feature_c": rng.normal(),
"feature_d": rng.normal(),
}
)
pd.DataFrame(rows).to_parquet("panel.parquet")
print(len(rows), "rows written")
Run the backtest with an explicit seed and a model that actually uses it (bagging and feature
sub-sampling draw random numbers; a model without them would give the same result for any
seed). --runs-dir also logs the run in the experiment tracker, --output writes the metrics
file:
pmlab backtest --plugin demo --panel panel.parquet --stride 10 --min-train-rows 40 \
--policy top-bin-yes --seed 123 \
--lgbm-param bagging_fraction=0.6 --lgbm-param bagging_freq=1 \
--lgbm-param feature_fraction=0.6 \
--runs-dir runs --output metrics.json
Now reproduce it: re-execute from the record alone, in a new process, and compare.
pmlab reproduce metrics.json
Reproducing metrics.json: backtest, plugin=demo, seeds {'python_random': 123, 'numpy': 123, 'lightgbm': 123}, recorded 2026-11-08T04:01:47
Data: matches the record (480 rows, sha256 980d1cc0aa5c)
Reproduction
┏━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┓
┃ Metric ┃ Recorded ┃ Re-run ┃ |diff| ┃ Match ┃
┡━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━┩
│ total_trades │ 21 │ 21 │ 0 │ yes │
│ steps │ 7 │ 7 │ 0 │ yes │
│ total_pnl │ 0.937 │ 0.937 │ 0 │ yes │
│ hit_rate │ 0.5238095238 │ 0.5238095238 │ 0 │ yes │
│ avg_edge │ 0.06744466124 │ 0.06744466124 │ 0 │ yes │
└──────────────┴───────────────┴───────────────┴────────┴───────┘
REPRODUCED: 5 metrics match (rtol=1e-09, atol=1e-12; counts exact).
pmlab reproduce accepts whatever holds the record: the metrics file above, a tracker run id
(or a unique prefix of it, with --runs-dir), a champion.json or the directory holding it,
or a registry version id (with --registry-dir). By run id:
RUN_ID=$(basename runs/demo/*.json .json)
pmlab reproduce "$RUN_ID" --runs-dir runs
When it does not reproduce¶
Exit status: 0 reproduced, 1 it ran but the metrics differ, 2 it could not run (no record, data changed, panel missing).
The data changed: refused¶
The record holds a hash of the panel's content. A re-run on other data is not a reproduction, so it is refused. Here a copy of the panel with one value changed, and a copy of the metrics file claiming an older LightGBM (used below):
import json
import pandas as pd
panel = pd.read_parquet("panel.parquet")
panel.loc[0, "feature_a"] += 0.5
panel.to_parquet("panel_edited.parquet")
record = json.load(open("metrics.json"))
record["reproducibility"]["environment"]["lightgbm"] = "3.3.0"
json.dump(record, open("metrics_older_lightgbm.json", "w"))
pmlab reproduce metrics.json --panel panel_edited.parquet || echo "exit status: $?"
Reproducing metrics.json: backtest, plugin=demo, seeds {...}, recorded 2026-11-08T04:01:47
Cannot reproduce: The data differs from the data this run was recorded on (content hash 980d1cc0aa5c (recorded) vs 40a835b489d8 (now); 480 rows (recorded) vs 480 rows (now)). Refusing to compare metrics across different data; pass --allow-data-mismatch to run anyway.
exit status: 2
--allow-data-mismatch runs it anyway, for when you want to see how far the numbers moved.
The result can then only ever be reported as not reproduced:
pmlab reproduce metrics.json --panel panel_edited.parquet --allow-data-mismatch || echo "exit status: $?"
A library version changed: warned, with the diff¶
A different version is a warning, not a verdict: only the metrics decide. If they come back, the run is reproduced and the drift is shown next to it; if they do not, the drift is the first place to look.
pmlab reproduce metrics_older_lightgbm.json; echo "exit status: $?"
Data: matches the record (480 rows, sha256 980d1cc0aa5c)
Version drift: 1 version(s) differ from the record. A different version can change a result without a change in the code.
Version drift
┏━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━┓
┃ Library ┃ Recorded ┃ Now ┃
┡━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━┩
│ lightgbm │ 3.3.0 │ 4.6.0 │
└──────────┴──────────┴───────┘
... the metrics table, as above ...
REPRODUCED: 5 metrics match (rtol=1e-09, atol=1e-12; counts exact).
exit status: 0
There is nothing to re-run¶
pmlab reproduce 0123abcd --runs-dir runs || echo "exit status: $?"
A run recorded before 0.10.0 (a champion.json or a tracker run with no reproducibility)
says so and says how to get one: re-run the backtest with pmlab backtest ... --output.
Tolerance¶
On the machine and library versions of the original run the metrics come back bit for bit.
The default tolerance is tight, --rtol 1e-9 --atol 1e-12 for floating-point metrics
(total_pnl, hit_rate, avg_edge): it absorbs summation-order differences across CPUs and
builds and nothing a different seed produces (in the example above, seed 124 instead of 123
moves avg_edge from 0.0674 to 0.0986). Counts (total_trades, steps) are always compared exactly. --rtol 0 --atol 0 is
strict.
Champions and the registry¶
pmlab promote-champion records the backtest's reproducibility record in champion.json
("reproducibility"), taken from the --expectation file (which pmlab backtest --output
wrote) or from --reproducibility FILE. Through the API:
ChampionManifest.publish(..., reproducibility=record). ModelRegistry.record() archives it with the
version and rollback() restores it, as it does every other field of the manifest
(docs/registry.md). Old manifests without the key load as before
(manifest.reproducibility is None).
import pandas as pd
from pmlab import HoldoutGateResult, ChampionManifest
from pmlab.reproducibility import BacktestConfig, build_forecaster, load_record, run_backtest
record = load_record("metrics.json")
panel = pd.read_parquet("panel.parquet")
config = BacktestConfig.from_dict(record.config)
trades = run_backtest(panel, config, record.seeds).trades
gate = HoldoutGateResult.evaluate(trades, required_segments=None, min_trades_per_segment=10)
model = build_forecaster(config, record.seeds)
features = [c for c in panel.columns if c.startswith("feature_")]
model.fit(panel[features], (panel["outcome_label"] == panel["winning_label"]).astype(int))
ChampionManifest.publish(
model, gate, "champion", plugin_family="demo", reproducibility=record
)
print("gate:", gate.decision)
The backtest behind the promotion can now be re-run from the champion:
pmlab reproduce champion/champion.json
Library use¶
from pmlab.reproducibility import (
BacktestConfig, capture_backtest, load_record, reproduce, resolve_seeds, run_backtest,
)
from pmlab import LocalJSONTracker
import pandas as pd
panel = pd.read_parquet("panel.parquet")
config = BacktestConfig.resolve(
plugin="demo", stride=10, min_train_rows=40, policy="top-bin-yes",
model_params={"bagging_fraction": 0.6, "bagging_freq": 1},
)
seeds = resolve_seeds(7)
outcome = run_backtest(panel, config, seeds)
record = capture_backtest(panel, config, seeds, outcome.metrics, source="panel.parquet")
tracker = LocalJSONTracker("runs")
with tracker.run("demo", "api-example") as run:
run.log_metrics(outcome.metrics)
run.log_reproducibility(record)
report = reproduce(record)
print(report.status, [d.name for d in report.drift])
reproduce(record, panel=df) takes the data in memory instead of a path. For your own
training loop (not run_backtest), build the record with ReproducibilityRecord.capture(
kind=..., seeds=..., config=..., data=fingerprint_panel(df), metrics=...) and log it: it is
stored and read back like any other, and pmlab reproduce will say clearly that it can only
re-execute backtest runs.
Where randomness is drawn, and how it is seeded¶
| Where | Seeded by | Note |
|---|---|---|
pmlab backtest / run_backtest |
--seed (default 0): python random, numpy global, LightGBM random_state |
one place; a seed in --lgbm-param is refused. The global generators are seeded for the run only and restored after, so your own streams are untouched |
LGBMForecaster used directly |
random_state= if you pass it |
unset, LightGBM uses its own fixed internal seeds: deterministic, but not a value that can be recorded |
SklearnForecaster random_forest / gradient_boosting |
random_state=0 by default (new in 0.10.0) |
previously unseeded: the numpy global state decided, so two runs differed. Pass random_state= to change it |
TunedForecaster |
random_state=0: the Optuna TPE sampler |
unchanged. Record it with resolve_seeds(seed, optuna=tuner.random_state) and the objective with BacktestConfig.resolve(tuning=tuner.tuning_config()) (new in 0.12.0, docs/tuning.md): a tuned run reproduces like any other, and its record says which policy picked the parameters |
| calibrators, conformal | random_state=0: the calibration split |
unchanged |
| bootstrap stability report | seed=0 |
unchanged |
target encoding (kfold, group) |
seed=0 |
unchanged |
Limits¶
- Only
backtestruns can be re-executed from their record; a record of anotherkindis read, stored and shown, andreproducesays it cannot run it. - The record cannot pin
PYTHONHASHSEED. The number of threads is not pinned either (the CPU count is recorded, for information, and never counted as drift). With LightGBM 4.6.0, 1, 2 and 8 threads gave identical probabilities on 300 000 rows x 30 features, but a result that did depend on them would show up as a miss with no version drift; adding--lgbm-param deterministic=true --lgbm-param force_row_wise=trueremoves that source. - A backtest that produces no trades writes nothing (no
--outputfile, no tracked run), as before: there is no result to reproduce. A record whose metrics hold no number cannot be verified, andreproducesays so instead of reporting a vacuous success. - A CSV panel is hashed as
pandas.read_csvreturns it; the same file always hashes the same, a re-exported one may not (float text parsing). - The MLflow tracker stores the record as the artifact
reproducibility.json; download it and pass the file topmlab reproduce.