Files
optimiz-rs/docs/mcmc.md
T
2025-12-03 22:08:08 +01:00

422 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MCMC Sampling API
## Overview
The MCMC (Markov Chain Monte Carlo) module implements the Metropolis-Hastings algorithm for sampling from arbitrary probability distributions. This is particularly useful for Bayesian parameter estimation, posterior inference, and uncertainty quantification.
## Function: `mcmc_sample`
```python
from optimizr import mcmc_sample
```
### Signature
```python
mcmc_sample(
log_likelihood_fn: Callable[[List[float], List[float]], float],
data: np.ndarray,
initial_params: np.ndarray,
param_bounds: List[Tuple[float, float]],
n_samples: int = 10000,
burn_in: int = 1000,
proposal_std: float = 0.1,
) -> np.ndarray
```
### Parameters
- **`log_likelihood_fn`** (callable): Function that computes log P(data | params).
- **Signature**: `log_likelihood_fn(params: list, data: list) -> float`
- Should return the natural logarithm of the likelihood.
- Higher values indicate better fit.
- **`data`** (np.ndarray): Observed data passed to the log-likelihood function.
- **`initial_params`** (np.ndarray): Starting parameter values. Should be a 1D array.
- **`param_bounds`** (List[Tuple[float, float]]): List of (min, max) bounds for each parameter. Must have the same length as `initial_params`.
- **`n_samples`** (int, optional): Number of samples to generate after burn-in. Default is 10,000.
- **`burn_in`** (int, optional): Number of initial samples to discard. Default is 1,000.
- **`proposal_std`** (float, optional): Standard deviation of Gaussian random walk proposals. Default is 0.1.
### Returns
- **`samples`** (np.ndarray): Array of shape `(n_samples, n_params)` containing parameter samples from the posterior distribution.
### Raises
- `ValueError`: If `initial_params` and `param_bounds` have different lengths.
## Basic Example
```python
import numpy as np
from optimizr import mcmc_sample
# Define log-likelihood for Gaussian model
def log_likelihood(params, data):
mu, sigma = params
if sigma <= 0:
return -np.inf
residuals = (data - mu) / sigma
return -0.5 * np.sum(residuals**2) - len(data) * np.log(sigma)
# Generate synthetic data
np.random.seed(42)
true_mu, true_sigma = 2.5, 1.2
data = np.random.normal(true_mu, true_sigma, 100)
# Sample from posterior
samples = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=np.array([0.0, 1.0]),
param_bounds=[(-10, 10), (0.1, 10)],
n_samples=10000,
burn_in=1000,
proposal_std=0.1
)
# Analyze results
print(f"True mean: {true_mu:.2f}, Estimated: {np.mean(samples[:, 0]):.2f}")
print(f"True std: {true_sigma:.2f}, Estimated: {np.mean(samples[:, 1]):.2f}")
# Posterior credible intervals
print(f"Mean 95% CI: {np.percentile(samples[:, 0], [2.5, 97.5])}")
print(f"Std 95% CI: {np.percentile(samples[:, 1], [2.5, 97.5])}")
```
## Advanced Examples
### 1. Linear Regression
```python
import numpy as np
from optimizr import mcmc_sample
# Log-likelihood for linear regression
def log_likelihood(params, data):
x, y = data
slope, intercept, sigma = params
if sigma <= 0:
return -np.inf
predictions = slope * x + intercept
residuals = (y - predictions) / sigma
return -0.5 * np.sum(residuals**2) - len(y) * np.log(sigma)
# Generate data
np.random.seed(42)
x = np.linspace(0, 10, 100)
y = 2.5 * x + 1.0 + np.random.normal(0, 0.5, 100)
# Sample from posterior
samples = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=[x, y],
initial_params=np.array([1.0, 0.0, 1.0]),
param_bounds=[(-10, 10), (-10, 10), (0.01, 10)],
n_samples=20000,
burn_in=2000,
proposal_std=0.05
)
print(f"Slope: {np.mean(samples[:, 0]):.3f} ± {np.std(samples[:, 0]):.3f}")
print(f"Intercept: {np.mean(samples[:, 1]):.3f} ± {np.std(samples[:, 1]):.3f}")
print(f"Sigma: {np.mean(samples[:, 2]):.3f} ± {np.std(samples[:, 2]):.3f}")
```
### 2. Mixture Model
```python
import numpy as np
from optimizr import mcmc_sample
from scipy.stats import norm
def log_likelihood(params, data):
"""Two-component Gaussian mixture"""
mu1, sigma1, mu2, sigma2, weight1 = params
# Ensure valid parameters
if sigma1 <= 0 or sigma2 <= 0:
return -np.inf
if not (0 <= weight1 <= 1):
return -np.inf
weight2 = 1 - weight1
# Mixture likelihood
likelihood = (weight1 * norm.pdf(data, mu1, sigma1) +
weight2 * norm.pdf(data, mu2, sigma2))
return np.sum(np.log(likelihood + 1e-10))
# Generate mixture data
np.random.seed(42)
data = np.concatenate([
np.random.normal(0, 1, 300),
np.random.normal(5, 1.5, 200)
])
samples = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=np.array([0.0, 1.0, 5.0, 1.5, 0.6]),
param_bounds=[(-5, 5), (0.1, 5), (0, 10), (0.1, 5), (0.1, 0.9)],
n_samples=15000,
burn_in=3000,
proposal_std=0.15
)
print("Component 1:")
print(f" Mean: {np.mean(samples[:, 0]):.2f}")
print(f" Std: {np.mean(samples[:, 1]):.2f}")
print("\nComponent 2:")
print(f" Mean: {np.mean(samples[:, 2]):.2f}")
print(f" Std: {np.mean(samples[:, 3]):.2f}")
print(f"\nMixing weight: {np.mean(samples[:, 4]):.2f}")
```
### 3. Time Series Model (AR process)
```python
import numpy as np
from optimizr import mcmc_sample
def log_likelihood(params, data):
"""Autoregressive AR(2) model"""
phi1, phi2, sigma = params
if sigma <= 0:
return -np.inf
# Check stationarity conditions
if abs(phi1) + abs(phi2) >= 1:
return -np.inf
# Compute residuals
predictions = phi1 * data[1:-1] + phi2 * data[:-2]
residuals = (data[2:] - predictions) / sigma
return -0.5 * np.sum(residuals**2) - (len(data) - 2) * np.log(sigma)
# Generate AR(2) process
np.random.seed(42)
n = 500
phi1_true, phi2_true = 0.6, -0.2
sigma_true = 0.5
data = np.zeros(n)
for t in range(2, n):
data[t] = (phi1_true * data[t-1] +
phi2_true * data[t-2] +
np.random.normal(0, sigma_true))
samples = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=np.array([0.5, -0.1, 0.5]),
param_bounds=[(-0.99, 0.99), (-0.99, 0.99), (0.01, 5)],
n_samples=15000,
burn_in=2000,
proposal_std=0.05
)
print(f"φ₁: True={phi1_true:.2f}, Est={np.mean(samples[:, 0]):.2f}")
print(f"φ₂: True={phi2_true:.2f}, Est={np.mean(samples[:, 1]):.2f}")
print(f"σ: True={sigma_true:.2f}, Est={np.mean(samples[:, 2]):.2f}")
```
## Diagnostics and Visualization
### Trace Plots
```python
import matplotlib.pyplot as plt
fig, axes = plt.subplots(3, 1, figsize=(10, 8))
param_names = ['Mean', 'Std Dev', 'Parameter 3']
for i, (ax, name) in enumerate(zip(axes, param_names)):
ax.plot(samples[:, i], alpha=0.7)
ax.set_ylabel(name)
ax.axhline(np.mean(samples[:, i]), color='r', linestyle='--',
label='Mean')
ax.legend()
axes[-1].set_xlabel('Iteration')
plt.tight_layout()
plt.show()
```
### Posterior Distributions
```python
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for i, ax in enumerate(axes):
ax.hist(samples[:, i], bins=50, density=True, alpha=0.7)
ax.axvline(np.mean(samples[:, i]), color='r', linestyle='--',
label=f'Mean: {np.mean(samples[:, i]):.3f}')
ax.set_xlabel(f'Parameter {i+1}')
ax.set_ylabel('Density')
ax.legend()
plt.tight_layout()
plt.show()
```
### Autocorrelation
```python
from scipy.stats import acf
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
for i, ax in enumerate(axes):
autocorr = acf(samples[:, i], nlags=100)
ax.plot(autocorr)
ax.axhline(0, color='k', linestyle='--', alpha=0.3)
ax.set_xlabel('Lag')
ax.set_ylabel('Autocorrelation')
ax.set_title(f'Parameter {i+1}')
plt.tight_layout()
plt.show()
```
### Acceptance Rate
```python
# Estimate acceptance rate from consecutive samples
def acceptance_rate(samples):
changes = np.sum(np.diff(samples, axis=0) != 0, axis=1)
return np.mean(changes > 0)
rate = acceptance_rate(samples)
print(f"Acceptance rate: {rate:.2%}")
# Ideal range: 20-40% for Metropolis-Hastings
if rate < 0.15:
print("⚠ Acceptance rate too low. Try decreasing proposal_std.")
elif rate > 0.50:
print("⚠ Acceptance rate too high. Try increasing proposal_std.")
else:
print("✓ Acceptance rate is in good range.")
```
## Performance Notes
- **Rust Backend**: When available, MCMC sampling is 50-100x faster than pure Python implementations.
- **Python Fallback**: A pure NumPy fallback is automatically used if Rust is unavailable.
- **Proposal Tuning**: The `proposal_std` parameter significantly affects convergence:
- Too small: Slow exploration, high acceptance rate
- Too large: Poor acceptance rate, slow convergence
- Optimal: 20-40% acceptance rate
## Tips and Best Practices
### 1. Choosing Burn-in Period
```python
# Run a short chain to visualize convergence
test_samples = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=initial_params,
param_bounds=bounds,
n_samples=5000,
burn_in=0, # Keep all samples for inspection
proposal_std=0.1
)
# Plot to determine burn-in
plt.plot(test_samples[:, 0])
plt.xlabel('Iteration')
plt.ylabel('Parameter')
plt.title('Determine burn-in period')
plt.show()
```
### 2. Multiple Chains
```python
# Run multiple chains with different starting points
n_chains = 4
all_samples = []
for i in range(n_chains):
# Random initialization
init = np.random.uniform(
[b[0] for b in bounds],
[b[1] for b in bounds]
)
samples = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=init,
param_bounds=bounds,
n_samples=5000,
burn_in=1000
)
all_samples.append(samples)
# Check convergence across chains
means = [np.mean(s[:, 0]) for s in all_samples]
print(f"Chain means: {means}")
print(f"Variance: {np.var(means):.6f}")
```
### 3. Adaptive Proposal
```python
# Start with exploration, then refine
samples_phase1 = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=initial_params,
param_bounds=bounds,
n_samples=5000,
burn_in=1000,
proposal_std=0.2 # Larger for exploration
)
# Use posterior mean as new starting point
new_init = np.mean(samples_phase1, axis=0)
samples_phase2 = mcmc_sample(
log_likelihood_fn=log_likelihood,
data=data,
initial_params=new_init,
param_bounds=bounds,
n_samples=10000,
burn_in=1000,
proposal_std=0.05 # Smaller for refinement
)
```
## Common Issues and Solutions
| Issue | Cause | Solution |
|-------|-------|----------|
| Chains don't converge | Poor initialization | Use multiple chains or better starting values |
| High autocorrelation | Proposal too small | Increase `proposal_std` |
| Low acceptance rate | Proposal too large | Decrease `proposal_std` |
| Bimodal posterior | Multiple modes | Use longer chains or parallel tempering |
| Numerical errors | Overflow in likelihood | Use log-likelihood correctly |
## See Also
- [HMM API](hmm.md) - For regime detection and sequence modeling
- [MCMC Theory](theory/mcmc.md) - Mathematical background
- [Examples](../examples/) - Complete working examples