GSoC 2026 · Final report

Streaming Variational Inference for PyMC

Yicheng Yang · NumFOCUS / PyMC · Mentors Chris Fonnesbeck and Rob Zinkov · GSoC 2026 final work product · ← All GSoC notes

What this page is. The final work product for my Google Summer of Code 2026 project with NumFOCUS / PyMC. It says what the project set out to do, what got built, what merged upstream, what is still in review, and what is left. Claims link to the pull requests, notebook outputs, or checked-in raw data they come from. State recorded on 18 August 2026 (America/Chicago); the open pull requests continue after GSoC, so each one's GSoC-cutoff commit is linked in the ledger below.

8db1880DataLoader merged into pymc-extras, 7 August 2026
491M rowsof my own tick corpus, one epoch at 339 MB peak
12weekly engineering notes over the program

Highlights

The problem and the goal

PyMC can already minibatch ADVI: pm.Minibatch slices tensors that are in memory, and a shared variable plus a callback can feed batches by hand. What it lacked was everything before that point — a reusable, tested way to turn data on disk into fixed-size batches that carry the row count the total_size rescaling needs — and Pathfinder runs L-BFGS on the full-data gradient, so it had no minibatch path at all. Once a dataset outgrows RAM, and financial tick data, sensor streams and large panels do that routinely, streaming it meant hand-rolled plumbing per model. The project's goal was to build that layer and the streaming drivers around it, on top of PyMC's existing total_size rescaling rather than as a parallel inference engine.

The proposal broke that into five deliverables:

  1. An out-of-core data loader that yields fixed-size minibatches from any re-iterable source and carries the row count N for the ELBO rescaling.
  2. A trainer that drives ADVI over the loader.
  3. A streaming Pathfinder: L-BFGS on minibatch gradients.
  4. Online convergence monitoring, because a stream has no natural step budget.
  5. Worked tutorials: a financial tick-data notebook and a panel-data notebook. Dask integration was a stretch item.

All four library components are built and upstream, with the loader merged, and the tick-data tutorial is open in the PyMC example gallery. The second tutorial and the Dask stretch item were scoped out in favour of getting the tick example right; see what is left.

What was built

Component map of the streaming stack: data on disk flows through the merged DataLoader into a pm.Data placeholder and PyMC's total_size-rescaled likelihood; below it, streaming ADVI with the Trainer and the CheckLossConvergence callback, and the streaming Pathfinder; the tutorial exercises the merged path end to end.
The streaming stack at submission. Data flows left to right into PyMC's existing total_size rescaling; the two consumers sit below it. Status pills are the state on 18 August 2026.
DataLoader — pymc-extras #698 merged 7 Aug 2026 +921 lines · 6 files · 23 commits

pymc_extras/variational/dataloader.py (308 lines) plus 589 lines of tests. Wraps any re-iterable Python source, a list of chunks, a generator factory, or Parquet shards via parquet_source. With shuffle=True, a bounded shuffle buffer re-batches arbitrary source blocks into minibatches of exactly batch_size rows, dropping the ragged tail each pass; with shuffle=False, source blocks pass through unchanged, which is the path for data already shuffled and sharded on disk — the tutorial writes its Parquet row groups at the batch size for exactly this reason. loader.total_size is N, resolved from Parquet metadata or one counting pass, and is what goes into total_size=; len(loader) is total_size // batch_size — the emitted batch count when shuffling, or when an unshuffled source's blocks are written batch-aligned, as the tutorial's are. Merge commit 8db1880.

History: opened against PyMC core as pymc#8325 on 9 June, moved to pymc-extras on the maintainers' extras-first guideline (week 5), cut by 23% in a review pass with three independent proofs the deletions were safe (week 9), and then edited by my mentor in seven commits before merge, taking the source file from just over 500 lines to 308 while 85% of the merged lines stayed mine (week 12).

Validation, on the public Criteo 1 TB click logs. Equivalence: in a seeded 1M-row run, streaming ADVI matched in-memory minibatch ADVI across all 14 posterior means (correlation 0.998, largest gap 0.12). Memory: in separate 150-step subprocess fits configured for 1 million to 150 million rows, streaming peak RSS stayed at 0.65–0.74 GB while the baseline that materializes every row reached 15.7 GB at 150 million (21×); a linear fit to the baseline's growth crosses this machine's 26 GB of RAM near 238 million rows — an actual out-of-memory run was not performed (week 3, week 4, scripts and raw sweep — those scripts were written against the pre-merge prototype and its API; the stored outputs and the raw sweep JSON are the record, and the runnable walkthrough of the merged API is the tutorial below). On a Binance trade corpus of mine (not public, so not independently verifiable) the merged loader streams one epoch over 491,559,069 rows in 6.0 s at 339 MB peak RSS with exact row conservation.

Line chart of peak resident memory versus configured dataset size on Criteo: the streaming DataLoader stays flat at 0.65 to 0.74 GB from 1 million to 150 million rows, the in-memory baseline rises linearly to 15.7 GB at 150 million, and a dashed extrapolation of the baseline reaches the machine's 25.8 GB of RAM near 238 million rows.
From the checked-in sweep (memory_sweep_full.json). Each point is a separate 150-step ADVI fit configured for that many Criteo rows: the streaming loader stays at 0.65–0.74 GB while the baseline that materializes every row reaches 15.7 GB at 150 million; the dashed line is a linear extrapolation of the baseline to the machine's RAM, not a run.
Trainer — pymc-extras #710 open · ready for review +683 lines · 3 files · at b736877

trainer.py (180 lines) plus 501 lines of tests. Runs ADVI through pm.fit over the loader: a pm.Data placeholder receives the next batch after every step, epochs cycle, and the trainer warns when the model's total_size disagrees with the loader's. Validated end to end on 30.7 million rows of tick data against a closed-form posterior. Started as pymc#8333 and moved with the loader.

Status: open, marked ready with a reading guide on the thread. It sits alongside two evolving ADVI efforts, #713 and the longer-term API in #635; #710 is kept small so the approaches are easy to compare, and the trainer APIs are close enough that the code moves across in whichever direction the maintainers take.

Streaming Pathfinder — pymc-extras #722 open · ready for review +1,756 lines · 5 files · at f98a4ff

stochastic_lbfgs.py (244 lines) and streaming_pathfinder.py (382 lines) plus 1,123 lines of tests. L-BFGS on minibatches with same-batch curvature pairs: the gradient difference that feeds the curvature estimate is taken on one batch, so the pair never mixes noise from two different batches (week 8). The proposal from the optimizer path is a Polyak–Ruppert-style tail average (the last 75% of iterates) rather than the ELBO-best iterate, because every accepted iterate sits near its own minibatch's mode, a location error that selection cannot remove but averaging can; Pareto-k went from about 6 to 0.3–0.6 at N = 105. Importance correction reuses Pathfinder's PSIS code and needs exact N, so the driver raises when it cannot get one.

On real data it recovers a streamed closed-form posterior on one million rows of tick data at Pareto-k 0.45, and on 30.7 million rows at 0.30. Against an exact full-data Laplace reference on Bayesian logistic regression it runs Pareto-k 0.26–0.58 over twelve seeds. The operating range — where it is clean, where it is marginal, and the hierarchical and high-dimensional targets where a single Gaussian proposal is not enough — is documented in the docstring and the pull request, from a held-out battery of model families not used during development, so a user knows before fitting. Open, marked ready with a reading guide on the thread.

CheckLossConvergence — pymc-extras #733 open · awaiting review +367 lines · 3 files · at 919857b

callbacks.py (147 lines) plus 218 lines of tests. A pm.fit callback that stops a noisy minibatch ELBO trace on evidence rather than a step count. It compares adjacent block means at two horizons that grow with the run, against a noise yardstick and a practical-negligibility yardstick, and fires only when both horizons agree for a full horizon.

Validated the way a stopping rule should be: on four real 60,000-step ADVI traces it stops at 1.9–3.0× the 99%-convergence step, saving 19–78% of the step budget, with no false fires on half-length truncations; on three further held-out traces (two model families it had never seen, plus a reseeded variant) it stops at 3.0–3.2×, saving 29–48%; and across four held-out still-improving families — seven scenarios, 50 fresh seeds each — the only premature stop in 350 runs was one seed of a power-law rate whose drift sits forty times below the weakest real signal measured. Its dominant failure mode is conservatism — a fit improving too slowly to resolve runs to its full budget, exactly as it would without the callback — with that one weak-drift stop as the measured exception. It grew out of the CUSUM monitor from week 7, whose per-step statistic I measured on real traces and then replaced with block means at growing horizons; supersedes pymc#8384, closed 14 August pointing here.

A gallery notebook (rendered preview, proposal issue #891) that fits a hierarchical hurdle–Student-t model of next-event price moves, 154 free parameters, by streaming minibatches from Parquet with the merged DataLoader. The 300,000 rows are generated inside the notebook with known ground truth, so recovery is checkable, and it runs in under a minute (the notebook times itself; the checked-in run took 42 s on a 10-core arm64 laptop). It teaches three things: two acceptance gates for when minibatch VI is valid and when streaming is doing real work (week 11); the mechanics end to end, an on-disk global shuffle, total_size rescaling and what a stopping rule has to be able to see; and how to read a streaming fit. That last part is the notebook's own contribution: it shows that replaying a fixed shuffled order puts the optimizer on an orbit, and that averaging the iterate over one full pass, rather than reading the last iterate, puts all nine identified quantities in the recovery table inside 1.2 reported posterior standard deviations of their generating values — replicated on a fresh optimizer seed and on a different on-disk replay order, with each refit judged in its own width. It also demonstrates, with a sixty-pass continuation, why a plateaued loss is not the same as a converged parameter along a weakly identified direction, and points at the reparameterization that removes it.

Status: open, with the rendered documentation build green. Merging waits on review and on a pymc-extras release that includes the DataLoader (v0.14.0 predates the merge, so the notebook pins a git install for now). The June draft #888 was closed in favour of this one so there is one example, not two. Immutable source at the GSoC cutoff: MyST and executed notebook.

Merged, open, closed: the ledger

Where each pull request stood on 18 August 2026 (America/Chicago). The four open ones continue after GSoC; each linked commit is the GSoC-cutoff state.

RepositoryPull requestStateNote
pymc-extras#698 DataLoadermerged7 Aug 2026, 8db1880
pymc-extras#710 Traineropenready with a reading guide, cutoff b736877
pymc-extras#722 Streaming Pathfinderopenready with a reading guide, cutoff f98a4ff
pymc-extras#733 CheckLossConvergenceopenawaiting review, cutoff 919857b
pymc-examples#892 Tick-data tutorialopendocs build green, cutoff 8c9e2b1
pymc-examples#882, #886 VI quickstart for PyMC 6merged26 May and 1 Jun, small fixes to the VI quickstart while getting oriented
pymc#8325, #8333closed12 Jul, moved to pymc-extras as #698 and #710
pymc#8384closed14 Aug, superseded by pymc-extras #733
pymc-examples#888closed14 Aug, superseded by #892

The four open pull requests are work I am carrying past 24 August, not work that stops there. They merge when they are ready, and the GSoC deadline does not change how carefully that happens.

Using it today

The loader is on pymc-extras main and installable from the merge commit. The shape of a streaming ADVI fit, which the tutorial spells out in full:

python -m pip install \
  "pymc-extras @ git+https://github.com/pymc-devs/pymc-extras@8db1880d410e509be02abf9b085f08c3d4514fd1" \
  pyarrow    # Python >= 3.12; pyarrow is what parquet_source reads with
import pymc as pm
from pymc_extras.variational.dataloader import DataLoader, parquet_source

loader = DataLoader(parquet_source("shards/", columns=cols),
                    batch_size=1024, shuffle=True, total_size="auto")

with pm.Model() as model:
    batch = pm.Data("batch", next(iter(loader)))
    ...
    pm.Normal("y", mu, sigma, observed=batch[:, 0],
              total_size=loader.total_size)      # N, never len(loader)


class StreamAdvance:                  # the Trainer in #710 owns this step
    def __init__(self, model, loader):
        self._shared = model["batch"]
        self._stream = self._endless(loader)

    @staticmethod
    def _endless(loader):
        while True:
            yield from loader

    def prime(self):
        self._shared.set_value(next(self._stream), borrow=True)

    def __call__(self, approx, losses, i):
        self._shared.set_value(next(self._stream), borrow=True)


stream = StreamAdvance(model, loader)
stream.prime()
with model:
    approx = pm.fit(10_000, method="advi", callbacks=[stream])

Two things the tutorial insists on: total_size is the row count, not the batch count — the snippet above uses loader.total_size for exactly that reason; and when a stream replays a fixed order, average what you read off the fit over a full pass rather than taking the last iterate — the tutorial implements that check, this minimal snippet does not.

What is left

What I learned

It was already a DataLoader. My first design invented names for an idea people already knew. Review pointed out that the thing looked like torch's DataLoader, so that is what it became: same words, same mental model, nothing new to learn. Week 3.

Where code lives is a design decision. A month of pull requests against PyMC core moved to pymc-extras once the maintainers gave the usual answer for a new feature: if it can start in extras, start there. The move was nearly mechanical because the loader has no PyMC coupling, which is itself the lesson about what a data loader should depend on. Week 5.

The review is the deletions. The loader went from 1,361 added lines to 1,045 in a review pass, and then my mentor's seven editing commits took the source file to 308. He rewrote almost nothing; he deleted. The seven ideas in those commits are now the checklist I run over my own code before anyone else sees it. Week 9, week 12.

Real data is the test. The two biggest improvements of the summer came from running finished-looking components on real traces. The stopping rule went from a synthetic calibration to a design validated out of sample; the Pathfinder went from selecting the ELBO-best iterate to averaging the tail of the path, because each minibatch iterate sits near its own batch's mode and averaging removes what selection cannot. Pareto-k from about 6 to 0.3–0.6 on the validated targets. Week 8, #722, #733.

Choosing the example is a statistics problem. Two acceptance gates decided the capstone before any code was written, and the survivor then earned its place by teaching something a smaller example could not: a plateaued loss says nothing about parameter convergence along a weakly identified direction, and the fix is a reparameterization, not more steps. Week 11, #892.

Numbers should trace to a source. Every number on this page traces to a pull request, a notebook output, or checked-in raw output; the one throughput number measured on my own corpus is marked as such wherever it appears.

Code under review or merged

Design, validation, staging

Writing

Thanks

To my mentors, Chris Fonnesbeck (@fonnesbeck) and Rob Zinkov (@zaxtax): for the weekly syncs, for the editing pass that taught me more about library code than anything I read this year, and for the reminder, at exactly the right moment, that good work lands when it is ready. To the PyMC maintainers, for the design guidance on where this work should live. And to NumFOCUS and Google for the program.


Links. #698 · #710 · #722 · #733 · pymc-examples #892 · All GSoC notes · GSoC project page · Mentors @fonnesbeck · @zaxtax