422 lines
11 KiB
Markdown
422 lines
11 KiB
Markdown
|
|
# 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
|