Streaming variational inference: out-of-core DataLoader for minibatch ADVI - #698
Conversation
Ports the streaming data layer (IterableDataset, parquet_source, DataLoader, shuffle_buffer) from pymc-devs/pymc#8325. Self-contained numpy/pyarrow data layer with no pymc-internal coupling; public names mirror torch.utils.data. Tests moved alongside.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #698 +/- ##
===========================================
+ Coverage 51.60% 91.82% +40.21%
===========================================
Files 73 100 +27
Lines 8003 9108 +1105
===========================================
+ Hits 4130 8363 +4233
+ Misses 3873 745 -3128
🚀 New features to boost your workflow:
|
zaxtax
left a comment
There was a problem hiding this comment.
Pretty good. I think it needs another edit. Also we should move things into a dataloader.py or something like that
| f"{total_size!r}." | ||
| ) | ||
|
|
||
| # Plain Python ints: create_minibatch_rv rejects np.int64 for total_size. |
| ) | ||
| self._source_factory = source_factory | ||
|
|
||
| if isinstance(total_size, str): |
There was a problem hiding this comment.
why not just explicitly test for "auto"
| seed: int | None = None, | ||
| sample_shape: tuple[int, ...] | None = None, | ||
| dtype: str = "float64", | ||
| total_size: int | str | None = None, |
There was a problem hiding this comment.
I think "auto" is a better default.
| ) | ||
| return self._total_size | ||
|
|
||
| def _stream_batches(self) -> Iterator[np.ndarray]: |
There was a problem hiding this comment.
The doc comment is a little too formally written. We can make this more clear as a comment.
| yield prepared | ||
| batch = following | ||
|
|
||
| def _prepare(self, batch: np.ndarray) -> np.ndarray: |
There was a problem hiding this comment.
Do we need this? Does torch.DataLoader do something similar?
| f"right, declare its trailing shape with DataLoader(sample_shape=...)" | ||
| ) | ||
| return a | ||
|
|
There was a problem hiding this comment.
A lot of these factory methods look over-engineered. Maybe there is a way to tighten this logic and get rid of much of the indirection.
Move the DataLoader, IterableDataset, shuffle_buffer, and parquet_source into pymc_extras/variational/dataloader.py, with matching test files. Pure rename; no behavior change.
Following the review: - total_size now defaults to "auto" and is matched explicitly (== "auto") rather than an isinstance(str) check. - Drop the factory-of-factory indirection: _make_factory / _block_factory and the .n_rows-forwarded-onto-closures dance are replaced by a single _as_source() that normalizes any source into (new_iter, n_rows, reiterable) and reads n_rows off the original object once; shuffle_buffer no longer forwards n_rows. - Slim _prepare to preprocess + owned dtype-cast (the collate step) and drop the separate _validate (a wrong sample_shape is already caught by _promote_to_block during rebatching). - Shorten the over-formal module / _stream_batches / shuffle_buffer docstrings. - Remove the two tests that asserted the deleted n_rows forwarding; the shuffle + auto path stays covered by test_dataloader_shuffle_auto_resolves_via_n_rows. 53 DataLoader tests pass; ruff clean.
6ca8eb0 to
9e2c2a1
Compare
|
All these files are new. Change the copyright to 2026 |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| """Out-of-core minibatching for variational inference. |
There was a problem hiding this comment.
Drop any mention of minibatching or scaling here
Drop the minibatching / N-over-batch-size rescaling prose from the module docstring (that belongs with the model, not the data loader) and move the usage example onto the DataLoader class docstring.
The missing-column and non-numeric-column checks were written out twice, once per shard in _ParquetDataset.__iter__ and once against the first shard in parquet_source. Both call one _check_columns helper now; both still raise, and the per-shard error still names the shard that broke. _as_source returned (new_iter, n_rows, reiterable), but the last two are one-line derivations from the source, so _auto_total_size takes the source and derives them itself and _as_source just returns the factory. Docstrings and comments lose the paragraphs that restate the module docstring or argue for a design decision; the non-obvious invariants (pyarrow silently drops unknown column names, the no-warn window for total_size, why _prepare copies) stay.
The suite had 53 tests and no parametrize, in a repo where the sibling
suites lean on it (tests/inference/pathfinder/test_lbfgs.py is 6 tests in
150 lines with 4 parametrizations). Six near-identical total_size sanity
tests become one four-case parametrization, and the same-contract pairs
(shuffle buffer conservation, size validation, scalar sample_shape, the
n_rows fast path) each become one test with ids.
Four tests were strictly subsumed by a neighbour and are gone; every
assertion they made still runs somewhere else.
The six catch_warnings/simplefilter("error", UserWarning) blocks are
redundant with filterwarnings = ["error"] in pyproject.toml, and the eight
Parquet tests shared a write_parquet helper instead of repeating
importorskip and write_table.
Test docstrings are one line each. Branch coverage of dataloader.py is
unchanged: same 7 statements and 4 partial branches missed.
Answers the review question on the counter block: torch's DataLoader has no batches_seen/rows_streamed, and nothing here read them either. Removing them leaves _stream_batches with no reason to exist separately from __iter__, so the one-batch lookahead and the total_size check move into __iter__ and the loader has a single way to iterate. The check itself is untouched, and it now runs on every pass rather than only on the accounting one, which closes the warn-once branch the tests could not reach before: 6 statements and 3 partial branches missed, down from 7 and 4. Iteration is no longer side-effect-free, so the module docstring stops claiming peak memory is one batch plus one source chunk -- measured, the lookahead holds two of each.
The total_size warning had said which value to fix; trimming it left only the diagnosis. The lazy pyarrow import lost the note saying why it is not at the top of the file, which is the kind of thing a later cleanup undoes.
The torch comparison it spelled out is already in the module docstring.
A second ponytail pass called the three shuffle integration tests one test written three times, which they were: same claim, three source shapes. One parametrization over (block factory, raw 2-D, raw 1-D scalars) makes the same assertion for all three, and a stronger one for the 2-D case, which had only checked a subset. It also proposed merging _rebatch and shuffle_buffer's loop. They only look alike: the shuffle path fills to buffer_size rather than batch_size, has to flush a final fill that never reached it, and must own the buffer it shuffles. The suite did not pin that last one -- a fill fed by a single chunk was shuffled in place with no test noticing -- so test_shuffle_buffer_does_not_mutate_source now covers both fill shapes. Also drops a variable in _auto_total_size that was set once and read once, and a caveat in shuffle_buffer's docstring that the module docstring makes.
A source is allowed to refill and re-yield one array -- the standard out-of-core reader idiom -- but _rebatch and shuffle_buffer both held such a block across further pulls and only copied at the end, so whole batches were silently replaced by later values. Iteration also ran its one-batch lookahead before preparing the current batch, aliasing it for sources that yield exact batch_size blocks. Also close two holes in the pass-size check: streaming more rows than total_size now always warns instead of getting 10% slack, and a pass too short to fill one batch says so instead of returning nothing quietly.
Sweeps one epoch over dataset sizes, batch sizes, chunkings and shuffle settings and asserts the batches are exactly floor(N/batch_size) groups of distinct source rows in source order when unshuffled -- nothing lost, duplicated or invented. Adds a second-epoch replay check, a seeded-shuffle equivalence against shuffle_buffer through the public API, and a check that a raw array, a factory, a bare re-iterable and an IterableDataset subclass all stream the same batches. Also covers three things the suite could not see before: dtype= was never asserted, preprocess_fn was never shown to run once per batch rather than per row, and the remainder a block leaves behind was never read after the source had been pulled again.
| ``50 * batch_size``. A buffer as large as the dataset is a full shuffle. | ||
| seed : int, optional | ||
| Seed for the shuffle buffer (ignored when ``shuffle=False``). | ||
| sample_shape : tuple of int, optional |
There was a problem hiding this comment.
Why does DataLoader need sample_shape ? Or there any code that becomes less error-prone with it?
sample_shape existed to disambiguate a yield that could be either one sample or a block of rows: a source handing back shape (3,) might mean three scalar samples or one three-feature sample, and nothing in the data says which. But the ambiguity was self-inflicted -- it only arises because a source was allowed to yield either shape. torch avoids it by contract rather than by parameter: Dataset.__getitem__ returns one sample and the loader does the batching. Requiring blocks does the same here, and it is already what IterableDataset documents and what parquet_source has always produced. With rows on the leading axis, block.shape[1:] is the sample shape and nothing has to be declared. A raw ndarray is now handed over whole rather than iterated, since iterating one yields its rows and reintroduces exactly the ambiguity this removes. Blocks are checked for a consistent trailing shape as they are read, which turns a mid-stream shape change into a message that names both shapes rather than a numpy concatenate error later.
_prepare was a 4-line method called exactly once in __iter__; inlined. IterableDataset was an abstract base class that nothing depended on via isinstance -- _auto_total_size uses getattr(dataset, 'n_rows', None) and _as_source just calls iter(). _ParquetDataset and BlockDataset no longer subclass it.
- __len__ now returns total_size // batch_size (number of batches per epoch) - Use loader.total_size for the PyMC total_size= parameter - Simplify _auto_total_size: remove redundant 'second is first' check, inline the second iterator, tighten error messages
202e686 to
e386f59
Compare
…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>
… limits
The num_draws + 1 proposal-pool default made importance_sampling="psis" a no-op and
crashed small fits. Measured on Bayesian logistic regression against an exact full-data
Laplace reference (k=5, batch 512, 200 iterations, three seeds):
pool Pareto-k worst-coordinate mean gap worst sd / reference wall
1000 (None) 8.03 / 12.87 / 14.16 sd 0.93 / 0.91 / 0.92 10.5 s
1001 5.5 8.03 / 12.87 / 14.16 sd 0.93 / 0.91 / 0.92 21.7 s
2000 5.0 7.50 / 12.15 / 13.64 sd 0.80 / 0.70 / 0.76 34.6 s
4000 4.9 7.15 / 11.76 / 13.33 sd 0.74 / 0.66 / 0.71 32.8 s
8000 5.1 6.90 / 11.38 / 13.07 sd 0.71 / 0.64 / 0.68 69.8 s
A pool of num_draws + 1 reproduces the unresampled numbers to every digit on every seed:
importance_sampling.py:120 sets replace = (method == "psir"), so "psis" draws 1000 of
1001 without replacement, which is the pool minus one draw whatever the weights are. It
also raised ValueError("n_draws_tail must be at least 5") from arviz for num_draws <= 23.
The default is back to 4 * num_draws, which is fit_pathfinder's own pool (num_paths=4 x
num_draws_per_path=1000 resampled to num_draws=1000), so the two APIs weight the same way
and num_draws=20 fits again. It is not free and it is not clean: at this Pareto-k the
4000-draw pool has an effective sample size of 1.0-1.2 and the resampled 1000 overlap
94.5-96.1% with the top 1000 by weight, so the "reweighting" is selection, which is why
the marginal sd degrades. Both facts are now in the importance_sampling docstring so the
choice of None is an informed one.
- Move the measured accuracy limits from the module docstring, which help() does not
render, into fit_streaming_pathfinder's own docstring, and re-measure all of them. The
old paragraph claimed "Pareto-k 3.4 at N=1e5" and "7.8 -> 3.9 ... at N=1e5" in the same
breath, and its "sd ratio 0.8-1.1" and "fit_pathfinder 0.3-0.5" are in no measurement
set on this machine. Measured today: N=1e5 gives Pareto-k 4.3-6.6 and 7.1-13.3
reference-sd; N=1.6e6 gives 22-39 and 57-101 sd; fit_pathfinder on the same N=1e5 data
gives Pareto-k -0.6 to 0.4 and 0.10 sd, so the comparison is no longer "unverified".
"More iterations do not help at all" was too strong: 600 instead of 200 left the
selected iterate unchanged on two of three seeds and improved the third.
- Plumb callbacks= through fit_streaming_pathfinder. The hook added to
run_stochastic_lbfgs was unreachable through the module's only export. It also handed
each callback a one-element list; pm.fit hands scores[: i + 1], so accumulate. The
1-based index was already right - _iterate_with_loss calls callback(approx,
scores[: i + 1], i + s + 1) with s = 0 on a fresh fit, verified by running one.
- Warn, rather than mention in passing, that a full_pass which skips the loader's
preprocessing is undetectable here: measured 181 nats of logP error and no exception.
- Drop the pymc_extras.variational.DataLoader reference from a public docstring; pymc-devs#722
does not depend on pymc-devs#698.
- One explanation for the 1e-16 curvature floor, placed on the test it guards instead of
dangling between the null-step branch and the elif.
- Test the jitter (mutation M26 survived 24 tests), the callbacks path, and num_draws=20.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds a streaming data layer for minibatch variational inference on data that doesn't fit in memory. Ported from pymc-devs/pymc#8325 — moving it here per the discussion on that PR (@ricardoV94's call to start in extras).
pm.Minibatchindexes an in-memory array, so peak memory is O(N). This streams minibatches from an out-of-core source into apm.Dataplaceholder instead, so peak memory is set by the batch, the source chunk, and the optional shuffle buffer — independent of N.The API mirrors
torch.utils.data:IterableDataset— a re-iterable, out-of-core source of rows (e.g.parquet_sourceover a directory of shards).DataLoader— fixed-size, optionally shuffled minibatches; sized, withlen(loader) == Nfortotal_size.shuffle_buffer— a bounded shuffle over the stream.The unbiased-gradient rescaling reuses the existing
create_minibatch_rv(the sameN / batch_sizeaspm.Minibatch), viatotal_size=len(loader).Round 2 — ran a ponytail pass over the whole diff, per review:
dataloader.py608 → 494.batches_seen/rows_streamedcounters and the second iteration path are gone — torch'sDataLoaderhas none and nothing read them.__iter__is the only loop now and fires thetotal_sizesanity check itself (that check now runs on every pass, so branch coverage went up, not down)._check_columns;_as_sourcereturns just the factory instead of a 3-tuple.parametrize(53 → 47, eight parametrizations); every raise/warning kept its test._rebatchwithshuffle_buffer's fill loop (they differ on buffer ownership and end-of-stream flush; details in the commit message) and dropping__iter__'s one-batch lookahead (it is what makes the sanity check fire when a fit stops exactly at a pass boundary).Verified against the pre-trim revision: the batch streams are byte-identical across plain/shuffled/Parquet/raw-array sources (same seeds), the old test suite replayed against the new code passes everywhere except the seven tests of the deleted counter API, and a streamed-ADVI run through
pm.fitrecovers the same posterior as in-RAM ADVI andpm.Minibatch.Notes:
pymc_extras/variational/dataloader.py.pyarrowis an optional dependency, imported lazily only for the Parquet source.tests/variational/. End-to-end example: Example: out-of-core minibatch variational inference with DataLoader and Trainer pymc-examples#888 (will switch its import topymc_extrasonce this lands).