Metadata-Version: 2.5
Name: cloudposterior
Version: 0.8.0
Summary: Run PyMC MCMC sampling on cloud VMs with one line of code. Caching, live progress, and phone notifications included.
Project-URL: Homepage, https://github.com/justmytwospence/cloudposterior
Project-URL: Documentation, https://justmytwospence.github.io/cloudposterior/
Project-URL: Repository, https://github.com/justmytwospence/cloudposterior
Project-URL: Issues, https://github.com/justmytwospence/cloudposterior/issues
Author: Spencer Boucher
License-Expression: FSL-1.1-ALv2
License-File: LICENSE.md
Keywords: bayesian,cloud,mcmc,modal,pymc,sampling
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.11
Requires-Dist: anywidget>=0.9
Requires-Dist: arviz<2.0,>=0.17
Requires-Dist: cloudpickle>=3.0
Requires-Dist: coolname>=4.0
Requires-Dist: lz4>=4.0
Requires-Dist: modal<2,>=1.0
Requires-Dist: msgpack>=1.0
Requires-Dist: numpy>=1.24
Requires-Dist: pymc-cache<0.2,>=0.1.0
Requires-Dist: pymc-monitor[dashboard,notebook]<0.2,>=0.1.0
Requires-Dist: pymc<7,>=5.28
Requires-Dist: pytensor>=2.38
Requires-Dist: qrcode>=7.0
Requires-Dist: requests>=2.28
Requires-Dist: rich>=13.0
Provides-Extra: nutpie
Requires-Dist: nutpie>=0.13; extra == 'nutpie'
Description-Content-Type: text/markdown

# 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. Sampling diagnostics and local dashboards come from [pymc-monitor](https://justmytwospence.github.io/pymc-monitor/); safe result reuse comes from [pymc-cache](https://justmytwospence.github.io/pymc-cache/).

```python
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

Requires Python >=3.11 and PyMC >=5.28.

```bash
uv add cloudposterior

# The Modal client is installed for you; authenticate once for cloud execution:
uv run modal setup
```

```python
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.

---

## Three composable packages

- **[pymc-monitor](https://justmytwospence.github.io/pymc-monitor/)** owns sampling progress, diagnostic checkpoints, adaptive stopping, retries, displays, and the local dashboard. Its [API reference](https://justmytwospence.github.io/pymc-monitor/reference/) provides a standalone `sample`/`start` interface without any Modal dependency.
- **[pymc-cache](https://justmytwospence.github.io/pymc-cache/)** owns graph/data identity, cache eligibility, safe memory/disk storage, and the `CacheSession` preflight API. Its [API reference](https://justmytwospence.github.io/pymc-cache/reference/) has no monitor or cloud dependency.
- **cloudposterior** keeps the `cp.cloud` interceptor, Modal provisioning and transport, notifications and model-map fanout. It composes the two packages rather than maintaining independent samplers or cache-key algorithms.

The cloud API is unchanged. Local `until=` and an authenticated `dashboard=True` loopback page are now supported. Diagnostic thresholds are heuristics, **not** convergence guarantees or publication certification. See [migration](migration.qmd) and [packages](packages.qmd) for the compatibility boundary and pre-PyPI installation.

## Features

### Cloud execution

Offload MCMC to cloud VMs. No Docker, no infrastructure, no config files. [Modal](https://modal.com) handles containers, scaling, and cleanup.

```python
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:

```python
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**.

```python
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:

```python
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-<full-sha256>.nc
└── radon_slopes/
    └── draws2000_tune1000_chains4-<full-sha256>.nc
```

Model names come from `pm.Model(name="radon_intercepts")`. The full versioned fingerprint includes graph structure, data, software versions and sampler settings. Existing legacy entries remain on disk but are not reused: see the [migration guide](migration.qmd). `cache=True` explicitly reuses an unseeded run; choose `overwrite=True` or `cache=False` for a fresh draw. Manually stopped and unmet adaptive runs are not automatically cached.

### Monitoring

Two ways to monitor sampling:

**Live dashboard** (on by default for remote) -- convergence diagnostics, trace plots, and a stop button:

```python
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](https://ntfy.sh):

```python
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:

1. Serialization
2. Upload
3. Container provisioning
4. MCMC sampling -- per-chain progress bars, divergences, step size, grad evals, speed, ETA
5. 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:

```python
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 (CPU; add instance="gpu" for a GPU)
```

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 when the chains have converged instead of guessing a draw count. `draws` becomes the cap:

```python
with cp.cloud(model, until=True):  # also works locally; add remote=True for Modal
    idata = pm.sample(draws=20000)   # stops once the convergence target holds
```

The worker checks **every element of every free variable** (vectors included). It checks at geometrically spaced checkpoints and stops once the target has held at two in a row. A run that can't meet the target stops early too, with the likely cause:

- `until=True` (or `"final"`):
  - rank-normalized R-hat <= 1.01 (Vehtari et al. 2021),
  - bulk and tail ESS >= 400,
  - zero post-warmup divergences,
  - E-BFMI >= 0.3.

  With more than about 4 chains the R-hat threshold scales as `sqrt(1 + 2m/ESS)` (Vats & Knudson 2021). Otherwise extra chains would force more draws than the ESS target needs.
- `until="draft"`: R-hat <= 1.05 and ESS >= 100, with divergences reported but not gating. Use it for model development.
- A dict overrides any preset key:

  ```python
  until={
      "ess": 1000,                 # bulk + tail ESS
      "mcse": 0.05,                # Monte Carlo SE <= 5% of the posterior sd
      "quantiles": True,           # ... for the 5%/95% quantiles too
      "var_names": ["beta", "sigma"],
      "escalate": 2,               # re-run up to twice with adjusted settings
      "superchains": 8,            # nested R-hat for many chains
  }
  ```

When the target can't be met, the result says why instead of silently using up the draw cap:

| Cause | What it means | What `escalate` tries |
|-------|---------------|------------------------|
| `divergences` | the sampler can't reach part of the posterior | `target_accept` 0.95, then 0.99 |
| `early_drift` | chains still drifting at the start of sampling: warmup too short | `tune` x2, twice |
| `chains_disagree` | chains stay apart late in sampling (modes, a stuck chain, a funnel) | `tune` x2, `target_accept` 0.95 |
| `slow_mixing` | chains agree but need more draws (the projected number is reported) | raise the draw cap to the projection |
| `ess_stalled` / `low_bfmi` | ESS isn't growing / energy poorly explored: reparameterize | nutpie `adaptation="low_rank"`, `tune` x2 |

The verdict is stored on the result and warns if it isn't met. Check any trace, local or remote, the same way:

```python
cp.check_convergence(idata)                  # report dict for any InferenceData
idata.posterior.attrs["cloudposterior_convergence"]   # the stored report (JSON)
```

The dashboard shows the verdict and the projected draws to reach the target. With `notify=`, it is also pushed to your phone.

**Starting points.** `cp.cloud(..., init="prior")` starts each chain from a prior draw instead of a jittered default point. Dispersed starts are what let R-hat detect non-convergence (Gelman & Rubin 1992; Margossian et al. 2024).

**Many chains.** A big VM can run 16-32 chains with fewer draws each. Classic R-hat then needs far more draws than the ESS target. `until={"superchains": K}` starts the chains in K groups, each group sharing one starting point, and switches to **nested R-hat** (Margossian et al. 2024), which works for many short chains. `chains` must be a multiple of K.

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:

```python
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](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:

```python
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` / `"memory"` / `False` / `"disk"` / `Path(...)` |
| Cloud execution | off | `remote=True` / `False` |
| Live dashboard | on remotely, off locally | `dashboard=True` (authenticated localhost for local runs) / `False` |
| Push notifications | off | `notify=True` / `"topic"` / `{"server": ..., "topic": ...}` |
| Adaptive stopping + convergence verdict | off | `until=True` / `"draft"` / `{"ess": ..., "mcse": ..., ...}` (local or remote) |
| Chain initialization | sampler default | `init="prior"` |
| Re-run and replace the cache entry | off | `overwrite=True` |
| Project namespace (volume + dashboard) | working directory name | `project="name"` |
| Live progress display | **on** | `progress=True` / `False` |

Mix and match:

```python
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](https://modal.com). Modal provides fast container spin-up, automatic dependency packaging, and a generous free tier.

The Modal client ships as a dependency of cloudposterior, so it is already
installed; all you need is one-time browser auth:

```bash
modal setup
```

The backend is abstracted behind a `ComputeBackend` interface. Support for additional providers (AWS, GCP, SSH to your own machines) is planned.

---

## How it works

1. **Serialize** -- The model is serialized with cloudpickle + lz4 on the first `pm.sample()` call inside `with 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.
2. **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.
3. **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.
4. **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:

```python
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()`:

```python
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):

```python
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. `cp.sample()` also takes a narrower set of options than `cp.cloud()`: no `dashboard=`, `until=`, `overwrite=` or `project=`.

---

## Example

Clone and run locally (Jupyter or marimo) for the full live progress display.

- [examples/basics.ipynb](examples/basics.ipynb) -- cloud execution and GPU acceleration with the Minnesota Radon dataset
- [examples/caching.ipynb](examples/caching.ipynb) -- local and disk caching, model iteration
- [examples/monitoring.ipynb](examples/monitoring.ipynb) -- live dashboard and push notifications
- [examples/parallelism.ipynb](examples/parallelism.ipynb) -- fit many models in parallel with `cp.map` and 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:

```bash
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`:

```bash
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 and is covered by a test suite on both the PyMC 5 and PyMC 6 stacks, but expect rough edges. Contributions and feedback welcome.

## License

[Functional Source License, Version 1.1, ALv2 Future License](LICENSE.md) (FSL-1.1-ALv2). You may use, modify and self-host it for any purpose except offering a competing commercial product or service; each release becomes available under Apache-2.0 two years after it ships. Earlier releases published under MIT remain available under MIT.

Outside contributions require agreeing to a contributor license agreement; open an issue before sending a pull request.
