Unit 1 · The Quant Landscape — Session 05 of 05

Set Up Your Quant Lab

Your laptop is about to become a research lab, and a lab is only as good as its ability to repeat an experiment. This session builds the workbench every later lab runs on — uv, Python 3.12, Jupyter, DuckDB, the quantnb package, a research repository and a data cache — and shows why each piece exists: a backtest you cannot rerun to the same number is an anecdote, and a research log full of anecdotes proves nothing. The core move: pin the four things that decide your numbers — code, environment, data and randomness — so anyone, including future you, gets the same answer.

DURATION 1.5 h + lab LEVEL Foundations TRACKS QR · QT · QD · IND HANDS-ON Prove your lab reproduces

1.5.1 Reproducibility is a research requirement

Session 1.3 asked you to log every trial and pre-register every test. Both are worthless if running the same trial twice can give two different numbers — so before any research, you build a workbench where it can't.

Scientists separate two ideas that everyday speech blurs. A result is reproducible when the same input data, code and conditions of analysis give consistent results; it is replicable when a new study, with its own data, reaches the same conclusion (National Academies, 2019). Most of this course is about replicability — whether an edge survives data it has never seen, which is what out-of-sample testing and all of Unit 6 are for. Reproducibility is the humbler precondition. It is an engineering problem, so it is yours to solve on day one.

Think of any research number as the output of a function:

result=f(code, environment, data, seed)\text{result} = f(\text{code},\ \text{environment},\ \text{data},\ \text{seed})

Pin all four inputs and ff behaves like a pure function: anyone, on any machine, next year, gets the number you logged. Leave one loose and your "result" is a random variable wearing a number's clothes.

A RESULT IS A FUNCTION OF FOUR INPUTS — pin all four LAYER PINNED BY LEAVE IT LOOSE AND… 1 · CODE notebooks, package, config git commit its hash in every run manifest which version of the code made this number? 2 · ENVIRONMENT Python + every library .python-version + uv.lock exact pins, committed an upgrade you never noticed moved the result 3 · DATA the exact inputs raw files, never edited + a content fingerprint the source revised history and you can't tell 4 · RANDOMNESS every simulated draw seed → Generator one stream per component a new step shifted every later draw SAME NUMBERS on any machine, today or next year: a trial you can log and re-check Leave any layer loose and your research log records a random variable, not a result.
fig 1.5.1 — the four layers of reproducibility: what pins each one, and what goes wrong when it is left loose

Why is this a research requirement rather than tidiness? Because every tool of Session 1.3 assumes it. A research log entry says "variant 7: Sharpe 1.12"; if rerunning variant 7 gives 0.94, which number was the trial? A pre-registration commits you to one test; if a library upgrade quietly changes how that test computes, you ran a test you never registered. And when a live strategy drifts from its backtest (Unit 15), the first debugging step is to reproduce the backtest's number exactly — you cannot bisect the gap between two numbers when one of them won't sit still.

The evidence says reproducibility fails by default. When Pimentel and colleagues attempted 863,878 executions of Jupyter notebooks from GitHub, only 24.11% ran without errors and only 4.03% produced the same results as the original. Published research does little better: working from the authors' own data and code, Chang and Li reproduced the key conclusions of 22 of 67 macroeconomics papers from 13 well-regarded journals (33%), and 29 of 59 even with the authors' help (49%; the 59 exclude six papers that used confidential data and two that needed software Chang and Li did not have).

📌 Key idea — an unreproducible backtest is a flaky test

Session 1.3 called a backtest a unit test that can pass by luck. An unreproducible backtest is worse: a flaky test, which passes or fails on reruns with no change to the code. You would never ship on a flaky test, so never log a trial on one. Same code, environment, data and seed must give the same numbers — or the number does not go in the log.

🧭 Margin note — you already do this in production

Lockfiles, pinned base images, hermetic builds, configuration in environment variables: production engineering solved reproducibility years ago. Research code skips it because it feels temporary. It isn't. The notebook that found the edge is the specification of the strategy you will trade, and the person who has to reproduce it in six months is you.

1.5.2 uv, Python 3.12 & the lockfile

One tool installs Python, creates the project's virtual environment, resolves every dependency into a lockfile and runs your code inside it — fast enough that you stop avoiding it.

uv, from Astral, replaces the pile of pyenv, venv, pip and pip-tools that Python projects used to need. Three ideas do the work. A virtual environment is a folder of installed packages that belongs to one project (.venv/), so upgrading pandas for one project cannot break another. A lockfile (uv.lock) records the exact version and hash of every package — including the dependencies of your dependencies — for every operating system at once. And uv run executes a command inside the environment after checking that it still matches the lock.

Install uv, then the course

# macOS / Linux
$ curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
PS> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# or a package manager: brew install uv · winget install --id=astral-sh.uv -e · pipx install uv

$ git clone https://github.com/georgevrs/quant-notebook
$ cd quant-notebook
$ uv sync                                        # Python 3.12 + core + dev tools, exactly as locked
$ uv run python labs/u01/s05_quant_lab_setup.py   # this session's lab

You don't need Python installed first. The one-line file .python-version says 3.12, and if no matching interpreter is on your machine, uv downloads a managed one the first time it needs it. You never activate the environment by hand either: prefix commands with uv run. Update uv itself with uv self update (standalone installer) or your package manager.

HOW uv TURNS WHAT YOU ASK FOR INTO WHAT RUNS 1 · YOU ASK FOR pyproject.toml ranges: pandas>=3.0 .python-version: 3.12 2 · LOCKED uv.lock exact versions + hashes every OS · committed 3 · INSTALLED .venv/ the lock, no more, no less (sync is exact) 4 · RUN uv run python … checks lock + env, then runs in .venv uv lock uv sync uv run uv run re-locks and re-syncs first if pyproject.toml changed Commit pyproject.toml, .python-version and uv.lock. Never commit .venv — uv rebuilds it.
fig 1.5.2 — from ranges to a running program: you edit step 1, uv owns steps 2–4

This repository's pyproject.toml asks for Python ≥ 3.12 and the core research stack — numpy, pandas 3, polars, pyarrow, DuckDB, scipy, statsmodels, arch, scikit-learn and matplotlib — plus a dev dependency group (pytest, jupytext, ipykernel, ruff and friends) that uv installs by default. Heavier tools sit in optional extras that you add when a unit needs them:

ExtraAddsFirst needed
portfoliocvxpy, skfolioSession 3.4, then Unit 9
backtest, trackingvectorbt, QuantStats · MLflowSession 6.8
derivQuantLibUnit 10 (derivatives)
mlLightGBM, SHAPUnit 12 (machine learning)
hfthftbacktest, numbaSession 14.5
liveNautilusTrader, alpaca-py, ib_asyncUnit 15 (production systems)
CommandWhat it doesWhen you'll use it
uv syncmakes .venv match uv.lock exactly — installing what's missing, removing what isn't asked forafter cloning; after every git pull
uv sync --extra derivthe same, plus one extra (repeat --extra for more)when a unit needs it
uv sync --all-extrasevery extra at once (large downloads)if you'd rather install once
uv run python …runs inside .venv; locks and syncs first if neededevery time you run code
uv add polarsadds a dependency to pyproject.toml, updates the lock, installs itin your own research repository
uv lock --upgrade-package numpymoves one pin forward, deliberatelywhen you choose to upgrade — then rerun and compare
💡 Tip — sync is exact, so repeat your extras

Because uv sync removes anything the lock doesn't ask for, a plain uv sync after uv sync --extra deriv uninstalls QuantLib. Pass the same --extra flags every time, or use --all-extras. In CI, uv sync --locked fails loudly if pyproject.toml and uv.lock disagree, instead of quietly re-resolving. This course's CI runs exactly that, and sets UV_LOCKED=1 so every later uv run is held to the same rule. (--frozen is the looser cousin: it installs the committed lock as-is without checking it against pyproject.toml.)

1.5.3 The daily loop: notebooks, scripts & Colab

Every lab is a Python file that opens as a notebook. That one trick keeps Jupyter's exploratory feel and gives you code you can diff, review and run from a terminal.

THE DAILY LOOP — small steps, each one rerun and compared 0 · CLONE git clone … once per machine 1 · SYNC uv sync environment = lockfile 2 · RUN uv run python lab.py top to bottom, from fresh 3 · CHANGE ONE THING a parameter, a feature, a rule — never three 4 · RERUN & COMPARE git diff results.json only what you meant moved? 5 · COMMIT .py + results + lock one idea per commit next change Every change stays attributable when each step is small and every run starts fresh. unexpected change? find the loose layer (fig 1.5.1)
fig 1.5.3 — the daily loop: sync, run, change one thing, rerun, compare the recorded numbers, commit

The loop is short on purpose. Sync, run the lab top to bottom, change one thing, rerun and compare. Each lab writes every number its page quotes to a results file — this one to labs/u01/out/s05.results.json — so git diff shows exactly which numbers your change moved. If a number moved that you didn't mean to move, stop: one of the four layers is loose.

Why the .py file is the source of truth

A Jupyter notebook (.ipynb) is a JSON document holding your code and its outputs, execution counters and base64-encoded charts. Change one line and git diff shows a screenful of noise; merge two branches and you get conflicts inside JSON. Jupytext stores a notebook as a plain script instead. In the percent format every lab here uses, # %% starts a code cell and # %% [markdown] starts a text cell:

# %% [markdown]
# ## 2 · Same seed, same numbers

# %%
SEED = 42

a = np.random.default_rng(SEED).standard_normal(3)
b = np.random.default_rng(SEED).standard_normal(3)
c = np.random.default_rng(SEED + 1).standard_normal(3)
THE SAME ONE-LINE CHANGE, AS git diff SEES IT .ipynb — JSON, OUTPUTS INCLUDED - "execution_count": 14, + "execution_count": 3, - "source": ["TRIALS = [1, 10, 100]"] + "source": ["TRIALS = [1, 10, 100, 1000]"] - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoA… + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAqB… - "execution_count": 15, + "execution_count": 4, The real change is one line in eight. …plus every re-rendered chart, as base64 noise. .py (PERCENT FORMAT) — CODE ONLY # %% - TRIALS = [1, 10, 100] + TRIALS = [1, 10, 100, 1000] REPEATS = 200 (outputs are not in the file: running it regenerates them) One line changed; a reviewer sees exactly what. Diffs, reviews and merges work like any other code.
fig 1.5.4 — one edit to the Session 1.3 lab, diffed as a notebook and as a percent-format script

The course's build regenerates each .ipynb from its .py with the outputs stripped, so the notebook is a build artifact — like a compiled binary, never edited by hand. Your research repository can do the same, or skip the .ipynb altogether: VS Code shows Run Cell above every # %% in a .py file, and JupyterLab with jupytext installed opens a .py as a notebook (right-click → Open With → Notebook).

WhereHowVersions come fromUse it for
Terminaluv run python labs/u01/s05_quant_lab_setup.pyuv.lockthe canonical run — CI does exactly this
JupyterLabuv run --with jupyter jupyter labuv.lock, plus a temporary Jupyter serverexploring a lab cell by cell
VS Codeopen the folder, pick the .venv interpreter, click Run Celluv.lock (ipykernel is in the dev group)editing and running in one place
ColabOpen in Colab in the lab strip of every session pageColab's packages plus whatever pip resolves — never the lockfilereading and experimenting with nothing to install
📌 Key idea — the notebook is a view, the script is the source

Explore in whatever interface you like, but the thing you commit, review and rerun is the .py file. A result counts when uv run python your_lab.py reproduces it from a fresh start — not when a kernel that has been alive since Tuesday happens to hold it.

🧭 Margin note — Colab is for reading, local is for results

A lab's first cell installs quantnb with pip into whatever Python and packages Colab ships, so you get a mix of Colab's preinstalled packages and whatever pip resolves that day — pip upgrades anything older than quantnb's minimums, such as pandas 3 — and never uv.lock's versions. The labs run there, but if Colab's NumPy is a different feature release, even seeded draws can differ (§1.5.5). A result you intend to log comes from the locked environment.

1.5.4 A research repository

The course repository is laid out to teach a hundred labs. Your own research needs a layout that keeps raw data sacred, results traceable and secrets out of git.

A RESEARCH REPOSITORY — what goes in git, and what never does PATH WHAT LIVES THERE GIT my-research/ ├─ pyproject.toml, uv.lock the environment, pinned COMMIT uv sync rebuilds it anywhere ├─ .python-version, .gitignore Python 3.12; what git skips COMMIT ├─ .env API keys and their values IGNORE read with os.environ ├─ data/raw/ downloads, never edited IGNORE re-fetch; licences forbid sharing ├─ data/processed/ derived from raw by code IGNORE delete and rebuild any time ├─ preregistrations/ R-001.yaml, R-002.yaml … COMMIT before the test: git dates it ├─ research/ 01_reversal.py (# %% cells) COMMIT notebooks as percent scripts ├─ src/myquant/ functions you reuse COMMIT imported, never copy-pasted ├─ tests/ pytest for src/ COMMIT └─ results/ run manifests, small tables COMMIT big outputs ignored Data flows one way: raw → processed → research → results — and every arrow is code you can rerun.
fig 1.5.5 — a research repository: commit the environment, code and decisions; never data or secrets

Read the tree from the data's point of view. Downloads land in data/raw/ and are never edited — if a vendor file is wrong, you fix it in code, so the fix is visible and repeatable. Code in src/ turns raw into data/processed/, which you can delete and rebuild at any time. Notebooks in research/ import from src/ instead of copy-pasting functions between notebooks, and anything imported gets a test in tests/. Pre-registrations are committed before the test they describe, so git's timestamp proves the order (Session 1.3). And results/ holds small committed summaries, each with its run manifest — the commit, package versions, data fingerprint and seed that produced it (§1.5.6), while bulky outputs stay out of git. The widely used Cookiecutter Data Science template makes the same raw/processed split; preregistrations/ is this course's addition. uv init my-research gives you the skeleton — pyproject.toml, .python-version, a src/ package, a .gitignore and a fresh git repository — and you add the rest.

In your research repositoryIn this course's repositoryWhy it exists
src/myquant/src/quantnb/small, readable, tested functions the notebooks import
research/*.pylabs/uNN/sSS_*.pypercent-format notebooks, one per question
data/raw/data/cache/downloaded data, git-ignored
results/labs/uNN/out/the numbers you quote, committed and diffable
preregistrations/the template in the Session 1.3 labdecisions made before the data is touched

Git hygiene: never commit data, secrets or bulk

Three things stay out of git. Data, because most market-data licences forbid redistribution and a public repository is redistribution (Session 4.1 — The Data Landscape, Vendors & Licensing), and because git is built for text, not gigabytes. Secrets, because git never forgets. Bulk outputs — large result files, experiment-tracker folders — because they bury the diffs that matter. A starting .gitignore:

# environment: rebuilt by uv sync
.venv/
# data: re-download or rebuild, never commit
data/
# secrets: commit .env.example (variable names only), never .env (the values)
.env
# notebook and run noise
.ipynb_checkpoints/
mlruns/
results/**/*.parquet

API keys live in environment variables — typed into your shell profile or a git-ignored .env file — and code reads them at run time. The course does this for SEC EDGAR, whose API requires a descriptive User-Agent:

import os

user_agent = os.environ["SEC_USER_AGENT"]   # set in your shell or .env, never typed into a notebook
⚠️ Pitfall — "I'll delete the key in the next commit"

Deleting the line does not delete the key: it stays in the history of every clone. GitGuardian counted 28.65 million new hard-coded secrets pushed to public GitHub commits in 2025 alone. If a key ever reaches a commit, revoke it and issue a new one; rewriting history is not enough. The Twelve-Factor App's litmus test is a good habit: could you make this repository public right now without compromising a single credential?

1.5.5 Seeds that actually reproduce

A seed makes randomness repeatable. It is also a trial in disguise, and a shared random stream leaks — two facts that catch out experienced ML engineers.

A pseudo-random generator is a deterministic function of its random seed. In NumPy you create a Generator from a seed and hand it to everything that draws. Every lab starts the same way:

lab = qn.Lab("1.5")  # seeds the random generators: every run gives the same numbers
rng = lab.rng

Two generators seeded with 42 both begin 0.3047, −1.0400, 0.7505; seed 43 begins 0.2442 and never looks back. The lab also hashes 100,000 seeded integers into a fingerprint with qn.repro.fingerprint, 5dad31ce62bf5227. Run it on your machine and compare: integer generation involves no floating-point arithmetic, so it should match exactly.

That promise has fine print. NumPy guarantees the same stream only for the same NumPy build on the same machine, and it allows Generator methods to change their streams in feature releases (a new X.Y version). So the randomness layer rests on the environment layer: pinning NumPy in uv.lock is part of what makes seed=42 mean anything.

A seed is a trial

SHARPE RATIO OF ONE STRATEGY UNDER 20 SEEDS (TRUE SHARPE 0.39, 5 YEARS) −1.5 −1.0 −0.5 0.0 0.5 1.0 1.5 0: −0.2 0 1: −0.4 1 2: −0.5 2 3: 1.0 3 4: 0.3 4 5: 0.7 5 6: 0.2 6 7: −0.8 7 8: −0.2 8 9: 0.4 9 10: −0.7 10 11: 0.6 11 12: 0.7 12 13: 0.4 13 14: 0.1 14 15: 0.9 15 16: 0.0 16 17: 0.6 17 18: 0.0 18 19: 0.0 19
fig 1.5.6 — one strategy, twenty seeds: report the best one (orange) and you have run twenty trials

Run the same strategy — true Sharpe ratio 0.39, 5 years of daily data, zero risk-free rate for simplicity — under 20 seeds, and its measured Sharpe ranges from −0.78 to 0.98. That is not a bug. It is estimation noise of about 1/Y1/\sqrt{Y} = 0.45, the rule from Session 1.3. But it means a seed is a trial: rerun with seed after seed until the backtest looks good and you have run a best-of-20 search and reported the winner — seed 3, at 2.5× the true Sharpe. Picard (2021) found the same in deep learning: scanning up to 10,000 seeds on CIFAR-10, "it is surprisingly easy to find an outlier" that performs much better or much worse than average. The rule: fix the seed before you look. To learn how much the seed matters, run many and report the distribution — never the best one.

Shared streams leak

ONE SEED, TWO WAYS TO USE IT — on run 2 the data comes from the cache ONE SHARED STREAM run 1 · cold cache draws for: build data draws for: bootstrap draw 0 → run 2 · warm cache (data read from disk) draws for: bootstrap nothing else draws The bootstrap now gets the data's old draws. Same seed, same data — a different answer. ONE STREAM PER COMPONENT (spawn) run 1 · cold cache data build data boot bootstrap run 2 · warm cache data unused (cache hit) boot bootstrap identical Skipping one step can't shift another's draws. Each component owns its stream: rng.spawn(n).
fig 1.5.7 — with one shared stream a cache hit shifts later draws; spawned streams stay independent

Seeding is necessary, not sufficient. In the lab, one pipeline builds five years of synthetic returns, then bootstraps a lower confidence bound for their mean (2,000 resamples). With one shared generator, the first run (empty cache) builds the data and bootstraps: the bound is −0.24%. The second run finds the data in the cache and skips the build — so the bootstrap starts at draw zero instead of after the data's draws, and reports −0.32%. Same seed, same data, different answer. Give each component its own child stream with spawn and both runs report −8.10% — a different figure from the shared version because the data now come from their own stream; what matters is that it no longer depends on the cache.

def run_pipeline(cache: dict, streams: str) -> float:
    """One run of data → bootstrap. `streams` is 'shared' (one generator) or 'spawned' (two)."""
    if streams == "shared":
        data_gen = boot_gen = np.random.default_rng(BOOT_SEED)
    else:
        data_gen, boot_gen = np.random.default_rng(BOOT_SEED).spawn(2)
    if "returns" not in cache:                  # cache miss: build (this consumes draws)
        cache["returns"] = build_returns(data_gen)
    return bootstrap_low(cache["returns"], boot_gen)

The legacy global generator, np.random.seed(0), is the same leak with no way to plug it: any library that draws from it shifts your numbers, invisibly. qn.Lab still seeds it as a safety net for old libraries; your own code should never draw from it.

🧭 Margin note — the hash-seed trap

Python salts the hash of every string with a random value chosen when the interpreter starts, so the iteration order of a set of strings can change from one run to the next. The lab starts two fresh interpreters and prints the same set of eight tickers in two different orders. Assign seeded draws to tickers with for t in set(tickers) and your results change anyway, seed or no seed. Write sorted(set(tickers)).

1.5.6 The data cache & a first query

Download once, fingerprint, reuse — then ask the data questions in whichever language suits the question: pandas, SQL or a lazy dataframe.

qn.data_cache() returns a git-ignored folder: data/cache/ inside the repository, ./quantnb_cache anywhere else (Colab), or wherever the QUANTNB_CACHE environment variable points. Labs that need real data fetch it into this folder once, and only when qn.offline() is false. The course's CI sets QUANTNB_OFFLINE=1, so every lab must also run from synthetic data. This lab practises the pattern on a synthetic panel: 50 assets × 10 years of daily returns — 126,000 rows — with volatilities known to run from 15% to 45%.

THE DATA-CACHE PATTERN — what load_or_build(params) does 1 · REQUEST params + seed + code versions 2 · NAME BY INPUTS panel-<hash>.parquet hash of all of them 3 · CACHED AND INTACT? 4a · READ — HIT milliseconds, no draws, no network 4b · BUILD OR FETCH fetch only if not qn.offline() else build synthetic data 5 · WRITE Parquet + a manifest (params, fingerprint) yes no then read data/cache/ git-ignored home of the cache QUANTNB_CACHE environment variable: move it QUANTNB_OFFLINE=1 CI: never touch the network Name by every input, seed included · fingerprint the content · give the builder its own stream.
fig 1.5.8 — the data-cache pattern: a hit costs milliseconds; a miss builds or fetches, then writes
def load_or_build(params: dict, gen: np.random.Generator) -> tuple[pd.DataFrame, str, Path]:
    """Read the panel from the cache if it is there and intact; otherwise build and write it."""
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    data_path = CACHE_DIR / f"panel-{params_key(params)}.parquet"
    meta_path = data_path.with_suffix(".json")
    if data_path.exists() and meta_path.exists():
        df = pd.read_parquet(data_path)
        if json.loads(meta_path.read_text(encoding="utf-8"))["fingerprint"] == content_fingerprint(df):
            return df, "hit", data_path
        print("cache entry failed its fingerprint check: rebuilding")
    df = build_panel(params, gen)
    df.to_parquet(data_path, index=False)
    meta = {"params": params, "fingerprint": content_fingerprint(df), "rows": len(df),
            "built_utc": datetime.now(UTC).isoformat(timespec="seconds")}
    meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
    return df, "miss", data_path

Three rules make a data cache safe, and each one closes a hole you have already met. Name files by everything that decides their content: the file name carries a hash of the parameters, the seed and random stream that drew the data, and the versions of the code that drew it (NumPy may change its streams between feature releases), so changing any of them can never silently reuse stale data. Fingerprint the content, not the file: the lab writes the same panel with two compression codecs and gets two files with different bytes but one content fingerprint — a pyarrow upgrade would change the bytes too, since writers stamp their version into the file. Give the builder its own random stream (§1.5.5), so a hit and a miss leave every later number untouched. Run the lab twice: the first call is a miss, the second a hit, and results.json comes out identical.

A first query, three ways

Now ask the panel a question — annualised mean and volatility per asset — in three dialects. DuckDB runs SQL directly on the Parquet file, with no server and no load step. polars builds a lazy query plan and, when you call collect(), reads only the two columns the plan needs (lazy evaluation). pandas reads the file and groups in memory.

SQL_STATS = """
SELECT ticker,
       count(*)                        AS n_days,
       avg(ret) * 252                  AS ann_mean,
       stddev_samp(ret) * sqrt(252)    AS ann_vol
FROM read_parquet(?)
GROUP BY ticker
ORDER BY ticker
"""


def stats_polars(path: Path) -> pd.DataFrame:
    query = (pl.scan_parquet(path)                         # lazy: nothing is read yet
               .group_by("ticker")
               .agg(n_days=pl.len(),
                    ann_mean=pl.col("ret").mean() * ANN,
                    ann_vol=pl.col("ret").std() * math.sqrt(ANN))
               .sort("ticker"))
    return query.collect().to_pandas()

The lab asserts that pandas, DuckDB and polars agree to within 1e-12 and asks for nothing more. On this panel polars happens to match pandas exactly, while DuckDB's means differ from pandas' in the last digit or two: engines may sum in a different order or with a different algorithm, and floating-point addition is not associative. Summing the lab's million synthetic returns forwards and then backwards gives two answers 6.7e-14 apart. That is why results are compared with a tolerance, here and in the course's CI, which reruns labs from a fresh checkout and checks every recorded number against the committed one to a relative 1e-6.

Synthetic data also lets you grade the answers against the truth. Ten years of daily data pin every asset's volatility to within 2.0 percentage points; the same ten years miss the true 7% mean return by as much as 40 percentage points. The mean is the number you know least well — Session 2.3 returns to this.

💡 Looking ahead

This is a first taste, deliberately. Session 4.5 — The Research Storage Stack goes deep on Parquet, Arrow, DuckDB and polars — partitioning, lazy evaluation, and when a warehouse earns its cost — and Session 4.6 — Lab: Build Your Research Data Lake turns this cache into an idempotent, tested data pipeline with lineage. The run manifest the lab prints (commit, versions, data fingerprint, seed) is what experiment tracking records automatically in Session 6.8, and what model registries record for live models in Session 15.5.

1.5.7 Where reproducibility breaks

Nobody decides to be irreproducible. It happens one convenient shortcut at a time — and every shortcut loosens one of the four layers.

SAME RESEARCH IDEA, TWO WORKBENCHES THE NOTEBOOK THAT WORKED ONCE Untitled7.ipynb — cells last run 14, 3, 9 !pip install pandas — whatever version today np.random.seed(0), then draws everywhere prices_final_v3.csv, fixed by hand API_KEY = "sk-…" pasted into cell 2 Nobody can rerun it — including you, next month. Its Sharpe ratio is an anecdote. THE REPOSITORY THAT REPRODUCES 1 · research/reversal.py, run top to bottom 2 · uv.lock + .python-version, committed 3 · one seed, one spawned stream per component 4 · raw data read-only, fingerprint in manifest 5 · keys in environment variables, not code Fresh clone → uv sync → uv run → same numbers. Every trial in the log can be re-checked.
fig 1.5.9 — the same idea on two workbenches: only one of them produces a result anyone can check
ShortcutLayer it loosensSymptomFix
Cells run out of order; state left in the kernelcodeworks for you, fails on "Restart & run all"uv run python lab.py from a fresh start is the test
pip install with no versionsenvironmentnumbers move after an upgrade nobody noticeduv.lock; upgrade deliberately, rerun, diff the results
Fixing a CSV by handdatathe fix can't be repeated, reviewed or undoneraw is read-only; fixes live in code
Re-downloading "the same" datadatathe source revised history and the numbers movedcache it once; keep its fingerprint in the manifest
np.random.seed and global drawsrandomnessadding a diagnostic changes the backtestone Generator, spawned per component
for t in set(tickers)randomnessseeded results change from run to runsorted(set(tickers))
Trying seeds until it looks goodrandomnessa best-of-N result reported as one trialfix the seed first; report the distribution
assert a == b on floats—tests fail across machines and enginescompare with a tolerance
An API key in a cell—a leaked credential, in history for goodenvironment variable; revoke if it leaks
⚠️ Pitfall — "it ran on my machine, so it's reproducible"

Your machine is the one place the test is guaranteed to pass: it has the packages you installed by hand, the variables still alive in the kernel and the file in your Downloads folder. When Pimentel and colleagues re-ran GitHub notebooks, the most common failure was ImportError, followed by NameError — a name defined in a cell that no longer exists or was never run — then ModuleNotFoundError and FileNotFoundError. Every one of those passes on the author's machine. The real test is a fresh one: new clone, uv sync, uv run. This course's CI does exactly that on Linux — for every lab a pull request touches, and for all of them every week.

1.5.8 Try it

Open the companion lab and prove that your workbench reproduces — then break it on purpose, so you know what the failure looks like.

🔧 Try it (20 min, in the lab)

1. Install uv, clone the repository and run uv sync. Run the lab with uv run python labs/u01/s05_quant_lab_setup.py. Section 1 should print "matches lock" for every package, and section 2 the fingerprint 5dad31ce62bf5227.
2. Run it a second time. Section 6 should now say "cache hit", and git diff labs/u01/out/ should show nothing: same numbers, from cache or not.
3. Change BOOT_SEED to any other number and rerun. The two shared-stream runs still disagree with each other and the two spawned-stream runs still agree: the leak is structural, not bad luck with one seed.
4. Delete data/cache/u01s05/ and rerun. Success looks like this: a cache miss, then the same results.json — and you can explain in one sentence why the cache status cannot move any recorded number.


1.5.9 Glossary · Check yourself · Go deeper

uv — a fast Python package and project manager that installs Python, creates a project's virtual environment, locks its dependencies and runs code inside it.
Virtual environment — an isolated folder of installed packages (.venv/) that belongs to one project, so projects cannot break each other.
Lockfile — a generated, committed file (uv.lock) that pins the exact version and hash of every direct and indirect dependency.
Jupytext — a tool that stores notebooks as plain-text scripts (percent format, # %%) and keeps them in sync with .ipynb files.
Research repository — a git repository laid out for research: immutable raw data, code-derived data, notebooks, a tested package, pre-registrations and results.
Data cache — a git-ignored local store of downloaded or generated data, named by its inputs and checked by a content fingerprint, so data is fetched once and reused.
Random seed — the number that initialises a pseudo-random generator; the same seed on the same software gives the same stream of draws.
Reproducibility — getting the same results again from the same data, code, environment and random seed.
Run manifest — a record of exactly what produced a result: commit, package versions, data fingerprint and seed.

Check yourself

1. A colleague's notebook reports a Sharpe ratio of 1.4. You rerun it on the same data and get 1.1. Where do you look first?
At the four layers, in order of likelihood. Code: were the cells run out of order, or is the notebook different from what produced 1.4? Environment: are your library versions the ones in their lockfile — is there a lockfile? Randomness: is there a seed, and is it passed as a Generator or set globally? Data: is "the same data" really the same file, with the same fingerprint? A run manifest saved next to the 1.4 would answer all four in a minute.
2. You run uv add lightgbm, commit and push. A teammate pulls and runs uv sync. What happens on their machine — and what would have happened if you had forgotten to commit uv.lock?
Their .venv gains exactly the LightGBM version (and dependencies) you locked, and nothing else changes. Without the updated lockfile, pyproject.toml and uv.lock would disagree: their uv sync would re-resolve, possibly picking newer versions than yours, and this course's CI — which runs uv sync --locked — would fail the build. The lockfile is what makes "works on my machine" portable.
3. Your backtest calls np.random.seed(0) at the top. You add a progress-bar library and the backtest's Sharpe changes. Why, and what is the fix?
Something new is drawing from NumPy's global generator — here, presumably the added library — so every later draw your backtest takes is shifted. Global state is shared by everything in the process and you cannot see who consumes it. Create a Generator from the seed, pass it explicitly, and spawn one child stream per component so no component can shift another's draws.
4. You try 20 seeds and report the one with the best Sharpe ratio. In Session 1.3's terms, what have you done?
Run 20 trials and reported the best — a best-of-20 search, whose winner is inflated by luck exactly as in the False Strategy Theorem. In the lab, seeds alone moved a 5-year Sharpe from −0.78 to 0.98 around a truth of 0.39. Fix the seed before looking; if the seed matters, report the distribution over many seeds.
5. Which of these do you commit: a 200 MB vendor price file in data/raw/, uv.lock, results/R-001-manifest.json, .env, research/reversal.py?
Commit uv.lock, the manifest and the .py notebook. Never commit the vendor file — its licence almost certainly forbids redistribution, and git is the wrong store for it; the manifest's fingerprint records which version you used. Never commit .env: it holds secrets, and git never forgets.
6. DuckDB and pandas disagree about an asset's mean return in the 15th decimal place. Is one of them wrong?
No. The engines may add the same numbers in a different order or with a different summation algorithm, and floating-point addition is not associative — the lab's forward and backward sums of the same million numbers differ by about 7e-14. Both answers are correct to the precision that matters. Test agreement with a tolerance, never with ==.
🔧 Homework (~1 h before Session 2.1)

1. Create your own research repository: uv init my-research, then uv add numpy pandas polars duckdb pyarrow, add the folders of fig 1.5.5 and the .gitignore of §1.5.4, and commit. Checkable: a fresh clone, uv sync and uv run python -c "import duckdb" work on a second machine or in a new folder.
2. Move content_fingerprint and load_or_build from the lab into src/, and write a pytest test that a cold-cache call and a warm-cache call return equal frames, and that changing one parameter — or the seed — changes the file name.
3. Write preregistrations/R-001.yaml for an idea of your own, using the template from the Session 1.3 lab, and commit it before touching any data.
4. Read Sandve et al. (2013), below — four pages. Which of its ten rules does your repository already satisfy, and which would the run manifest fix?

📚 Go deeper

  1. National Academies of Sciences, Engineering, and Medicine (2019). Reproducibility and Replicability in Science. The National Academies Press. doi:10.17226/25303
  2. Sandve, G. K., Nekrutenko, A., Taylor, J. & Hovig, E. (2013). "Ten Simple Rules for Reproducible Computational Research." PLoS Computational Biology 9(10), e1003285. doi:10.1371/journal.pcbi.1003285
  3. Pimentel, J. F., Murta, L., Braganholo, V. & Freire, J. (2019). "A Large-Scale Study About Quality and Reproducibility of Jupyter Notebooks." Proceedings of the 16th International Conference on Mining Software Repositories (MSR), 507–517. doi:10.1109/MSR.2019.00077
  4. Chang, A. C. & Li, P. (2022). "Is Economics Research Replicable? Sixty Published Papers From Thirteen Journals Say 'Often Not'." Critical Finance Review 11(1), 185–206. doi:10.1561/104.00000053
  5. Picard, D. (2021). "Torch.manual_seed(3407) is all you need: On the influence of random seeds in deep learning architectures for computer vision." arXiv:2109.08203
  6. Raasveldt, M. & Mühleisen, H. (2019). "DuckDB: an Embeddable Analytical Database." Proceedings of the 2019 International Conference on Management of Data (SIGMOD), 1981–1984. doi:10.1145/3299869.3320212
  7. Astral. uv documentation — installation, projects, locking and syncing. https://docs.astral.sh/uv/
  8. NumPy Developers. "Compatibility policy" (numpy.random). NumPy documentation. https://numpy.org/doc/stable/reference/random/compatibility.html
  9. Jupytext contributors. Jupytext documentation — text notebooks and the percent format. https://jupytext.readthedocs.io/
  10. Wiggins, A. (2011, updated 2017). "The Twelve-Factor App — III. Config." https://12factor.net/config
  11. GitGuardian (2026). "The State of Secrets Sprawl 2026." https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/
  12. DrivenData. Cookiecutter Data Science (v2) — a standard project structure for data science. https://cookiecutter-data-science.drivendata.org/