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:

Note: The EmceeSolver is still an experimental feature and some of the arguments may change in before official release. We welcome testing and feedback on this new feature!
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     : 2.0.0
Python     : 3.12.12
Numpy      : 2.4.4
Pandas     : 3.0.1
Scipy      : 1.17.1
Matplotlib : 3.10.8
Numba      : 0.65.0

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     26                     EVP          81.25
nobs     351                    R2            0.81
noise    True                   RMSE          0.09
tmin     1990-01-02 00:00:00    AICc      -1957.18
tmax     2005-10-14 00:00:00    BIC       -1934.26
freq     D                      Obj           0.64
freq_obs None                   ___               
warmup   3650 days 00:00:00     Interp.         No

Parameters (6 optimized)
==================================================
                optimal    initial  vary
rch_A          0.261229   0.198424  True
rch_n          0.886319   1.000000  True
rch_a        116.341591  10.000000  True
rch_f         -0.703221  -1.000000  True
constant_d     1.028788   1.338063  True
noise_alpha   53.501179  15.000000  True
../_images/98269e9c5f64fcf7313421e170f2e35aceb3228ab8b6816866c8c8c119c8f245.png

The diagnostics show that the noise meets the statistical requirements for uncertainty analysis reasonably well.

ml1.plots.diagnostics();
../_images/940154cb7c466de998c3e9314d3b854368ce399a9703769f19181f0307a880cb.png

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.261229 0.021429
rch_n 0.886319 0.028279
rch_a 116.341591 16.172891
rch_f -0.703221 0.073977
constant_d 1.028788 0.043803
noise_alpha 53.501179 8.864145
# 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.361 m
Prediction interval coverage probability:  0.943
../_images/76555ea63d53022e9ab27bc138198ace39ec7c0d55c4f5c27bff5e4f0ddf602b.png

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     18                     EVP          85.03
nobs     351                    R2            0.85
noise    False                  RMSE          0.08
tmin     1990-01-02 00:00:00    AICc      -1758.77
tmax     2005-10-14 00:00:00    BIC       -1739.64
freq     D                      Obj           1.14
freq_obs None                   ___               
warmup   3650 days 00:00:00     Interp.         No

Parameters (5 optimized)
==================================================
               optimal    initial  vary
rch_A         0.292827   0.198424  True
rch_n         0.783041   1.000000  True
rch_a       264.518237  10.000000  True
rch_f        -1.161715  -1.000000  True
constant_d    1.192934   1.338063  True
ml2.parameters
initial pmin pmax vary name dist stderr optimal
rch_A 0.198424 0.00001 19.842364 True rch uniform 0.011970 0.292827
rch_n 1.000000 0.10000 5.000000 True rch uniform 0.025215 0.783041
rch_a 10.000000 0.01000 10000.000000 True rch uniform 29.179792 264.518237
rch_f -1.000000 -2.00000 0.000000 True rch uniform 0.052025 -1.161715
constant_d 1.338063 NaN NaN True constant uniform 0.022717 1.192934

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.

Note: This means that either the `pmin` and `pmax` should be set for uniform distributions, or the `stderr` for any other distribution. That is why in this example model was first solved using LeastSquares, in order to obtain estimates for the `stderr`. In practice, these could also be set based on expert judgement or information about the parameters.
# Set the initial parameters to optimal values from least squares and a normal distribution
# for a good starting point
for name in ml2.parameters.index:
    ml2.set_parameter(name, initial=ml2.parameters.at[name, "optimal"])
    ml2.set_parameter(name, dist="norm")

# the column stderr is used (for now) to set the scale of the normal distribution
ml2._parameters["stderr"] = 2 * ml2.parameters["stderr"]

ml2.parameters
initial pmin pmax vary name dist stderr optimal
rch_A 0.292827 0.00001 19.842364 True rch norm 0.023940 0.292827
rch_n 0.783041 0.10000 5.000000 True rch norm 0.050431 0.783041
rch_a 264.518237 0.01000 10000.000000 True rch norm 58.359585 264.518237
rch_f -1.161715 -2.00000 0.000000 True rch norm 0.104050 -1.161715
constant_d 1.192934 NaN NaN True constant norm 0.045433 1.192934

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.0038 1.000000e-10 1.00000 True 0.000475 ln uniform
ln_phi 0.5000 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,
)
100%|██████████| 1000/1000 [01:38<00:00, 10.15it/s]

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()
../_images/3aed0579095a918ff4b16cc8dfcc1e6b1d24e731db698cd8c9bc2a668727b287.png

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")
Text(0.5, 0, 'step number')
../_images/2f597b7287b37eb1adb99308664076b97d277d3b2790c27a532c02ee970991d7.png
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)
ls_opt ls_sig mcn_opt mcn_sig
rch_A 0.261229 0.021429 0.288613 0.015743
rch_n 0.886319 0.028279 0.812515 0.021883
rch_a 116.341591 16.172891 198.442453 25.539189
rch_f -0.703221 0.073977 -0.988452 0.062935
constant_d 1.028788 0.043803 1.122006 0.028334
noise_alpha 53.501179 8.864145 40.443053 5.282542

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=True)
Fit report head                     Fit Statistics
==================================================
nfev     18                     EVP          85.03
nobs     351                    R2            0.85
noise    False                  RMSE          0.08
tmin     1990-01-02 00:00:00    AICc      -1758.77
tmax     2005-10-14 00:00:00    BIC       -1739.64
freq     D                      Obj           1.14
freq_obs None                   ___               
warmup   3650 days 00:00:00     Interp.         No

Parameters (5 optimized)
==================================================
               optimal    initial  vary
rch_A         0.292827   0.198424  True
rch_n         0.783041   1.000000  True
rch_a       264.518237  10.000000  True
rch_f        -1.161715  -1.000000  True
constant_d    1.192934   1.338063  True

Uniform prior selected from 0.25 till 4 times the optimal values

# Set the initial parameters to a normal distribution and 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,
            initial=ml3.parameters.loc[name, "optimal"],
            dist="uniform",
            pmin=0.25 * ml3.parameters.loc[name, "optimal"],
            pmax=4 * ml3.parameters.loc[name, "optimal"],
        )
    else:
        ml3.set_parameter(
            name,
            dist="uniform",
            initial=ml3.parameters.loc[name, "optimal"],
            pmin=4 * ml3.parameters.loc[name, "optimal"],
            pmax=0.25 * ml3.parameters.loc[name, "optimal"],
        )

ml3.parameters
initial pmin pmax vary name dist stderr optimal
rch_A 0.292827 0.073207 1.171307 True rch uniform 0.011970 0.292827
rch_n 0.783041 0.195760 3.132165 True rch uniform 0.025215 0.783041
rch_a 264.518237 66.129559 1058.072947 True rch uniform 29.179792 264.518237
rch_f -1.161715 -4.646860 -0.290429 True rch uniform 0.052025 -1.161715
constant_d 1.192934 0.298233 4.771735 True constant uniform 0.022717 1.192934
# 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,
)
100%|██████████| 1000/1000 [01:39<00:00, 10.01it/s]
s.parameters
initial pmin pmax vary stderr name dist optimal
ln_var 0.0025 6.250000e-04 0.01000 True 0.01 ln uniform 0.003609
ln_phi 0.5000 1.000000e-10 0.99999 True 0.20 ln uniform 0.715231
# 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()
../_images/d3b017b2a9388bf2f8b05acf6f7f9d459fb60bf5806a6e73100d2fa47df0721d.png
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")
Text(0.5, 0, 'step number')
../_images/e5ecf6a51bff39ee4aef4002077900dd1148d472b7ef14dd347b0c05af630592.png
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)
ls_opt ls_sig mcn_opt mcn_sig mcu_opt mcu_sig
rch_A 0.261229 0.021429 0.288613 0.015743 0.287815 0.023126
rch_n 0.886319 0.028279 0.812515 0.021883 0.873075 0.027257
rch_a 116.341591 16.172891 198.442453 25.539189 137.658322 21.63212
rch_f -0.703221 0.073977 -0.988452 0.062935 -0.660983 0.081434
constant_d 1.028788 0.043803 1.122006 0.028334 0.978104 0.050219
noise_alpha 53.501179 8.864145 40.443053 5.282542 52.626095 5.182964

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}")
prediction interval emcee with uniform priors: 0.370 m
PICP: 0.949

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}")
prediction interval emcee with uniform priors: 0.351 m
PICP: 0.934