Skip to content

Commit 28255ec

Browse files
committed
Add oscillatory model fitting example
- Add comprehensive docs/examples/oscillatory_fitting.md with theory, implementation, results analysis, and best practices for fitting oscillatory models - Create plot generation script at docs/examples/scripts/generate_oscillatory_plot.py - Add standalone runnable example at docs/examples/scripts/oscillatory_standalone.py - Generate 4 publication-quality visualizations (fit, corner, predictive, residuals) - Update mkdocs.yml navigation to include new example under Examples section - Demonstrates fitting 5-parameter oscillatory model: y = (Ax + B) sin(ωx + φ) + noise - Includes parameter estimation (all recovered <15% error), R² = 0.9876 - Covers posterior predictive checks, residuals analysis, and convergence diagnostics
1 parent 10b4917 commit 28255ec

8 files changed

Lines changed: 1019 additions & 0 deletions
214 KB
Loading
100 KB
Loading
121 KB
Loading
80.6 KB
Loading

docs/examples/oscillatory_fitting.md

Lines changed: 401 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate plots for Oscillatory Model Fitting example.
4+
5+
This script demonstrates fitting an oscillatory model to synthetic data using
6+
tempest, with comprehensive visualization of results.
7+
"""
8+
9+
import os
10+
import sys
11+
12+
# Add project root to path
13+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
14+
15+
import numpy as np
16+
import tempest as tp
17+
import matplotlib
18+
import matplotlib.pyplot as plt
19+
20+
matplotlib.use("Agg") # Use non-interactive backend
21+
import corner
22+
23+
# Configuration
24+
# Determine project root and correct output directory
25+
script_dir = os.path.dirname(os.path.abspath(__file__))
26+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(script_dir)))
27+
output_dir = os.path.join(project_root, "docs", "examples", "assets", "examples")
28+
os.makedirs(output_dir, exist_ok=True)
29+
30+
print("Generating synthetic oscillatory data...")
31+
32+
# True parameters for oscillatory model
33+
A_true = 0.5 # Amplitude coefficient for linear trend
34+
B_true = 2.0 # Offset coefficient
35+
omega_true = 2 * np.pi # Frequency (period = 1)
36+
phi_true = np.pi / 4 # Phase offset
37+
sigma_true = 0.25 # 25% noise level
38+
39+
# Generate data
40+
np.random.seed(42)
41+
n_data = 50
42+
x = np.linspace(0, 3, n_data)
43+
y_true = (A_true * x + B_true) * np.sin(omega_true * x + phi_true)
44+
y_obs = y_true + np.random.normal(0, sigma_true, size=len(x))
45+
46+
print(f"Generated {n_data} data points with {sigma_true:.1%} noise")
47+
print(f"True parameters: A={A_true}, B={B_true}, ω={omega_true:.2f}, φ={phi_true:.2f}")
48+
49+
50+
# Model definition: Oscillatory Model (5 parameters: A, B, omega, phi, sigma)
51+
def log_likelihood_oscillatory(theta):
52+
"""Log-likelihood for oscillatory model."""
53+
A, B, omega, phi, sigma = theta
54+
y_pred = (A * x + B) * np.sin(omega * x + phi)
55+
return -0.5 * np.sum(((y_obs - y_pred) / sigma) ** 2 + np.log(2 * np.pi * sigma**2))
56+
57+
58+
def prior_transform_oscillatory(u):
59+
"""Prior transform for oscillatory model."""
60+
A = u[0] # U(0, 1)
61+
B = 5 * u[1] # U(0, 5)
62+
omega = 8 * np.pi * u[2] # U(0, 8π), wide enough for the problem
63+
phi = 2 * np.pi * u[3] # U(0, 2π)
64+
sigma = 10 ** (3 * u[4] - 2) # Log-uniform from 0.01 to 10
65+
return np.array([A, B, omega, phi, sigma])
66+
67+
68+
print("\nRunning Tempest sampler for oscillatory model...")
69+
sampler = tp.Sampler(
70+
prior_transform=prior_transform_oscillatory,
71+
log_likelihood=log_likelihood_oscillatory,
72+
n_dim=5,
73+
n_effective=512,
74+
n_active=256,
75+
random_state=42,
76+
)
77+
78+
sampler.run(n_total=4096, progress=False)
79+
samples, weights, logl = sampler.posterior()
80+
logz, logz_err = sampler.evidence()
81+
82+
print(f"Sampling completed: logZ = {logz:.2f}")
83+
if logz_err is not None:
84+
print(f" logZ error = {logz_err:.2f}")
85+
print(f"Number of posterior samples: {len(samples)}")
86+
87+
# Get best-fit parameters (weighted posterior mean)
88+
params = np.average(samples, weights=weights, axis=0)
89+
stds = np.sqrt(np.average((samples - params) ** 2, weights=weights, axis=0))
90+
91+
A_fit, B_fit, omega_fit, phi_fit, sigma_fit = params
92+
A_err, B_err, omega_err, phi_err, sigma_err = stds
93+
94+
print(f"\nParameter estimates:")
95+
print(f" A = {A_fit:.3f} ± {A_err:.3f} (true: {A_true})")
96+
print(f" B = {B_fit:.3f} ± {B_err:.3f} (true: {B_true})")
97+
print(f" ω = {omega_fit:.3f} ± {omega_err:.3f} (true: {omega_true:.3f})")
98+
print(f" φ = {phi_fit:.3f} ± {phi_err:.3f} (true: {phi_true:.3f})")
99+
print(f" σ = {sigma_fit:.3f} ± {sigma_err:.3f} (true: {sigma_true})")
100+
101+
# Generate predictions
102+
y_pred = (A_fit * x + B_fit) * np.sin(omega_fit * x + phi_fit)
103+
104+
print("\nGenerating visualizations...")
105+
106+
# Figure 1: Data, true model, and best-fit
107+
fig, ax = plt.subplots(figsize=(10, 6))
108+
ax.scatter(x, y_obs, alpha=0.6, s=50, color="black", label="Observed data", zorder=3)
109+
ax.plot(x, y_true, "g-", linewidth=2, label="True model", alpha=0.7, zorder=1)
110+
ax.plot(x, y_pred, "r-", linewidth=2, label="Best-fit model", zorder=2)
111+
ax.set_xlabel("x", fontsize=12)
112+
ax.set_ylabel("y", fontsize=12)
113+
ax.set_title("Oscillatory Model Fit", fontsize=14, fontweight="bold")
114+
ax.legend(fontsize=10, loc="upper right")
115+
ax.grid(True, alpha=0.3)
116+
117+
output_path = os.path.join(output_dir, "oscillatory_fit.png")
118+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
119+
plt.close()
120+
print(f"Saved: {output_path}")
121+
122+
# Figure 2: Corner plot of posterior distributions
123+
fig_corner = corner.corner(
124+
samples[:, :4], # Exclude sigma for cleaner visualization
125+
labels=["A", "B", r"$\omega$", r"$\phi$"],
126+
truths=[A_true, B_true, omega_true, phi_true],
127+
show_titles=True,
128+
title_fmt=".2f",
129+
quantiles=[0.16, 0.5, 0.84],
130+
title_kwargs={"fontsize": 10},
131+
label_kwargs={"fontsize": 12},
132+
)
133+
134+
output_path = os.path.join(output_dir, "oscillatory_corner.png")
135+
fig_corner.savefig(output_path, dpi=150, bbox_inches="tight")
136+
plt.close(fig_corner)
137+
print(f"Saved: {output_path}")
138+
139+
# Figure 3: Posterior predictive distribution with uncertainty bands
140+
print("\nGenerating posterior predictive samples...")
141+
n_predictive = 200
142+
idx = np.random.choice(len(samples), size=n_predictive, p=weights, replace=True)
143+
predictive_samples = samples[idx]
144+
145+
# Generate predictions for each sample
146+
x_dense = np.linspace(0, 3, 200)
147+
predictions = np.zeros((n_predictive, len(x_dense)))
148+
for i, theta in enumerate(predictive_samples):
149+
A, B, omega, phi, _ = theta
150+
predictions[i] = (A * x_dense + B) * np.sin(omega * x_dense + phi)
151+
152+
# Compute percentiles for credible intervals
153+
q16, q50, q84 = np.percentile(predictions, [16, 50, 84], axis=0)
154+
155+
fig, ax = plt.subplots(figsize=(10, 6))
156+
# Plot 68% credible interval
157+
ax.fill_between(
158+
x_dense, q16, q84, alpha=0.3, color="red", label="68% credible interval"
159+
)
160+
# Plot median prediction
161+
ax.plot(x_dense, q50, "r-", linewidth=2, label="Median prediction")
162+
# Plot observed data
163+
ax.scatter(x, y_obs, alpha=0.6, s=50, color="black", label="Observed data", zorder=3)
164+
# Plot true model
165+
ax.plot(
166+
x_dense,
167+
(A_true * x_dense + B_true) * np.sin(omega_true * x_dense + phi_true),
168+
"g--",
169+
linewidth=1,
170+
alpha=0.7,
171+
label="True model",
172+
)
173+
174+
ax.set_xlabel("x", fontsize=12)
175+
ax.set_ylabel("y", fontsize=12)
176+
ax.set_title("Posterior Predictive Distribution", fontsize=14, fontweight="bold")
177+
ax.legend(fontsize=10, loc="upper right")
178+
ax.grid(True, alpha=0.3)
179+
180+
output_path = os.path.join(output_dir, "oscillatory_predictive.png")
181+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
182+
plt.close()
183+
print(f"Saved: {output_path}")
184+
185+
# Figure 4: Residuals analysis
186+
residuals = y_obs - y_pred
187+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
188+
189+
# Residuals vs fitted
190+
ax1.scatter(y_pred, residuals, alpha=0.6, s=50, color="black")
191+
ax1.axhline(y=0, color="r", linestyle="--", alpha=0.7)
192+
ax1.set_xlabel("Fitted values", fontsize=12)
193+
ax1.set_ylabel("Residuals", fontsize=12)
194+
ax1.set_title("Residuals vs Fitted", fontsize=12, fontweight="bold")
195+
ax1.grid(True, alpha=0.3)
196+
197+
# Histogram of residuals
198+
ax2.hist(residuals, bins=15, alpha=0.7, color="gray", edgecolor="black", density=True)
199+
ax2.axvline(x=0, color="r", linestyle="--", alpha=0.7)
200+
# Overlay expected normal distribution
201+
x_norm = np.linspace(residuals.min(), residuals.max(), 100)
202+
y_norm = np.exp(-0.5 * (x_norm / sigma_fit) ** 2) / (sigma_fit * np.sqrt(2 * np.pi))
203+
ax2.plot(x_norm, y_norm, "r-", linewidth=2, label=f"N(0, {sigma_fit:.3f})")
204+
ax2.set_xlabel("Residuals", fontsize=12)
205+
ax2.set_ylabel("Density", fontsize=12)
206+
ax2.set_title("Residual Distribution", fontsize=12, fontweight="bold")
207+
ax2.legend()
208+
ax2.grid(True, alpha=0.3)
209+
210+
plt.tight_layout()
211+
output_path = os.path.join(output_dir, "oscillatory_residuals.png")
212+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
213+
plt.close()
214+
print(f"Saved: {output_path}")
215+
216+
print("\nAll visualizations generated successfully!")

0 commit comments

Comments
 (0)