Skip to content

Streaming variational inference: Trainer for minibatch ADVI - #710

Open
YichengYang-Ethan wants to merge 9 commits into
pymc-devs:mainfrom
YichengYang-Ethan:streaming-trainer
Open

Streaming variational inference: Trainer for minibatch ADVI#710
YichengYang-Ethan wants to merge 9 commits into
pymc-devs:mainfrom
YichengYang-Ethan:streaming-trainer

Conversation

@YichengYang-Ethan

@YichengYang-Ethan YichengYang-Ethan commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Streaming variational inference: Trainer for minibatch ADVI

Stacks on #698 (the streaming DataLoader). This ports the Trainer from
pymc-devs/pymc#8333 into extras, so both halves of the streaming-VI work —
out-of-core data plus a callback-free fit loop — are here now.

What it does

Trainer(method="advi", dataloader=loader, data_name="batch").fit(n) owns the
fitting loop and streams each minibatch into the model's pm.Data placeholder
with set_data, so the user writes no callbacks. The DataLoader owns batching
(len(dataloader) is the dataset size N), the model owns the math.

from pymc_extras.variational.streaming import DataLoader, Trainer, parquet_source

loader = DataLoader(
    parquet_source("shuffled/"), batch_size=4096, sample_shape=(4,), total_size="auto"
)
with pm.Model() as model:
    b = pm.Normal("b", 0.0, 3.0, shape=4)
    batch = pm.Data("batch", np.zeros((4096, 4)))
    logit = b[0] + b[1] * batch[:, 0] + b[2] * batch[:, 1] + b[3] * batch[:, 2]
    pm.Bernoulli("y", logit_p=logit, observed=batch[:, 3], total_size=len(loader))
    approx = Trainer(method="advi", dataloader=loader, data_name="batch").fit(20_000)

fit(n) feeds exactly n minibatches (the first seeds the placeholder before
step 0; the advance after the final step is skipped). User callbacks compose with
the internal advance instead of colliding on the keyword, and an Inference
instance is forwarded to pm.fit unchanged.

Relationship to #635 (the new ADVI API)

#635 already reworks ADVI around its own Trainer
(pymc_extras/inference/advi/training.py), so this is not meant to land a
second, competing Trainer. It's the interim, out-of-core path that runs on
today's pm.fit — useful now, and a concrete home for the streaming DataLoader
while #635 is in review. The end state is to feed the DataLoader into #635's
Trainer through a small adapter, at which point this interim Trainer retires.
Opening it as a draft so the two can be reconciled rather than duplicated; happy
to fold it into #635's direction once that settles.

Where the scaling lives (interim)

The N / batch_size rescaling stays in the model, via total_size=len(loader),
reusing the existing create_minibatch_rv machinery, so it runs unchanged on
today's pm.fit. Folding the scaling into the inference step — so it is derived
from len(dataloader) and drops out of the model body — is the cleaner end state
and lines up with #635; the Trainer Notes document this.

Tests

tests/variational/test_streaming_trainer.py: end-to-end equivalence to in-RAM
pm.Minibatch ADVI, exact batch accounting (fit(n) consumes n batches),
placeholder seeding/streaming, refine resuming the stream, the pass-boundary
total_size check, user-callback composition, and the input guards.

Refs

Ports pymc-devs/pymc#8333 · stacks on #698 · original data layer
pymc-devs/pymc#8325 · relates to #635.

@codecov-commenter

codecov-commenter commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.86%. Comparing base (86fac3c) to head (b736877).
⚠️ Report is 50 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main     #710       +/-   ##
===========================================
+ Coverage   51.60%   91.86%   +40.26%     
===========================================
  Files          73      101       +28     
  Lines        8003     9159     +1156     
===========================================
+ Hits         4130     8414     +4284     
+ Misses       3873      745     -3128     
Files with missing lines Coverage Δ
pymc_extras/variational/__init__.py 100.00% <100.00%> (ø)
pymc_extras/variational/trainer.py 100.00% <100.00%> (ø)

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@zaxtax

zaxtax commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Is this ready for review?

YichengYang-Ethan and others added 9 commits August 8, 2026 02:08
Trainer(method=..., dataloader=...).fit(n) owns the loop: it seeds the
model's pm.Data placeholder before step 0 and streams a batch into it
after every step, so the user writes no callbacks.

Every step advances, including the last. Skipping the final advance made
fit(n) pull exactly n batches, but it left the batch fit had just trained
in the placeholder, so Inference.refine -- which steps before replaying
callbacks -- retrained it, and the closure counter that implemented the
skip stayed live inside refine after an early stop and stranded it on a
stale batch. The loader already reads one batch ahead for its pass-size
check, so uniform advancing costs nothing a re-readable source did not
already pay.

User callbacks run before the advance, so one inspecting the placeholder
sees the batch that produced the latest loss rather than its successor,
and a StopIteration from one ends the fit without pulling again.

An Inference instance bound to a different model than the one being
trained is now refused instead of silently optimizing a model that never
receives a batch, and a model whose observed variables declare no
total_size, or one that disagrees with the loader's N, warns rather than
returning a quietly misweighted posterior.
Decodes which batch each gradient step trained from the loss fingerprint
and compares it to the sequence a user gets by iterating the loader --
across a single step, a fit ending exactly at an epoch seam, and fits
wrapping the loader once and several times. The previous assertions
checked that n distinct values were seen, which a stream off by one
still satisfies.

Also pins that the scaling warning fires on an absent or mismatched
total_size and stays quiet on a correct one, over two N/batch pairs,
and that the bound-model check accepts an instance built under the model
as readily as it rejects one built elsewhere.
Remove the cross-module `_is_positive_int` import: it is private to
dataloader.py, where it validates row counts, and `n` is a step count.
Inline the check instead of promoting a one-line predicate into shared code.

Remove the `_stream` generator nested inside `fit`, hoisting it to a module-level
`_cycle`. Epoch cycling is the loop rule that has to hold, and inside a method
body it could only be exercised through a full ADVI fit; two direct tests now
pin it.

Remove the Notes block from the class docstring: it described a refactor of the
per-step set_data as still pending, which is PR-description material rather than
API documentation.

Co-Authored-By: Claude <noreply@anthropic.com>
Mutation testing found two behaviours of fit() with nothing asserting them:
dropping the isinstance(n, bool) clause and dropping the progressbar
setdefault both left the suite green.

Co-Authored-By: Claude <noreply@anthropic.com>
DataLoader validates its sizes with numbers.Integral, so fit now does the
same: np.int64(4) was accepted for batch_size and refused for n.

The two spin-detector fixtures now raise on a third pass, so a _cycle that
loses its guard fails those tests instead of hanging them.

Co-Authored-By: Claude <noreply@anthropic.com>
…batch count

The merged pymc-devs#698 makes len(DataLoader) the batch count, matching torch, with
the dataset size N on the .total_size property. Every total_size=len(loader)
in the tests silently declared N to be the batch count under the new
semantics. The pass-boundary warning test pinned a loader check that the
merge deleted, so it goes too.

Co-Authored-By: Claude <noreply@anthropic.com>
The DataLoader isinstance check and the data_name lookup both protected
failures that announce themselves: a wrong loader dies on .total_size before
anything is consumed, and a wrong data_name dies on the first set_data with
a KeyError naming it, one batch in. The Inference-bound-to-another-model
check stays because that failure is silent. Their tests go with them, and
the class docstring loses the lineage paragraph.

Co-Authored-By: Claude <noreply@anthropic.com>
…open

The mutation audit re-added the DataLoader isinstance guard and no test
failed: nothing asserted that an iterable with a total_size attribute is
enough. This test trains through a plain sized iterable and kills that
mutation.

Co-Authored-By: Claude <noreply@anthropic.com>
The module and class docstrings both read as 'user callbacks are
unsupported' while fit() documents, supports, and tests callbacks=; the
phrase now says what was meant, once: the user writes no hand-written
streaming callbacks. test_fit_trains_one_batch_per_step and
test_user_callbacks_see_the_batch_that_produced_the_loss assert strict
subsets (installed only / seen only) of what the parametrized
test_steps_consume_the_loaders_own_batch_sequence asserts across four
n/blocks configurations; the default-optimizer sequencing path stays
pinned by test_refine_after_fit_continues_without_repeating_a_batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@YichengYang-Ethan
YichengYang-Ethan marked this pull request as ready for review August 14, 2026 08:29
@YichengYang-Ethan

Copy link
Copy Markdown
Contributor Author

Marking this ready, with a short reading guide.

trainer.py top to bottom:

  • _cycle — the only epoch logic, and it lives here rather than in the loader on purpose: the loader yields exactly one epoch per iteration and knows nothing about repetition. _cycle restarts it forever and turns an empty source into a raise instead of an infinite spin. Happy to inline it into fit if you'd rather not have the helper.
  • _warn_if_scaling_mismatches — the one guard I kept, because both failure modes are silent: a model that never declares total_size, or declares a different one than the loader streams, fits successfully and just gives a wrong posterior. Per Monday's discussion it warns and never blocks — the user may know something we don't.
  • Trainer.fit — cycle the loader, seed the placeholder with batch 0, advance one batch per step through a callback appended after the user's. Step i trains on batch i; user callbacks run while the batch that produced the latest loss is still installed, and refine() continues the stream instead of repeating a batch.

Tests pin the batch-to-step alignment against the loader itself, the duck-typed loader contract (what the deleted isinstance guard used to cover), and both mismatch warnings. Validated end to end on 30.7M rows of tick data against a closed-form posterior streamed through the same loader.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants