Frequentist uncertainty vs. Bayesian uncertainty analysis#
Mark Bakker, TU Delft & Raoul Collenteur, Eawag, February, 2025
In this notebook, the fit and uncertainty are compared for pastas models solved with least squares (frequentist uncertainty) and with MCMC (Bayesian uncertainty).
Besides Pastas the following Python Packages have to be installed to run this notebook:
import corner
import emcee
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pastas as ps
ps.set_log_level("ERROR")
ps.show_versions()
Pastas version: 1.12.0
Python version: 3.11.12
NumPy version: 2.3.4
Pandas version: 2.3.3
SciPy version: 1.16.3
Matplotlib version: 3.10.7
Numba version: 0.62.1
1. A ‘regular’ Pastas Model#
The first step is to create a Pastas Model with a linear RechargeModel and a Gamma response function to simulate the effect of precipitation and evaporation on the heads. The AR1 noise model is used. We first estimate the model parameters using the standard least-squares approach.
head = pd.read_csv(
"data/B32C0639001.csv", parse_dates=["date"], index_col="date"
).squeeze()
head = head["1990":] # use data from 1990 on for this example
evap = pd.read_csv("data/evap_260.csv", index_col=0, parse_dates=[0]).squeeze()
rain = pd.read_csv("data/rain_260.csv", index_col=0, parse_dates=[0]).squeeze()
ml1 = ps.Model(head)
ml1.add_noisemodel(ps.ArNoiseModel())
rm = ps.RechargeModel(
rain, evap, recharge=ps.rch.Linear(), rfunc=ps.Gamma(), name="rch"
)
ml1.add_stressmodel(rm)
ml1.solve()
ax = ml1.plots.results(figsize=(10, 4))
Fit report head Fit Statistics
==================================================
nfev 31 EVP 84.76
nobs 351 R2 0.85
noise True RMSE 0.08
tmin 1990-01-02 00:00:00 AICc -1976.57
tmax 2005-10-14 00:00:00 BIC -1953.65
freq D Obj 0.61
freq_obs None ___
warmup 3650 days 00:00:00 Interp. No
solver LeastSquares weights Yes
Parameters (6 optimized)
==================================================
optimal initial vary
rch_A 0.306929 0.198424 True
rch_n 0.873052 1.000000 True
rch_a 145.630892 10.000000 True
rch_f -0.637841 -1.000000 True
constant_d 0.935046 1.338063 True
noise_alpha 41.305065 15.000000 True
The diagnostics show that the noise meets the statistical requirements for uncertainty analysis reasonably well.
ml1.plots.diagnostics();
The estimated least squares parameters and standard errors are stored for later reference
ls_params = ml1.parameters[["optimal", "stderr"]].copy()
ls_params.rename(columns={"optimal": "ls_opt", "stderr": "ls_sig"}, inplace=True)
ls_params
| ls_opt | ls_sig | |
|---|---|---|
| rch_A | 0.306929 | 0.021354 |
| rch_n | 0.873052 | 0.026222 |
| rch_a | 145.630892 | 18.352602 |
| rch_f | -0.637841 | 0.069333 |
| constant_d | 0.935046 | 0.048544 |
| noise_alpha | 41.305065 | 6.342946 |
# Compute prediction interval Pastas
pi = ml1.solver.prediction_interval(n=1000)
ax = ml1.plot(figsize=(10, 3))
ax.fill_between(pi.index, pi.iloc[:, 0], pi.iloc[:, 1], color="lightgray")
ax.legend(["Observations", "Simulation", "95% Prediction interval"], ncol=3, loc=2)
pi_pasta = np.mean(pi[0.975] - pi[0.025])
print(f"Mean prediction interval width: {pi_pasta:.3f} m")
print(f"Prediction interval coverage probability: {ps.stats.picp(head, pi): .3f}")
Mean prediction interval width: 0.325 m
Prediction interval coverage probability: 0.946
2. Use the EmceeSolver#
We will now use MCMC to estimate the model parameters and their uncertainties. The EmceeSolve solver wraps the Emcee package, which implements different versions of MCMC. A good understanding of Emcee helps when using this solver, so it comes recommended to check out their documentation as well.
We start by making a pastas model with a linear recharge model and a Gamma response function. No noise model is added, as this is taken care of in the likelihood function. The model is solved using the regular solve (least squares) to have a good estimate of the starting values of the parameters.
ml2 = ps.Model(head)
rm = ps.RechargeModel(
rain, evap, recharge=ps.rch.Linear(), rfunc=ps.Gamma(), name="rch"
)
ml2.add_stressmodel(rm)
ml2.solve()
Fit report head Fit Statistics
==================================================
nfev 14 EVP 86.58
nobs 351 R2 0.87
noise False RMSE 0.08
tmin 1990-01-02 00:00:00 AICc -1797.03
tmax 2005-10-14 00:00:00 BIC -1777.90
freq D Obj 1.02
freq_obs None ___
warmup 3650 days 00:00:00 Interp. No
solver LeastSquares weights Yes
Parameters (5 optimized)
==================================================
optimal initial vary
rch_A 0.314022 0.198424 True
rch_n 0.800954 1.000000 True
rch_a 235.006908 10.000000 True
rch_f -0.898115 -1.000000 True
constant_d 1.051880 1.338063 True
To set up the EmceeSolve solver, a number of decisions need to be made:
Select the priors of the parameters
Select a (log) likelihood function
Select the number of steps and thinning
2a. Priors#
The first step is to select the priors of the parameters. This is done by using the ml.set_parameter method and the dist argument (from distribution). Any distribution from scipy.stats can be chosen url, for example uniform, norm, or lognorm. Here, we select normal distributions for the priors. Currently, pastas will use the initial value of the parameter for the loc argument of the distribution (e.g., the mean of a normal distribution), and the stderr as the scale argument (e.g., the standard deviation of a normal distribution). Only for the parameters with a uniform distribution, the pmin and pmax values are used to determine a uniform prior. By default, all parameters are assigned a uniform prior.
# Set the initial parameters to a normal distribution
ml2.parameters["initial"] = ml2.parameters[
"optimal"
] # set initial value to the optimal from least squares for good starting point
ml2.parameters["stderr"] = (
2 * ml2.parameters["stderr"]
) # this column is used (for now) to set the scale of the normal distribution
for name in ml2.parameters.index:
ml2.set_parameter(
name,
dist="norm",
)
ml2.parameters
| initial | pmin | pmax | vary | name | dist | stderr | optimal | |
|---|---|---|---|---|---|---|---|---|
| rch_A | 0.314022 | 0.00001 | 19.842364 | True | rch | norm | 0.022942 | 0.314022 |
| rch_n | 0.800954 | 0.10000 | 5.000000 | True | rch | norm | 0.049725 | 0.800954 |
| rch_a | 235.006908 | 0.01000 | 10000.000000 | True | rch | norm | 46.255306 | 235.006908 |
| rch_f | -0.898115 | -2.00000 | 0.000000 | True | rch | norm | 0.107650 | -0.898115 |
| constant_d | 1.051880 | NaN | NaN | True | constant | norm | 0.057944 | 1.051880 |
2b. Create the solver instance#
The next step is to create an instance of the EmceeSolve solver class. At this stage all the settings need to be provided on how the Ensemble Sampler is created (https://emcee.readthedocs.io/en/stable/user/sampler/). Important settings are the nwalkers, the moves, the objective_function. More advanced options are to parallelize the MCMC algorithm (parallel=True), and to set a backend to store the results. Here’s an example:
# Choose the objective function
ln_prob = ps.objfunc.GaussianLikelihoodAr1()
# Create the EmceeSolver with some settings
s = ps.EmceeSolve(
nwalkers=20,
moves=emcee.moves.DEMove(),
objective_function=ln_prob,
progress_bar=True,
parallel=False,
)
In the above code we created an EmceeSolve instance with 20 walkers, which take steps according to the DEMove move algorithm (see Emcee docs), and a Gaussian likelihood function that assumes AR1 correlated errors. Different objective functions are available, see the Pastas documentation on the different options.
Depending on the likelihood function, a number of additional parameters need to be inferred. These parameters are not added to the Pastas Model instance, but are available from the solver object. Using the set_parameter method of the solver, these parameters can be changed. In this example where we use the GaussianLikelihoodAr1 function, the \(\sigma^2\) and \(\phi\) are estimated; the unknown standard deviation of the errors and the autoregressive parameter.
s.parameters
| initial | pmin | pmax | vary | stderr | name | dist | |
|---|---|---|---|---|---|---|---|
| ln_var | 0.05 | 1.000000e-10 | 1.00000 | True | 0.01 | ln | uniform |
| ln_phi | 0.50 | 1.000000e-10 | 0.99999 | True | 0.20 | ln | uniform |
sigsq = ml1.noise().std() ** 2
s.set_parameter("ln_var", initial=sigsq, vary=True)
s.parameters.loc["ln_var", "stderr"] = stderr = sigsq / 8
s.parameters
| initial | pmin | pmax | vary | stderr | name | dist | |
|---|---|---|---|---|---|---|---|
| ln_var | 0.00347 | 1.000000e-10 | 1.00000 | True | 0.000434 | ln | uniform |
| ln_phi | 0.50000 | 1.000000e-10 | 0.99999 | True | 0.200000 | ln | uniform |
2c. Run the solver and solve the model#
After setting the parameters and creating a EmceeSolve solver instance we are now ready to run the MCMC analysis. We can do this by running ml.solve. We can pass the same parameters that we normally provide to this method (e.g., tmin or fit_constant). Here we use the initial parameters from our least-square solve, and do not fit a noise model, because we take autocorrelated errors into account through the likelihood function.
All the arguments that are not used by ml.solve, for example steps and tune, are passed on to the run_mcmc method from the sampler (see Emcee docs). The most important is the steps argument, that determines how many steps each of the walkers takes.
# Use the solver to run MCMC
ml2.solve(
solver=s,
initial=False,
tmin="1990",
steps=1000,
tune=True,
report=False,
)
emcee: Exception while calling your likelihood function:
params: [ 2.56441169e-01 8.06976911e-01 1.65566211e+02 -7.33912724e-01
1.04653152e+00 3.83126845e-03 7.13755262e-01]
args: (False, None, None)
kwargs: {}
exception:
0%| | 0/1000 [00:00<?, ?it/s]
0%| | 3/1000 [00:00<00:49, 20.25it/s]
1%| | 6/1000 [00:00<00:48, 20.39it/s]
1%| | 9/1000 [00:00<00:47, 20.68it/s]
1%| | 12/1000 [00:00<00:47, 20.97it/s]
2%|▏ | 15/1000 [00:00<00:47, 20.93it/s]
2%|▏ | 18/1000 [00:00<00:46, 21.03it/s]
2%|▏ | 21/1000 [00:01<00:46, 20.95it/s]
2%|▏ | 24/1000 [00:01<00:47, 20.75it/s]
3%|▎ | 27/1000 [00:01<00:47, 20.67it/s]
3%|▎ | 30/1000 [00:01<00:47, 20.42it/s]
3%|▎ | 33/1000 [00:01<00:47, 20.54it/s]
4%|▎ | 36/1000 [00:01<00:46, 20.66it/s]
4%|▍ | 39/1000 [00:01<00:46, 20.84it/s]
4%|▍ | 42/1000 [00:02<00:45, 21.02it/s]
4%|▍ | 45/1000 [00:02<00:45, 21.19it/s]
5%|▍ | 48/1000 [00:02<00:44, 21.37it/s]
5%|▌ | 51/1000 [00:02<00:44, 21.36it/s]
5%|▌ | 54/1000 [00:02<00:44, 21.20it/s]
6%|▌ | 57/1000 [00:02<00:44, 21.11it/s]
6%|▌ | 60/1000 [00:02<00:44, 21.12it/s]
6%|▋ | 63/1000 [00:03<00:44, 21.17it/s]
7%|▋ | 66/1000 [00:03<00:44, 21.19it/s]
7%|▋ | 69/1000 [00:03<00:43, 21.23it/s]
7%|▋ | 72/1000 [00:03<00:44, 21.02it/s]
8%|▊ | 75/1000 [00:03<00:44, 21.00it/s]
8%|▊ | 78/1000 [00:03<00:43, 21.28it/s]
8%|▊ | 81/1000 [00:03<00:42, 21.52it/s]
8%|▊ | 84/1000 [00:03<00:42, 21.46it/s]
9%|▊ | 87/1000 [00:04<00:42, 21.30it/s]
9%|▉ | 90/1000 [00:04<00:42, 21.21it/s]
9%|▉ | 93/1000 [00:04<00:42, 21.11it/s]
10%|▉ | 96/1000 [00:04<00:42, 21.10it/s]
10%|▉ | 99/1000 [00:04<00:42, 21.26it/s]
10%|█ | 102/1000 [00:04<00:41, 21.39it/s]
10%|█ | 105/1000 [00:04<00:41, 21.35it/s]
11%|█ | 108/1000 [00:05<00:41, 21.41it/s]
11%|█ | 111/1000 [00:05<00:40, 21.72it/s]
11%|█▏ | 114/1000 [00:05<00:40, 21.70it/s]
12%|█▏ | 117/1000 [00:05<00:41, 21.34it/s]
12%|█▏ | 120/1000 [00:05<00:41, 21.25it/s]
12%|█▏ | 123/1000 [00:05<00:41, 21.39it/s]
13%|█▎ | 126/1000 [00:05<00:40, 21.60it/s]
13%|█▎ | 129/1000 [00:06<00:40, 21.50it/s]
13%|█▎ | 132/1000 [00:06<00:40, 21.36it/s]
14%|█▎ | 135/1000 [00:06<00:40, 21.22it/s]
14%|█▍ | 138/1000 [00:06<00:40, 21.19it/s]
14%|█▍ | 141/1000 [00:06<00:40, 21.42it/s]
14%|█▍ | 144/1000 [00:06<00:39, 21.45it/s]
15%|█▍ | 147/1000 [00:06<00:40, 21.28it/s]
15%|█▌ | 150/1000 [00:07<00:40, 21.03it/s]
15%|█▌ | 153/1000 [00:07<00:40, 21.01it/s]
16%|█▌ | 156/1000 [00:07<00:40, 21.09it/s]
16%|█▌ | 159/1000 [00:07<00:39, 21.17it/s]
16%|█▌ | 162/1000 [00:07<00:39, 21.14it/s]
16%|█▋ | 165/1000 [00:07<00:39, 21.17it/s]
17%|█▋ | 168/1000 [00:07<00:39, 21.17it/s]
17%|█▋ | 171/1000 [00:08<00:39, 21.11it/s]
17%|█▋ | 174/1000 [00:08<00:39, 21.08it/s]
18%|█▊ | 177/1000 [00:08<00:39, 21.00it/s]
18%|█▊ | 180/1000 [00:08<00:39, 21.02it/s]
18%|█▊ | 183/1000 [00:08<00:38, 20.97it/s]
19%|█▊ | 186/1000 [00:08<00:39, 20.71it/s]
19%|█▉ | 189/1000 [00:08<00:39, 20.64it/s]
19%|█▉ | 192/1000 [00:09<00:39, 20.57it/s]
20%|█▉ | 195/1000 [00:09<00:38, 20.68it/s]
20%|█▉ | 198/1000 [00:09<00:39, 20.51it/s]
20%|██ | 201/1000 [00:09<00:39, 20.49it/s]
20%|██ | 204/1000 [00:09<00:39, 20.33it/s]
21%|██ | 207/1000 [00:09<00:38, 20.52it/s]
21%|██ | 210/1000 [00:09<00:38, 20.69it/s]
21%|██▏ | 213/1000 [00:10<00:37, 20.71it/s]
22%|██▏ | 216/1000 [00:10<00:37, 21.01it/s]
22%|██▏ | 219/1000 [00:10<00:36, 21.16it/s]
22%|██▏ | 222/1000 [00:10<00:36, 21.35it/s]
22%|██▎ | 225/1000 [00:10<00:36, 21.52it/s]
23%|██▎ | 228/1000 [00:10<00:36, 21.38it/s]
23%|██▎ | 231/1000 [00:10<00:36, 21.24it/s]
23%|██▎ | 234/1000 [00:11<00:36, 21.11it/s]
24%|██▎ | 237/1000 [00:11<00:36, 20.89it/s]
24%|██▍ | 240/1000 [00:11<00:36, 20.60it/s]
24%|██▍ | 243/1000 [00:11<00:37, 20.40it/s]
25%|██▍ | 246/1000 [00:11<00:36, 20.40it/s]
25%|██▍ | 249/1000 [00:11<00:36, 20.43it/s]
25%|██▌ | 252/1000 [00:11<00:36, 20.64it/s]
26%|██▌ | 255/1000 [00:12<00:35, 20.81it/s]
26%|██▌ | 258/1000 [00:12<00:35, 21.01it/s]
26%|██▌ | 261/1000 [00:12<00:35, 20.91it/s]
26%|██▋ | 264/1000 [00:12<00:35, 20.96it/s]
27%|██▋ | 267/1000 [00:12<00:34, 21.20it/s]
27%|██▋ | 270/1000 [00:12<00:34, 21.02it/s]
27%|██▋ | 273/1000 [00:12<00:34, 20.87it/s]
28%|██▊ | 276/1000 [00:13<00:34, 20.74it/s]
28%|██▊ | 279/1000 [00:13<00:34, 20.73it/s]
28%|██▊ | 282/1000 [00:13<00:35, 20.51it/s]
28%|██▊ | 285/1000 [00:13<00:35, 20.43it/s]
29%|██▉ | 288/1000 [00:13<00:34, 20.61it/s]
29%|██▉ | 291/1000 [00:13<00:34, 20.72it/s]
29%|██▉ | 294/1000 [00:14<00:34, 20.76it/s]
30%|██▉ | 297/1000 [00:14<00:33, 20.78it/s]
30%|███ | 300/1000 [00:14<00:33, 20.79it/s]
30%|███ | 303/1000 [00:14<00:33, 20.77it/s]
31%|███ | 306/1000 [00:14<00:33, 21.02it/s]
31%|███ | 309/1000 [00:14<00:32, 21.13it/s]
31%|███ | 312/1000 [00:14<00:32, 21.12it/s]
32%|███▏ | 315/1000 [00:14<00:32, 21.18it/s]
32%|███▏ | 318/1000 [00:15<00:32, 20.87it/s]
32%|███▏ | 321/1000 [00:15<00:32, 20.97it/s]
32%|███▏ | 324/1000 [00:15<00:32, 20.83it/s]
33%|███▎ | 327/1000 [00:15<00:32, 20.89it/s]
33%|███▎ | 330/1000 [00:15<00:32, 20.86it/s]
33%|███▎ | 333/1000 [00:15<00:31, 20.92it/s]
34%|███▎ | 336/1000 [00:16<00:31, 21.00it/s]
34%|███▍ | 339/1000 [00:16<00:31, 21.00it/s]
34%|███▍ | 342/1000 [00:16<00:31, 21.08it/s]
34%|███▍ | 345/1000 [00:16<00:31, 21.06it/s]
35%|███▍ | 348/1000 [00:16<00:30, 21.05it/s]
35%|███▌ | 351/1000 [00:16<00:31, 20.92it/s]
35%|███▌ | 354/1000 [00:16<00:31, 20.82it/s]
36%|███▌ | 357/1000 [00:17<00:30, 20.74it/s]
36%|███▌ | 360/1000 [00:17<00:30, 20.65it/s]
36%|███▋ | 363/1000 [00:17<00:30, 20.91it/s]
37%|███▋ | 366/1000 [00:17<00:30, 20.67it/s]
37%|███▋ | 369/1000 [00:17<00:30, 20.61it/s]
37%|███▋ | 372/1000 [00:17<00:30, 20.80it/s]
38%|███▊ | 375/1000 [00:17<00:29, 20.98it/s]
38%|███▊ | 378/1000 [00:18<00:29, 20.91it/s]
38%|███▊ | 381/1000 [00:18<00:29, 20.85it/s]
38%|███▊ | 384/1000 [00:18<00:29, 20.80it/s]
39%|███▊ | 387/1000 [00:18<00:29, 20.66it/s]
39%|███▉ | 390/1000 [00:18<00:29, 20.71it/s]
39%|███▉ | 393/1000 [00:18<00:29, 20.53it/s]
40%|███▉ | 396/1000 [00:18<00:29, 20.49it/s]
40%|███▉ | 399/1000 [00:19<00:29, 20.25it/s]
40%|████ | 402/1000 [00:19<00:29, 20.06it/s]
40%|████ | 405/1000 [00:19<00:29, 20.11it/s]
41%|████ | 408/1000 [00:19<00:29, 20.05it/s]
41%|████ | 411/1000 [00:19<00:29, 20.10it/s]
41%|████▏ | 414/1000 [00:19<00:28, 20.39it/s]
42%|████▏ | 417/1000 [00:19<00:28, 20.68it/s]
42%|████▏ | 420/1000 [00:20<00:27, 20.92it/s]
42%|████▏ | 423/1000 [00:20<00:27, 21.03it/s]
43%|████▎ | 426/1000 [00:20<00:27, 21.25it/s]
43%|████▎ | 429/1000 [00:20<00:26, 21.18it/s]
43%|████▎ | 432/1000 [00:20<00:26, 21.21it/s]
44%|████▎ | 435/1000 [00:20<00:26, 21.17it/s]
44%|████▍ | 438/1000 [00:20<00:26, 21.21it/s]
44%|████▍ | 441/1000 [00:21<00:26, 20.86it/s]
44%|████▍ | 444/1000 [00:21<00:26, 20.61it/s]
45%|████▍ | 447/1000 [00:21<00:26, 20.51it/s]
45%|████▌ | 450/1000 [00:21<00:27, 20.27it/s]
45%|████▌ | 453/1000 [00:21<00:26, 20.35it/s]
46%|████▌ | 456/1000 [00:21<00:26, 20.53it/s]
46%|████▌ | 459/1000 [00:21<00:25, 20.82it/s]
46%|████▌ | 462/1000 [00:22<00:25, 21.11it/s]
46%|████▋ | 465/1000 [00:22<00:25, 21.18it/s]
47%|████▋ | 468/1000 [00:22<00:25, 21.23it/s]
47%|████▋ | 471/1000 [00:22<00:24, 21.27it/s]
47%|████▋ | 474/1000 [00:22<00:24, 21.30it/s]
48%|████▊ | 477/1000 [00:22<00:24, 21.25it/s]
48%|████▊ | 480/1000 [00:22<00:24, 21.30it/s]
48%|████▊ | 483/1000 [00:23<00:24, 21.03it/s]
49%|████▊ | 486/1000 [00:23<00:24, 20.83it/s]
49%|████▉ | 489/1000 [00:23<00:24, 20.71it/s]
49%|████▉ | 492/1000 [00:23<00:24, 20.67it/s]
50%|████▉ | 495/1000 [00:23<00:24, 20.82it/s]
50%|████▉ | 498/1000 [00:23<00:23, 21.16it/s]
50%|█████ | 501/1000 [00:23<00:23, 21.34it/s]
50%|█████ | 504/1000 [00:24<00:23, 21.29it/s]
51%|█████ | 507/1000 [00:24<00:23, 21.06it/s]
51%|█████ | 510/1000 [00:24<00:23, 21.03it/s]
51%|█████▏ | 513/1000 [00:24<00:23, 21.06it/s]
52%|█████▏ | 516/1000 [00:24<00:23, 20.76it/s]
52%|█████▏ | 519/1000 [00:24<00:23, 20.67it/s]
52%|█████▏ | 522/1000 [00:24<00:23, 20.76it/s]
52%|█████▎ | 525/1000 [00:25<00:23, 20.63it/s]
53%|█████▎ | 528/1000 [00:25<00:22, 20.64it/s]
53%|█████▎ | 531/1000 [00:25<00:22, 20.69it/s]
53%|█████▎ | 534/1000 [00:25<00:22, 20.79it/s]
54%|█████▎ | 537/1000 [00:25<00:21, 21.07it/s]
54%|█████▍ | 540/1000 [00:25<00:21, 21.22it/s]
54%|█████▍ | 543/1000 [00:25<00:21, 21.42it/s]
55%|█████▍ | 546/1000 [00:26<00:21, 20.76it/s]
55%|█████▍ | 549/1000 [00:26<00:21, 21.05it/s]
55%|█████▌ | 552/1000 [00:26<00:21, 21.26it/s]
56%|█████▌ | 555/1000 [00:26<00:20, 21.49it/s]
56%|█████▌ | 558/1000 [00:26<00:20, 21.65it/s]
56%|█████▌ | 561/1000 [00:26<00:20, 21.57it/s]
56%|█████▋ | 564/1000 [00:26<00:20, 21.38it/s]
57%|█████▋ | 567/1000 [00:27<00:20, 21.17it/s]
57%|█████▋ | 570/1000 [00:27<00:20, 21.22it/s]
57%|█████▋ | 573/1000 [00:27<00:20, 21.22it/s]
58%|█████▊ | 576/1000 [00:27<00:20, 20.94it/s]
58%|█████▊ | 579/1000 [00:27<00:19, 21.07it/s]
58%|█████▊ | 582/1000 [00:27<00:19, 21.08it/s]
58%|█████▊ | 585/1000 [00:27<00:19, 21.01it/s]
59%|█████▉ | 588/1000 [00:28<00:19, 21.02it/s]
59%|█████▉ | 591/1000 [00:28<00:19, 21.10it/s]
59%|█████▉ | 594/1000 [00:28<00:19, 21.21it/s]
60%|█████▉ | 597/1000 [00:28<00:18, 21.39it/s]
60%|██████ | 600/1000 [00:28<00:18, 21.33it/s]
60%|██████ | 603/1000 [00:28<00:18, 21.22it/s]
61%|██████ | 606/1000 [00:28<00:18, 21.25it/s]
61%|██████ | 609/1000 [00:29<00:18, 21.13it/s]
61%|██████ | 612/1000 [00:29<00:18, 21.14it/s]
62%|██████▏ | 615/1000 [00:29<00:18, 21.30it/s]
62%|██████▏ | 618/1000 [00:29<00:17, 21.29it/s]
62%|██████▏ | 621/1000 [00:29<00:17, 21.41it/s]
62%|██████▏ | 624/1000 [00:29<00:17, 21.53it/s]
63%|██████▎ | 627/1000 [00:29<00:17, 21.47it/s]
Traceback (most recent call last):
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/emcee/ensemble.py", line 640, in __call__
return self.f(x, *self.args, **self.kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py", line 1009, in log_probability
return lp + self.log_likelihood(
^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py", line 1046, in log_likelihood
rv = self.misfit(
^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py", line 124, in misfit
rv = self.ml.residuals(p)
^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/model.py", line 556, in residuals
sim_interpolated = sim.reindex(oseries_calib.index)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/series.py", line 5172, in reindex
return super().reindex(
^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py", line 5632, in reindex
return self._reindex_axes(
^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py", line 5660, in _reindex_axes
obj = obj._reindex_with_indexers(
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py", line 5728, in _reindex_with_indexers
return self._constructor_from_mgr(new_data, axes=new_data.axes).__finalize__(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py", line 6284, in __finalize__
self.flags.allows_duplicate_labels = other.flags.allows_duplicate_labels
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/docs/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/flags.py", line 89, in allows_duplicate_labels
value = bool(value)
^^^^^^^^^^^
KeyboardInterrupt
63%|██████▎ | 628/1000 [00:29<00:17, 20.97it/s]
---------------------------------------------------------------------------
KeyboardInterrupt Traceback (most recent call last)
Cell In[11], line 2
1 # Use the solver to run MCMC
----> 2 ml2.solve(
3 solver=s,
4 initial=False,
5 tmin="1990",
6 steps=1000,
7 tune=True,
8 report=False,
9 )
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/model.py:961, in Model.solve(self, tmin, tmax, freq, warmup, noise, solver, report, initial, weights, fit_constant, freq_obs, initialize, **kwargs)
958 self.add_solver(solver=solver)
960 # Solve model
--> 961 success, optimal, stderr = self.solver.solve(
962 noise=self.settings["noise"], weights=weights, **kwargs
963 )
964 if not success:
965 logger.warning("Model parameters could not be estimated well.")
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py:961, in EmceeSolve.solve(self, noise, weights, steps, callback, **kwargs)
950 else:
951 self.sampler = emcee.EnsembleSampler(
952 nwalkers=self.nwalkers,
953 ndim=ndim,
(...) 958 args=(noise, weights, callback),
959 )
--> 961 self.sampler.run_mcmc(pinit, steps, progress=self.progress_bar, **kwargs)
963 # Get optimal values
964 optimal = self.initial.copy()
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/emcee/ensemble.py:450, in EnsembleSampler.run_mcmc(self, initial_state, nsteps, **kwargs)
447 initial_state = self._previous_state
449 results = None
--> 450 for results in self.sample(initial_state, iterations=nsteps, **kwargs):
451 pass
453 # Store so that the ``initial_state=None`` case will work
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/emcee/ensemble.py:409, in EnsembleSampler.sample(self, initial_state, log_prob0, rstate0, blobs0, iterations, tune, skip_initial_state_check, thin_by, thin, store, progress, progress_kwargs)
406 move = self._random.choice(self._moves, p=self._weights)
408 # Propose
--> 409 state, accepted = move.propose(model, state)
410 state.random_state = self.random_state
412 if tune:
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/emcee/moves/red_blue.py:93, in RedBlueMove.propose(self, model, state)
90 q, factors = self.get_proposal(s, c, model.random)
92 # Compute the lnprobs of the proposed position.
---> 93 new_log_probs, new_blobs = model.compute_log_prob_fn(q)
95 # Loop over the walkers and update them accordingly.
96 for i, (j, f, nlp) in enumerate(
97 zip(all_inds[S1], factors, new_log_probs)
98 ):
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/emcee/ensemble.py:496, in EnsembleSampler.compute_log_prob(self, coords)
494 else:
495 map_func = map
--> 496 results = list(map_func(self.log_prob_fn, p))
498 try:
499 # perhaps log_prob_fn returns blobs?
500
(...) 504 # l is a length-1 array, np.array([1.234]). In that case blob
505 # will become an empty list.
506 blob = [l[1:] for l in results if len(l) > 1]
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/emcee/ensemble.py:640, in _FunctionWrapper.__call__(self, x)
638 def __call__(self, x):
639 try:
--> 640 return self.f(x, *self.args, **self.kwargs)
641 except: # pragma: no cover
642 import traceback
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py:1009, in EmceeSolve.log_probability(self, p, noise, weights, callback)
1007 return -np.inf
1008 else:
-> 1009 return lp + self.log_likelihood(
1010 p, noise=noise, weights=weights, callback=callback
1011 )
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py:1046, in EmceeSolve.log_likelihood(self, p, noise, weights, callback)
1043 # Set the parameters that are varied from the model and objective function
1044 par[self.vary] = p
-> 1046 rv = self.misfit(
1047 p=par[: -self.objective_function.nparam],
1048 noise=noise,
1049 weights=weights,
1050 callback=callback,
1051 )
1053 lnlike = self.objective_function.compute(
1054 rv, par[-self.objective_function.nparam :]
1055 )
1057 return lnlike
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/solver.py:124, in BaseSolver.misfit(self, p, noise, weights, callback, returnseparate)
121 rv = self.ml.noise(p) * self.ml.noise_weights(p)
123 else:
--> 124 rv = self.ml.residuals(p)
126 # Determine if weights need to be applied
127 if weights is not None:
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pastas/model.py:556, in Model.residuals(self, p, tmin, tmax, freq, warmup)
551 sim_interpolated = np.interp(
552 oseries_calib.index.asi8, sim.index.asi8, sim.values
553 )
554 else:
555 # All the observation indexes are in the simulation
--> 556 sim_interpolated = sim.reindex(oseries_calib.index)
558 # Calculate the actual residuals here
559 res = oseries_calib.subtract(sim_interpolated)
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/series.py:5172, in Series.reindex(self, index, axis, method, copy, level, fill_value, limit, tolerance)
5155 @doc(
5156 NDFrame.reindex, # type: ignore[has-type]
5157 klass=_shared_doc_kwargs["klass"],
(...) 5170 tolerance=None,
5171 ) -> Series:
-> 5172 return super().reindex(
5173 index=index,
5174 method=method,
5175 copy=copy,
5176 level=level,
5177 fill_value=fill_value,
5178 limit=limit,
5179 tolerance=tolerance,
5180 )
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py:5632, in NDFrame.reindex(self, labels, index, columns, axis, method, copy, level, fill_value, limit, tolerance)
5629 return self._reindex_multi(axes, copy, fill_value)
5631 # perform the reindex on the axes
-> 5632 return self._reindex_axes(
5633 axes, level, limit, tolerance, method, fill_value, copy
5634 ).__finalize__(self, method="reindex")
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py:5660, in NDFrame._reindex_axes(self, axes, level, limit, tolerance, method, fill_value, copy)
5655 new_index, indexer = ax.reindex(
5656 labels, level=level, limit=limit, tolerance=tolerance, method=method
5657 )
5659 axis = self._get_axis_number(a)
-> 5660 obj = obj._reindex_with_indexers(
5661 {axis: [new_index, indexer]},
5662 fill_value=fill_value,
5663 copy=copy,
5664 allow_dups=False,
5665 )
5666 # If we've made a copy once, no need to make another one
5667 copy = False
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py:5728, in NDFrame._reindex_with_indexers(self, reindexers, fill_value, copy, allow_dups)
5725 elif using_copy_on_write() and new_data is self._mgr:
5726 new_data = new_data.copy(deep=False)
-> 5728 return self._constructor_from_mgr(new_data, axes=new_data.axes).__finalize__(
5729 self
5730 )
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/generic.py:6284, in NDFrame.__finalize__(self, other, method, **kwargs)
6277 if other.attrs:
6278 # We want attrs propagation to have minimal performance
6279 # impact if attrs are not used; i.e. attrs is an empty dict.
6280 # One could make the deepcopy unconditionally, but a deepcopy
6281 # of an empty dict is 50x more expensive than the empty check.
6282 self.attrs = deepcopy(other.attrs)
-> 6284 self.flags.allows_duplicate_labels = other.flags.allows_duplicate_labels
6285 # For subclasses using _metadata.
6286 for name in set(self._metadata) & set(other._metadata):
File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.12.0/lib/python3.11/site-packages/pandas/core/flags.py:89, in Flags.allows_duplicate_labels(self, value)
87 @allows_duplicate_labels.setter
88 def allows_duplicate_labels(self, value: bool) -> None:
---> 89 value = bool(value)
90 obj = self._obj()
91 if obj is None:
KeyboardInterrupt:
3. Posterior parameter distributions#
The results from the MCMC analysis are stored in the sampler object, accessible through ml.solver.sampler variable. The object ml.solver.sampler.flatchain contains a Pandas DataFrame with \(n\) the parameter samples, where \(n\) is calculated as follows:
\(n = \frac{\left(\text{steps}-\text{burn}\right)\cdot\text{nwalkers}}{\text{thin}} \)
Corner.py#
Corner is a simple but great python package that makes creating corner graphs easy. A couple of lines of code suffice to create a plot of the parameter distributions and the covariances between the parameters.
# Corner plot of the results
fig = plt.figure(figsize=(8, 8))
labels = list(ml2.parameters.index[ml2.parameters.vary]) + list(
ml2.solver.parameters.index[ml2.solver.parameters.vary]
)
labels = [label.split("_")[1] for label in labels]
best = list(ml2.parameters[ml2.parameters.vary].optimal) + list(
ml2.solver.parameters[ml2.solver.parameters.vary].optimal
)
axes = corner.corner(
ml2.solver.sampler.get_chain(flat=True, discard=500),
quantiles=[0.025, 0.5, 0.975],
labelpad=0.1,
show_titles=True,
title_kwargs=dict(fontsize=10),
label_kwargs=dict(fontsize=10),
max_n_ticks=3,
fig=fig,
labels=labels,
truths=best,
)
plt.show()
4. The trace shows when MCMC converges#
The walkers take steps in different directions for each step. It is expected that after a number of steps, the direction of the step becomes random, as a sign that an optimum has been found. This can be checked by looking at the autocorrelation, which should be insignificant after a number of steps. Below we just show how to obtain the different chains, the interpretation of which is outside the scope of this notebook.
fig, axes = plt.subplots(len(labels), figsize=(10, 7), sharex=True)
samples = ml2.solver.sampler.get_chain(flat=True)
for i in range(len(labels)):
ax = axes[i]
ax.plot(samples[:, i], "k", alpha=0.5)
ax.set_xlim(0, len(samples))
ax.set_ylabel(labels[i])
ax.yaxis.set_label_coords(-0.1, 0.5)
axes[-1].set_xlabel("step number")
mcn_params = pd.DataFrame(index=ls_params.index, columns=["mcn_opt", "mcn_sig"])
params = ml2.solver.sampler.get_chain(
flat=True, discard=500
) # discard first 500 of every chain
for iparam in range(params.shape[1] - 1):
mcn_params.iloc[iparam] = np.median(params[:, iparam]), np.std(params[:, iparam])
mean_time_diff = head.index.to_series().diff().mean().total_seconds() / 86400
# Translate phi into the value of alpha also used by the noisemodel
mcn_params.loc["noise_alpha", "mcn_opt"] = -mean_time_diff / np.log(
np.median(params[:, -1])
)
mcn_params.loc["noise_alpha", "mcn_sig"] = -mean_time_diff / np.log(
np.std(params[:, -1])
)
pd.concat((ls_params, mcn_params), axis=1)
Repeat with uniform priors#
Set more or less uninformative uniform priors. Now also include \(\sigma^2\).
ml3 = ps.Model(head)
rm = ps.RechargeModel(
rain, evap, recharge=ps.rch.Linear(), rfunc=ps.Gamma(), name="rch"
)
ml3.add_stressmodel(rm)
ml3.solve(report=False)
Uniform prior selected from 0.25 till 4 times the optimal values
# Set the initial parameters to a normal distribution
ml3.parameters["initial"] = ml3.parameters[
"optimal"
] # set initial value to the optimal from least squares for good starting point
for name in ml3.parameters.index:
if ml3.parameters.loc[name, "optimal"] > 0:
ml3.set_parameter(
name,
dist="uniform",
pmin=0.25 * ml3.parameters.loc[name, "optimal"],
pmax=4 * ml3.parameters.loc[name, "optimal"],
)
else:
ml3.set_parameter(
name,
dist="uniform",
pmin=4 * ml3.parameters.loc[name, "optimal"],
pmax=0.25 * ml3.parameters.loc[name, "optimal"],
)
ml3.parameters
# Choose the objective function
ln_prob = ps.objfunc.GaussianLikelihoodAr1()
# Create the EmceeSolver with some settings
s = ps.EmceeSolve(
nwalkers=20,
moves=emcee.moves.DEMove(),
objective_function=ln_prob,
progress_bar=True,
parallel=False,
)
s.parameters.loc["ln_var", "initial"] = 0.05**2
s.parameters.loc["ln_var", "pmin"] = 0.05**2 / 4
s.parameters.loc["ln_var", "pmax"] = 4 * 0.05**2
# Use the solver to run MCMC
ml3.solve(
solver=s,
initial=False,
tmin="1990",
steps=1000,
tune=True,
report=False,
)
s.parameters
# Corner plot of the results
fig = plt.figure(figsize=(8, 8))
labels = list(ml3.parameters.index[ml3.parameters.vary]) + list(
ml3.solver.parameters.index[ml3.solver.parameters.vary]
)
labels = [label.split("_")[1] for label in labels]
best = list(ml3.parameters[ml3.parameters.vary].optimal) + list(
ml3.solver.parameters[ml3.solver.parameters.vary].optimal
)
axes = corner.corner(
ml3.solver.sampler.get_chain(flat=True, discard=500),
quantiles=[0.025, 0.5, 0.975],
labelpad=0.1,
show_titles=True,
title_kwargs=dict(fontsize=10),
label_kwargs=dict(fontsize=10),
max_n_ticks=3,
fig=fig,
labels=labels,
truths=best,
)
plt.show()
fig, axes = plt.subplots(len(labels), figsize=(10, 7), sharex=True)
samples = ml3.solver.sampler.get_chain(flat=True)
for i in range(len(labels)):
ax = axes[i]
ax.plot(samples[:, i], "k", alpha=0.5)
ax.set_xlim(0, len(samples))
ax.set_ylabel(labels[i])
ax.yaxis.set_label_coords(-0.1, 0.5)
axes[-1].set_xlabel("step number")
mcu_params = pd.DataFrame(index=ls_params.index, columns=["mcu_opt", "mcu_sig"])
params = ml3.solver.sampler.get_chain(
flat=True, discard=500
) # discard first 500 of every chain
for iparam in range(params.shape[1] - 1):
mcu_params.iloc[iparam] = np.median(params[:, iparam]), np.std(params[:, iparam])
mean_time_diff = head.index.to_series().diff().mean().total_seconds() / 86400
mcu_params.loc["noise_alpha", "mcu_opt"] = -mean_time_diff / np.log(
np.median(params[:, -1])
)
mcu_params.loc["noise_alpha", "mcu_sig"] = -mean_time_diff / np.log(
np.std(params[:, -1])
)
pd.concat((ls_params, mcn_params, mcu_params), axis=1)
5. Compute prediction interval#
nobs = len(head)
params = ml3.solver.sampler.get_chain(flat=True, discard=500)
sim = {}
# compute for 1000 random samples of chain
np.random.seed(1)
for i in np.random.choice(np.arange(10000), size=1000, replace=False):
h = ml3.simulate(p=params[i, :-2])
res = ml3.residuals(p=params[i, :-2])
h += np.random.normal(loc=0, scale=np.std(res), size=len(h))
sim[i] = h
simdf = pd.DataFrame.from_dict(sim, orient="columns", dtype=float)
alpha = 0.05
q = [alpha / 2, 1 - alpha / 2]
pi = simdf.quantile(q, axis=1).transpose()
pimean = np.mean(pi[0.975] - pi[0.025])
print(f"prediction interval emcee with uniform priors: {pimean:.3f} m")
print(f"PICP: {ps.stats.picp(head, pi):.3f}")
For this example, the prediction interval is dominated by the residuals not by the uncertainty of the parameters. In the code cell below, the parameter uncertainty is not included: the coverage only changes slightly and is mostly affected by the difference in randomly drawing residuals.
logprob = ml3.solver.sampler.compute_log_prob(
ml3.solver.sampler.get_chain(flat=True, discard=500)
)[0]
imax = np.argmax(logprob) # parameter set with larges likelihood
#
nobs = len(head)
params = ml3.solver.sampler.get_chain(flat=True, discard=500)
sim = {}
# compute for 1000 random samples of residuals, but one parameter set
h = ml3.simulate(p=params[imax, :-2])
res = ml3.residuals(p=params[imax, :-2])
np.random.seed(1)
for i in range(1000):
sim[i] = h + np.random.normal(loc=0, scale=np.std(res), size=len(h))
simdf = pd.DataFrame.from_dict(sim, orient="columns", dtype=float)
alpha = 0.05
q = [alpha / 2, 1 - alpha / 2]
pi = simdf.quantile(q, axis=1).transpose()
pimean = np.mean(pi[0.975] - pi[0.025])
print(f"prediction interval emcee with uniform priors: {pimean:.3f} m")
print(f"PICP: {ps.stats.picp(head, pi):.3f}")