Run PyMC MCMC sampling on cloud VMs with one line of code. Caching, live progress, and phone notifications included.
Project description
cloudposterior
Stop waiting for MCMC. Start shipping posteriors.
cloudposterior lets you run PyMC models on cloud VMs without changing your sampling code. One extra line gives you cloud compute, automatic caching, and phone notifications -- while pm.sample() stays exactly the same.
import cloudposterior as cp
with cp.cloud(model, remote=True):
idata = pm.sample(draws=5000, chains=8) # 8 cores in the cloud, zero config
Why?
You've built a hierarchical model. It's beautiful. But sampling takes 45 minutes on your laptop, your fans sound like a jet engine, and you can't use your machine for anything else.
cloudposterior fixes this:
- Ship sampling to the cloud with one line. Your model runs on a VM with as many cores and as much RAM as it needs.
- Never re-run the same model twice. Results are cached automatically -- re-execute a notebook cell and get your posterior back instantly.
- Monitor from anywhere. Get live progress notifications on your phone while your model samples.
All three features work independently. Use any combination, or just the caching.
Quick start
uv add cloudposterior
# For cloud execution (optional):
uv add modal && uv run modal setup
import pymc as pm
import cloudposterior as cp
with pm.Model() as my_model:
mu = pm.Normal("mu", 0, 5)
sigma = pm.HalfNormal("sigma", 5)
pm.Normal("obs", mu, sigma, observed=data)
# This is the only line you add:
with cp.cloud(my_model, remote=True, cache="disk"):
idata = pm.sample(draws=2000, chains=4)
Second time you run that cell? Instant. The result is already cached.
Features
Cloud execution
Offload MCMC to cloud VMs. No Docker, no infrastructure, no config files. Modal handles containers, scaling, and cleanup.
with cp.cloud(model, remote=True):
idata = pm.sample(draws=5000, chains=8)
Your model is serialized with cloudpickle, shipped to a container with version-matched dependencies (PyMC, PyTensor, numpy -- all pinned to your exact local versions), sampled there, and the trace is compressed and sent back. The container image is built once and cached, so subsequent runs start in seconds.
Smart resource sizing
cloudposterior inspects your model and sampling config to right-size the VM automatically:
- CPU cores matched to your chain count (8 chains = 8 cores)
- Memory scaled to your observed data size and parameter count
No guessing, no over-provisioning. A small model gets 4 cores and 4GB. A hierarchical model with large datasets gets 8+ cores and 16GB+. The progress display shows what was chosen:
cloudposterior -- Modal (auto-sized: 8 cores, 8GB)
Want explicit control? Use a preset:
with cp.cloud(model, remote=True, instance="xlarge"): # 32 cores, 64GB
...
The container is sized on the first pm.sample() call inside with cp.cloud(...) and stays that size for the duration of the block. If a later call uses different chains/draws that the auto-sizer would have provisioned differently, you'll get a warning -- start a new cp.cloud() block to resize.
Automatic caching
Re-running a notebook cell? If the model, data, and sampling config haven't changed, cloudposterior returns the cached result instantly. No wasted compute. Caching is on by default.
with cp.cloud(model):
idata = pm.sample(draws=2000) # samples normally
with cp.cloud(model):
idata = pm.sample(draws=2000) # instant -- cached
For persistence across kernel restarts, use disk caching:
with cp.cloud(model, cache="disk"):
idata = pm.sample(draws=2000)
Results are stored in a human-readable directory tree:
.cloudposterior/
├── radon_intercepts/
│ └── draws2000_tune1000_chains4-a3f7b2c9.nc
└── radon_slopes/
└── draws2000_tune1000_chains4-7c2e5fa8.nc
Model names come from pm.Model(name="radon_intercepts"). The hash suffix ensures uniqueness when non-displayed parameters (like random_seed) differ.
Monitoring
Two ways to monitor sampling:
Live dashboard (on by default for remote) -- convergence diagnostics, trace plots, and a stop button:
with cp.cloud(model, remote=True):
idata = pm.sample(draws=5000, chains=8)
Scan the QR code or open the URL on your phone. No app install needed.
Push notifications -- get notified when sampling starts and completes via ntfy:
with cp.cloud(model, notify=True): # auto topic
with cp.cloud(model, notify="my-channel"): # custom topic
with cp.cloud(model, remote=True, notify=True): # remote (dashboard on by default) + ntfy
With remote=True, the dashboard is on by default; notify=True adds ntfy push notifications on top.
Live progress display
Both Jupyter notebooks and terminals show real-time, in-place progress for every phase:
- Serialization
- Upload
- Container provisioning
- MCMC sampling -- per-chain progress bars, divergences, step size, grad evals, speed, ETA
- Result download
Notebooks get a live anywidget display that animates in-cell in both Jupyter and marimo. Terminals get a Rich TUI. Progress bars turn red when chains diverge, just like PyMC's native display. During a remote run a Stop button appears in the cell -- click it to abort early and keep the partial trace.
Samplers
cloudposterior defaults to nutpie -- PyMC's recommended NUTS sampler, roughly 2x faster on CPU -- for fully continuous models, and falls back to PyMC's built-in sampler for models with discrete variables. Override per call:
with cp.cloud(model, remote=True):
idata = pm.sample() # nutpie (default for continuous models)
idata = pm.sample(nuts_sampler="pymc") # PyMC's sampler (handles discrete vars)
idata = pm.sample(nuts_sampler="numpyro") # JAX sampler (GPU auto-provisioned)
Live per-chain progress, convergence diagnostics (rank-normalized R-hat and ESS), and the stop button work with nutpie and pymc. JAX samplers (numpyro, blackjax) run entirely inside a compiled graph with no per-draw hook, so they report phase-level progress only.
Custom step methods work too -- pass step=pm.Metropolis() (or Slice, DEMetropolis, a CompoundStep, ...) and cloudposterior routes to PyMC's sampler and ships the step alongside the model so it samples correctly in the cloud, with live progress. Discrete models that rely on PyMC's automatic step assignment need no step= at all -- just call pm.sample().
Works with both PyMC 5 and PyMC 6 (PyMC 6 ships arviz 1.x's DataTree); the versions installed in the remote container are matched to your local environment.
Adaptive sampling
Stop as soon as the chains converge instead of guessing a draw count -- draws becomes the cap:
with cp.cloud(model, remote=True, until={"r_hat": 1.01, "ess": 400}):
idata = pm.sample(draws=20000) # stops early once every parameter clears the target
until=True uses the Vehtari (2021) defaults shown above. Works with nutpie and pymc (the samplers with a per-draw hook).
Parallel fitting
Fit many models at once on a single warm container -- ideal for model comparison and prior sensitivity:
import arviz as az
idatas = cp.map([pooled, hierarchical, per_county], {"draws": 1000})
az.compare(dict(zip(["pooled", "hier", "county"], idatas)))
sample_kwargs is a shared dict or a list aligned with the models. Results return in input order. See examples/parallelism.ipynb.
Predictive checks and model comparison
pm.sample_prior_predictive() and pm.sample_posterior_predictive() are intercepted too, so prior/posterior predictive checks (and GP .conditional() predictions, which run through posterior predictive) execute on the same cloud container.
pm.compute_log_likelihood() is intercepted as well -- compute pointwise log-likelihoods in the cloud, then run az.loo / az.waic / az.compare locally:
with cp.cloud(model, remote=True):
idata = pm.sample(draws=2000)
pm.compute_log_likelihood(idata) # adds the log_likelihood group, in the cloud
az.loo(idata)
pm.sample_smc() (Sequential Monte Carlo) runs in the cloud too, for multimodal posteriors and model evidence.
What runs in the cloud
cloudposterior runs the entire MCMC workflow on the cloud container -- posterior sampling (NUTS and step methods, all four backends), prior/posterior predictive checks, SMC, and log-likelihood for model comparison. Optimization-based inference (variational inference, MAP) and a few non-InferenceData utilities are not yet routed to the cloud; they still run locally as usual.
| Supported in the cloud | Not yet (runs locally) |
|---|---|
pm.sample() -- NUTS (nutpie, pymc, numpyro, blackjax) |
pm.fit() -- variational inference (ADVI, etc.) |
pm.sample() with custom step= (Metropolis, Slice, DEMetropolis, ...) |
pm.find_MAP() -- MAP point estimation |
pm.sample_smc() -- Sequential Monte Carlo |
pm.compute_deterministics() |
pm.sample_prior_predictive() / pm.sample_posterior_predictive() |
pm.draw() |
pm.compute_log_likelihood() -- LOO/WAIC/az.compare |
pymc-extras (fit_pathfinder, fit_laplace) |
Two pm.sample details can't be matched exactly for remote execution and warn rather than silently diverge: return_inferencedata=False (a MultiTrace can't be transported, so you get an InferenceData) and a per-draw callback= (it can't run against local state inside a container -- use remote=False for that). VI, MAP, and the non-InferenceData utilities are a planned follow-up.
Composable features
| Feature | Default | Control |
|---|---|---|
| Caching | on (in-memory) | cache=True / False / "disk" / Path(...) |
| Cloud execution | off | remote=True / False |
| Live dashboard | on (when remote) | dashboard=True / False |
| Push notifications | off | notify=True / "topic" / {"server": ..., "topic": ...} |
| Adaptive early-stop | off | until=True / {"r_hat": ..., "ess": ...} (remote) |
Mix and match:
with cp.cloud(model): # local + memory cache
with cp.cloud(model, cache="disk"): # local + disk cache
with cp.cloud(model, remote=True): # cloud + dashboard
with cp.cloud(model, remote=True, cache="disk", notify=True): # everything
Configuration
Instance presets
| Name | CPUs | Memory |
|---|---|---|
small |
4 | 8 GB |
medium |
8 | 16 GB |
large |
16 | 32 GB |
xlarge |
32 | 64 GB |
gpu |
8 | 16 GB + A100 |
Environment variables
| Variable | Description |
|---|---|
CLOUDPOSTERIOR_NTFY_TOPIC |
Default ntfy topic |
CLOUDPOSTERIOR_NTFY_SERVER |
Custom ntfy server (default: https://ntfy.sh) |
Cloud backend
Cloud execution currently uses Modal. Modal provides fast container spin-up, automatic dependency packaging, and a generous free tier.
uv add modal
modal setup # one-time browser auth
The backend is abstracted behind a ComputeBackend interface. Support for additional providers (AWS, GCP, SSH to your own machines) is planned.
How it works
- Serialize -- The model is serialized with cloudpickle + lz4 on the first
pm.sample()call insidewith cp.cloud(...). Cloudpickle bundles your observed data into the model object, so there's a single payload, not two. A version manifest captures your exact package versions. - Upload once -- The serialized payload is uploaded to a Modal Volume the first time. Subsequent calls with the same model skip the upload entirely. Old payloads from past edits are pruned automatically.
- Sample --
pm.sample()runs remotely. The container loads the payload from the mounted Volume (fast local read) and streams per-chain progress back in real time via msgpack. Only sample kwargs and a path string are sent over the wire per call. - Return -- The InferenceData trace is compressed as NetCDF, sent back, and cached.
The container stays warm for ~20 minutes after the with block exits, so a re-run of the same model is near-instant and the live dashboard stays browsable in the meantime (open it on your phone, walk away, come back and check). It idles out on its own and is torn down when the kernel exits; stop it immediately with cp.cleanup_volumes() or session.destroy().
Cleanup
Model payloads are stored in a project-scoped Modal Volume. The following also stops any kept-warm container/dashboard for the project:
cp.cleanup_volumes() # delete the current project's volume
cp.cleanup_volumes(project="my-research") # delete a specific project's volume
For a one-shot teardown that also stops a warm container immediately, use the context manager's destroy():
session = cp.cloud(model, remote=True)
with session:
idata = pm.sample(draws=2000)
session.destroy() # stop the container and delete the project volume
Explicit API
If you prefer not to use the context manager, cp.sample() runs a single remote sampling job (always cloud, no persistent container reuse):
idata = cp.sample(model, draws=2000, chains=4)
For repeated sampling with the same model, the cp.cloud() context manager is cheaper -- it keeps the container warm and only sends kwargs after the first call.
Example
Clone and run locally (Jupyter or marimo) for the full live progress display.
- examples/basics.ipynb -- cloud execution and GPU acceleration with the Minnesota Radon dataset
- examples/caching.ipynb -- local and disk caching, model iteration
- examples/monitoring.ipynb -- live dashboard and push notifications
- examples/parallelism.ipynb -- fit many models in parallel with
cp.mapand compare them with LOO
Tests
The default suite is fast and free -- it covers serialization, caching, naming, kwarg validation, and runs cp.cloud(model) end-to-end against a local PyMC sampler:
uv run pytest tests/ -v
A separate suite of end-to-end tests hits real Modal infrastructure to verify the cloud path. These cost a small amount of Modal credit per run and are skipped by default. Opt in with --run-modal:
uv run pytest tests/test_modal_e2e.py -v --run-modal
Modal tests provision the smallest possible instance, sample 20 draws on a 2-RV model, and use isolated per-test project volumes that are cleaned up at teardown.
Status
Early proof of concept. Works end-to-end with 75+ passing tests, but expect rough edges. Contributions and feedback welcome.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cloudposterior-0.6.0a1.tar.gz.
File metadata
- Download URL: cloudposterior-0.6.0a1.tar.gz
- Upload date:
- Size: 978.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c35b56f8e31ee37e2a463d14b716dea294f011f6b745ec1126bded13e8afbefd
|
|
| MD5 |
179eaa958882b1b002008a6d4d89eecf
|
|
| BLAKE2b-256 |
87a47f952c360a094cc95ad07ab0713731f1dcfe9f4ed8877c0b7237b5df1cb5
|
Provenance
The following attestation bundles were made for cloudposterior-0.6.0a1.tar.gz:
Publisher:
publish.yml on justmytwospence/cloudposterior
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cloudposterior-0.6.0a1.tar.gz -
Subject digest:
c35b56f8e31ee37e2a463d14b716dea294f011f6b745ec1126bded13e8afbefd - Sigstore transparency entry: 1723033287
- Sigstore integration time:
-
Permalink:
justmytwospence/cloudposterior@b150e4930c8c2df6ae6de4e225c26cc6477955d5 -
Branch / Tag:
refs/tags/v0.6.0a1 - Owner: https://github.com/justmytwospence
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b150e4930c8c2df6ae6de4e225c26cc6477955d5 -
Trigger Event:
release
-
Statement type:
File details
Details for the file cloudposterior-0.6.0a1-py3-none-any.whl.
File metadata
- Download URL: cloudposterior-0.6.0a1-py3-none-any.whl
- Upload date:
- Size: 72.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4d537db53f53849fab3c77dda1f60492e12648126ff3941c621215e698056926
|
|
| MD5 |
8a405494d348d85c4c110a40522bb574
|
|
| BLAKE2b-256 |
36be187f48161ab0a700e2bbca12e4f05b1508c957c6d91d1e0c24db43ba2871
|
Provenance
The following attestation bundles were made for cloudposterior-0.6.0a1-py3-none-any.whl:
Publisher:
publish.yml on justmytwospence/cloudposterior
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cloudposterior-0.6.0a1-py3-none-any.whl -
Subject digest:
4d537db53f53849fab3c77dda1f60492e12648126ff3941c621215e698056926 - Sigstore transparency entry: 1723033791
- Sigstore integration time:
-
Permalink:
justmytwospence/cloudposterior@b150e4930c8c2df6ae6de4e225c26cc6477955d5 -
Branch / Tag:
refs/tags/v0.6.0a1 - Owner: https://github.com/justmytwospence
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b150e4930c8c2df6ae6de4e225c26cc6477955d5 -
Trigger Event:
release
-
Statement type: