Parameter (uncertainty) estimates benchmarked with synthetic time series#

Mark Bakker - Delft University of Technology

The purpose of this notebook is to demonstrate that the parameters estimated by Pastas are unbiased and that the coverage of the estimated uncertainty of the parameters is correct, i.e., the 95% confidence intervals of the estimated parameters contains the true parameter values approximately 95% of the time.

All examples in this notebook use synthetic data. The true head is simulated with a known response function. The performance of Pastas is evaluated for head series with different kinds of both uncorrelated and correlated errors. The Notebook consists of the following sections:

  1. Generation of the true head series.

  2. Modeling of synthetic heads that contain no errors.

  3. Modeling of synthetic heads with uncorrelated errors

  4. Modeling of synthetic heads with correlated errors

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.special import gammainc, gammaincinv

import pastas as ps

plt.rcParams["figure.figsize"] = (5, 3)  # default figure size
ps.show_versions()
Pastas version: 1.13.0
Python version: 3.11.12
NumPy version: 2.3.5
Pandas version: 2.3.3
SciPy version: 1.17.0
Matplotlib version: 3.10.8
Numba version: 0.63.1

The following function generates correlated data with an underlying standard deviation of \(\sigma_x\) and a correlation \(\rho\).

def generate_correlated_data(N, sigx, rho, seed=1):
    """
    N: length of array of correlated data
    sigx: standard deviation of the correlated data
    rho: correlation coefficient
    seed: seed to be used in random number generation
    """
    sige = np.sqrt(1 - rho**2) * sigx
    np.random.seed(seed)
    e = np.random.normal(0, sige, N - 1)
    x = np.zeros(N)
    x[0] = np.random.normal(0, sigx)  # first point variance sigx
    for j in range(1, len(x)):
        x[j] = rho * x[j - 1] + e[j - 1]
    return x

Generation of synthetic head series (the truth)#

The synthetic head series are generated with a Gamma response function. The response functions are defined below for clarity (alternatively, the response function of Pastas can be used).

def gamma_tmax(A, n, a, cutoff=0.999):
    """returns time for which step function equals cutoff * A"""
    return gammaincinv(n, cutoff) * a


def gamma_step(A, n, a, cutoff=0.999):
    """
    returns gamma step function starting at t=0 with intervals of delt = 1
    tmax is the time for which the step function reaches cutoff * A
    """
    tmax = gamma_tmax(A, n, a, cutoff)
    t = np.arange(0, tmax, 1)
    s = A * gammainc(n, t / a)
    return s


def gamma_block(A, n, a, cutoff=0.999):
    """returns the gamma block response starting at t=0 with intervals of delt = 1"""
    s = gamma_step(A, n, a, cutoff)
    return np.append(s[0], s[1:] - s[:-1])

The Gamma response function requires three input arguments: A, n and a. The true values of the parameters used in this notebook are given below and the response function is drawn. The response function reflects the response to one unit of recharge during one day. For example, if the units of the recharge are mm/d, then the head response is in mm (in response to 1 mm of recharge). If the units of the recharge are m/d, then the head response is in m (in response to 1 m of recharge).

Atrue = 400  # true value of A
ntrue = 1.1  # true value of n
atrue = 100  # true vale of a
dtrue = 20  # base level of heads
ptrue = np.array([Atrue, ntrue, atrue, dtrue])  # array with true parameter values
pnames = ["A", "n", "a", "d"]  # names of parameters
hblock = gamma_block(Atrue, ntrue, atrue)
tmax = gamma_tmax(Atrue, ntrue, atrue)
plt.plot(hblock)
plt.xlabel("Time (days)")
plt.ylabel("Head response")
plt.title(f"Gamma block response with tmax={tmax:.0f} d")
plt.grid()
../_images/9757c9037328c0e5e2e3a5366aa3f66366feda7635305e4e18acf0357fdcdfa2.png

Create synthetic observations#

Rainfall is used as input series to create a synthetic head series. The generated head series is purposely not generated with convolution so that it is clear how the head is computed. The computed head at day 1 is the head at the end of day 1 due to rainfall during day 1.

def generate_heads(Atrue, ntrue, atrue, dtrue, rain, yearstart, yearend):
    """yearstart and yearend are strings"""
    step = gamma_block(Atrue, ntrue, atrue)[
        1:
    ]  # start head computation at end of first day
    lenstep = len(step)
    h = dtrue * np.ones(len(rain) + lenstep)
    for i in range(len(rain)):
        h[i : i + lenstep] += rain.iloc[i] * step
    head = pd.Series(index=rain.index, data=h[: len(rain)]).loc[yearstart:yearend]
    return head

Synthetic heads are generated for the period 1990 - 2000. Computations start in 1980 as a warm-up period. The series with daily rainfall starts in 1980.

rain = (
    pd.read_csv("../examples/data/rain_260.csv", index_col=0, parse_dates=[0]).squeeze()
    / 1000
)
head = generate_heads(Atrue, ntrue, atrue, dtrue, rain, "1990", "1999")
#
plt.figure(figsize=(10, 3))
plt.plot(head, "k.", label="head", markersize=1)
plt.legend(loc=0)
plt.ylabel("head (m)")
plt.xlabel("time (years)")
plt.grid()
../_images/0708f2e7b433256bda28628a8cf97cf228619f1f65971662cf6931bad2ec9f51.png

Synthetic heads that contain no errors.#

In this first test, it is demonstrated that Pastas finds the correct parameters back

ml = ps.Model(oseries=head)
sm = ps.StressModel(rain, rfunc=ps.Gamma(cutoff=0.999), name="rain")
ml.add_stressmodel(sm)
ml.solve()
Fit report None                       Fit Statistics
====================================================
nfev     10                     EVP           100.00
nobs     3652                   R2              1.00
noise    False                  RMSE            0.00
tmin     1990-01-01 00:00:00    AICc      -226290.45
tmax     1999-12-31 00:00:00    BIC       -226265.65
freq     D                      Obj             0.00
freq_obs None                   ___                 
warmup   3650 days 00:00:00     Interp.           No
solver   LeastSquares           weights          Yes

Parameters (4 optimized)
====================================================
            optimal     initial  vary
rain_A        400.0  217.313623  True
rain_n          1.1    1.000000  True
rain_a        100.0   10.000000  True
constant_d     20.0   20.907717  True

Synthetic heads that contain uncorrelated errors#

Uncorrelated errors with \(\sigma=0.1\) are added to the synthetic heads.

head_error = head + generate_correlated_data(len(head), sigx=0.1, rho=0, seed=1)
ml = ps.Model(oseries=head_error)
sm = ps.StressModel(rain, rfunc=ps.Gamma(cutoff=0.999), name="rain")
ml.add_stressmodel(sm)
ml.solve(report=False)
ml.plots.results(figsize=(12, 4));
../_images/fe15ea77071f0e74439e40c01ab43178b18554e5aad6b731b4afaf7063cd069e.png
head_error = head + generate_correlated_data(len(head), sigx=0.1, rho=0.9, seed=1)
ml = ps.Model(oseries=head_error)
sm = ps.StressModel(rain, rfunc=ps.Gamma(cutoff=0.999), name="rain")
ml.add_stressmodel(sm)
ml.solve(report=False)
ml.plots.results(figsize=(12, 4));
../_images/60ff792fc0f7cc50455d14faf9c2c8f427cd462c4a6c80fe15a4780f996aac37.png

Perform experiments to test whether the uncertainty estimate of the parameters is reasonable#

def model1(headseries, rain=rain, returnmodel=False):
    """
    Function that creates pastas model for given headseries, rain, a linear
    recharge model and a Gamma response function
    Returns:
    Optimal parameters, their estimated standard error, r-squared
    """
    ml = ps.Model(oseries=headseries)
    sm = ps.StressModel(rain, rfunc=ps.Gamma(cutoff=0.999), name="rain")
    ml.add_stressmodel(sm)
    ml.solve(report=False)
    if returnmodel:
        return ml
    return (
        ml.parameters["optimal"].values,
        ml.parameters["stderr"].values,
        ml.stats.rsq(),
        ml.stats.rmse(),
    )
def model2(headseries, rain=rain, returnmodel=False):
    """
    Function that creates pastas model for given headseries, rain, a linear
    recharge model, a Gamma response function, and an AR1 noise model
    Returns:
    Optimal parameters, their estimated standard error, r-squared
    """
    ml = ps.Model(oseries=headseries)
    sm = ps.StressModel(rain, rfunc=ps.Gamma(cutoff=0.999), name="rain")
    ml.add_stressmodel(sm)
    nm = ps.ArNoiseModel()
    ml.add_noisemodel(nm)
    ml.solve(report=False)
    if returnmodel:
        return ml
    return (
        ml.parameters["optimal"].values,
        ml.parameters["stderr"].values,
        ml.stats.rsq(),
        ml.stats.rmse(),
    )

A numerical experiment is conducted to test whether the Pastas estimates the uncertainty of the parameters correctly. In the experiment, the synthetic head consists of the true head plus an error. Different errors are generated every time. A pastas model is created of the synthetic head series with the introduced error and it is determined whether the true parameters are within the 95% confidence interval of the estimated parameters. The experiment consists of 1000 runs of heads with different random errors.

def experiment(nexp, nparam, model, ptrue, pnames, sigh=0.1, rho=0):
    """
    nexp: number of times to run the experiment
    nparam: number of parameters to estimate
    model: model function to call (as defined above)
    ptrue: array with true values of the parameters
    sigh: standard deviation of the errors in the synthetic head
    rho: correlation between the errors in the synthetic head
    Returns:
    p: DataFrame with estiamted parameters for each nexp runs
    sig: DataFrame with estimated standard error for each nexp runs
    coverage: DataFrame with 1 row and nparam columns with the coverage
    of each parameter
    """
    head = generate_heads(Atrue, ntrue, atrue, dtrue, rain, "1990", "1999")
    head_errors = [
        head + generate_correlated_data(len(head), sigx=sigh, rho=rho, seed=i)
        for i in range(nexp)
    ]

    p = np.empty((nexp, nparam))
    sig = np.empty((nexp, nparam))
    rsq = np.empty(nexp)
    rmse = np.empty(nexp)
    for i in range(nexp):
        if i % 100 == 0:
            print(".", end="")
        p[i], sig[i], rsq[i], rmse[i] = model(head_errors[i])
    print("\n")

    coverage = (np.abs(p - ptrue) < 1.96 * sig).sum(0)
    p_df = pd.DataFrame(p, columns=pnames)
    sig_df = pd.DataFrame(sig, columns=pnames)
    cov_df = pd.DataFrame(
        coverage[np.newaxis, :] * 100 / nexp,
        columns=pnames,
        index=["coverage percentage"],
    )
    return p_df, sig_df, cov_df, rsq, rmse

Experiment 0. Uncorrelated errors, no noise model#

The coverage is pretty good and close to 95% for all parameters. This shows the standard error estimation in pastas is correct when the residuals are uncorrelated.

nexp = 1000  # 1000 runs in the experiments in this notebook
# no correlation between errors, no noise model
sigh = 0.1
rho = 0
p, sig, coverage, rsq, rmse = experiment(
    nexp=nexp, nparam=4, model=model1, ptrue=ptrue, pnames=pnames, sigh=sigh, rho=0
)
coverage  # number of estimates in 95% uncertainty interval (out of 1000)
.
.
.
.
.
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[14], line 4
      2 sigh = 0.1
      3 rho = 0
----> 4 p, sig, coverage, rsq, rmse = experiment(
      5     nexp=nexp, nparam=4, model=model1, ptrue=ptrue, pnames=pnames, sigh=sigh, rho=0
      6 )
      7 coverage  # number of estimates in 95% uncertainty interval (out of 1000)

Cell In[12], line 28, in experiment(nexp, nparam, model, ptrue, pnames, sigh, rho)
     26     if i % 100 == 0:
     27         print(".", end="")
---> 28     p[i], sig[i], rsq[i], rmse[i] = model(head_errors[i])
     29 print("\n")
     31 coverage = (np.abs(p - ptrue) < 1.96 * sig).sum(0)

Cell In[10], line 11, in model1(headseries, rain, returnmodel)
      9 sm = ps.StressModel(rain, rfunc=ps.Gamma(cutoff=0.999), name="rain")
     10 ml.add_stressmodel(sm)
---> 11 ml.solve(report=False)
     12 if returnmodel:
     13     return ml

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/model.py:1017, in Model.solve(self, tmin, tmax, freq, warmup, noise, solver, report, initial, weights, fit_constant, freq_obs, initialize, **kwargs)
   1014     self.add_solver(solver=solver)
   1016 # Solve model
-> 1017 success, optimal, stderr = self.solver.solve(
   1018     noise=self._settings["noise"], weights=weights, **kwargs
   1019 )
   1020 if not success:
   1021     logger.warning("Model parameters could not be estimated well.")

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/solver.py:545, in LeastSquares.solve(self, noise, weights, callback, **kwargs)
    538 else:
    539     bounds = Bounds(
    540         lb=np.where(parameters.pmin.isnull(), -np.inf, parameters.pmin),
    541         ub=np.where(parameters.pmax.isnull(), np.inf, parameters.pmax),
    542         keep_feasible=True,
    543     )
--> 545 self.result = least_squares(
    546     self.objfunction,
    547     bounds=bounds,
    548     x0=parameters.initial.values,
    549     args=(noise, weights, callback),
    550     method=method,
    551     **kwargs,
    552 )
    554 self.pcov = DataFrame(
    555     LeastSquares.get_covariances(
    556         self.result.jac, self.result.cost, method=method, absolute_sigma=False
   (...)    559     columns=parameters.index,
    560 )
    561 self.pcor = self._get_correlations(self.pcov)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/_lib/_util.py:717, in _workers_wrapper.<locals>.inner(*args, **kwds)
    715 with MapWrapper(_workers) as mf:
    716     kwargs['workers'] = mf
--> 717     return func(*args, **kwargs)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_lsq/least_squares.py:1019, in least_squares(fun, x0, jac, bounds, method, ftol, xtol, gtol, x_scale, loss, f_scale, diff_step, tr_solver, tr_options, jac_sparsity, max_nfev, verbose, args, kwargs, callback, workers)
   1015     result = call_minpack(vector_fun.fun, x0, vector_fun.jac, ftol, xtol, gtol,
   1016                           max_nfev, x_scale, jac_method=jac)
   1018 elif method == 'trf':
-> 1019     result = trf(vector_fun.fun, vector_fun.jac, x0, f0, J0, lb, ub, ftol, xtol,
   1020                  gtol, max_nfev, x_scale, loss_function, tr_solver,
   1021                  tr_options.copy(), verbose, callback=callback_wrapped)
   1023 elif method == 'dogbox':
   1024     if tr_solver == 'lsmr' and 'regularize' in tr_options:

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_lsq/trf.py:124, in trf(fun, jac, x0, f0, J0, lb, ub, ftol, xtol, gtol, max_nfev, x_scale, loss_function, tr_solver, tr_options, verbose, callback)
    120     return trf_no_bounds(
    121         fun, jac, x0, f0, J0, ftol, xtol, gtol, max_nfev, x_scale,
    122         loss_function, tr_solver, tr_options, verbose, callback=callback)
    123 else:
--> 124     return trf_bounds(
    125         fun, jac, x0, f0, J0, lb, ub, ftol, xtol, gtol, max_nfev, x_scale,
    126         loss_function, tr_solver, tr_options, verbose, callback=callback)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_lsq/trf.py:376, in trf_bounds(fun, jac, x0, f0, J0, lb, ub, ftol, xtol, gtol, max_nfev, x_scale, loss_function, tr_solver, tr_options, verbose, callback)
    372 f_true = f.copy()
    374 cost = cost_new
--> 376 J = jac(x)
    377 njev += 1
    379 if loss_function is not None:

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py:750, in VectorFunction.jac(self, x)
    748 def jac(self, x):
    749     self._update_x(x)
--> 750     self._update_jac()
    751     if hasattr(self.J, "astype"):
    752         # returns a copy so that downstream can't overwrite the
    753         # internal attribute. But one can't copy a LinearOperator
    754         return self.J.astype(self.J.dtype)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py:719, in VectorFunction._update_jac(self)
    716 else:
    717     self._njev += 1
--> 719 self.J = self.jac_wrapped(xp_copy(self.x), f0=self.f)
    720 self.J_updated = True

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py:454, in _VectorJacWrapper.__call__(self, x, f0, **kwds)
    452     self.njev += 1
    453 elif self.jac in FD_METHODS:
--> 454     J, dct = approx_derivative(
    455         self.fun,
    456         x,
    457         f0=f0,
    458         **self.finite_diff_options,
    459     )
    460     self.nfev += dct['nfev']
    462 if self.sparse_jacobian:

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_numdiff.py:593, in approx_derivative(fun, x0, method, rel_step, abs_step, f0, bounds, sparsity, as_linear_operator, args, kwargs, full_output, workers)
    591 with MapWrapper(workers) as mf:
    592     if sparsity is None:
--> 593         J, _nfev = _dense_difference(fun_wrapped, x0, f0, h,
    594                                  use_one_sided, method,
    595                                  mf)
    596     else:
    597         if not issparse(sparsity) and len(sparsity) == 2:

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_numdiff.py:686, in _dense_difference(fun, x0, f0, h, use_one_sided, method, workers)
    684 f_evals = workers(fun, x_generator2(x0, h))
    685 dx = [(x0[i] + h[i]) - x0[i] for i in range(n)]
--> 686 df = [f_eval - f0 for f_eval in f_evals]
    687 df_dx = [delf / delx for delf, delx in zip(df, dx)]
    688 nfev += len(df_dx)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_numdiff.py:686, in <listcomp>(.0)
    684 f_evals = workers(fun, x_generator2(x0, h))
    685 dx = [(x0[i] + h[i]) - x0[i] for i in range(n)]
--> 686 df = [f_eval - f0 for f_eval in f_evals]
    687 df_dx = [delf / delx for delf, delx in zip(df, dx)]
    688 nfev += len(df_dx)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_numdiff.py:879, in _Fun_Wrapper.__call__(self, x)
    876 if xp.isdtype(x.dtype, "real floating"):
    877     x = xp.astype(x, self.x0.dtype)
--> 879 f = np.atleast_1d(self.fun(x, *self.args, **self.kwargs))
    880 if f.ndim > 1:
    881     raise RuntimeError("`fun` return value has "
    882                        "more than 1 dimension.")

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py:424, in _VectorFunWrapper.__call__(self, x)
    422 def __call__(self, x):
    423     self.nfev += 1
--> 424     return np.atleast_1d(self.fun(x))

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/scipy/optimize/_lsq/least_squares.py:263, in _WrapArgsKwargs.__call__(self, x)
    262 def __call__(self, x):
--> 263     return self.f(x, *self.args, **self.kwargs)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/solver.py:579, in LeastSquares.objfunction(self, p, noise, weights, callback)
    577 par = self.initial
    578 par[self.vary] = p
--> 579 return self.misfit(p=par, noise=noise, weights=weights, callback=callback)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.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.13.0/lib/python3.11/site-packages/pastas/model.py:592, in Model.residuals(self, p, tmin, tmax, freq, warmup)
    589     freq_obs = self._settings["freq_obs"]
    591 # simulate model
--> 592 sim = self.simulate(p, tmin, tmax, freq, warmup, return_warmup=False)
    594 # Get the oseries calibration series
    595 oseries_calib = self.observations(tmin, tmax, freq_obs)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/model.py:515, in Model.simulate(self, p, tmin, tmax, freq, warmup, return_warmup)
    513 istart = 0  # Track parameters index to pass to stressmodel object
    514 for sm in self.stressmodels.values():
--> 515     contrib = sm.simulate(
    516         p[istart : istart + sm.nparam], sim_index.min(), tmax, freq, dt
    517     )
    518     sim = sim.add(contrib)
    519     istart += sm.nparam

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/stressmodels.py:428, in StressModel.simulate(self, p, tmin, tmax, freq, dt)
    420 def simulate(
    421     self,
    422     p: ArrayLike,
   (...)    426     dt: float = 1.0,
    427 ) -> Series:
--> 428     return self._simulate(tuple(p), tmin, tmax, freq, dt)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/decorators.py:265, in conditional_cachedmethod.<locals>.decorator.<locals>.wrapper(self, *args, **kwargs)
    263     return cached_func(self, *args, **kwargs)
    264 else:
--> 265     return func(self, *args, **kwargs)

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/stressmodels.py:464, in StressModel._simulate(self, p, tmin, tmax, freq, dt)
    462 self.update_stress(tmin=tmin, tmax=tmax, freq=freq)
    463 b = self._get_block(p, dt, tmin, tmax)
--> 464 stress = self.stress[0].series
    465 npoints = stress.index.size
    466 h = Series(
    467     data=fftconvolve(stress, b, "full")[:npoints],
    468     index=stress.index,
    469     name=self.name,
    470 )

File ~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.13.0/lib/python3.11/site-packages/pastas/timeseries.py:203, in TimeSeries.series(self)
    200     self.settings["tmax"] = series.index.max()  # reset tmax
    201     self.update_series(force_update=True, **self.settings)
--> 203 @property
    204 def series(self) -> Series:
    205     return self._series
    207 @series.setter
    208 def series(self, value):

KeyboardInterrupt: 

Experiment 1. Correlated errors with \(\rho=0.9\), no noise model#

The first set of experiments are for the case that the standard deviation of the error is \(\sigma=0.1\) and the correlation between errors from one day to the next is \(\phi=0.9\) (\(\alpha\approx 9.5\) d).

The coverage is not very good, as expected. The coverage is low, because the standard error is estimated too low when there is large autocorrelation in the residuals.

# correlation between errors, no noise model
sigh = 0.1
rho = 0.9
p1, sig1, coverage1, rsq1, rmse1 = experiment(
    nexp=nexp, nparam=4, model=model1, ptrue=ptrue, pnames=pnames, sigh=sigh, rho=rho
)
coverage1
..........
A n a d
coverage percentage 33.5 42.7 38.9 31.3

In fact, the standard error estimate is approximately 4 times too small for this case. As shown below, when the standard error is 4 times larger, the coverage is reasonable.

coverage1a = (np.abs(p1.values - ptrue) < 1.96 * (4 * sig1.values)).sum(0)
pd.DataFrame(
    coverage1a[np.newaxis, :] * 100 / nexp,
    columns=pnames,
    index=["coverage percentage"],
)
A n a d
coverage percentage 93.5 96.9 94.9 92.7

Experiment 2. Correlated errors with \(\rho=0.9\), with AR1 noise model#

The coverage is much better now as the AR1 noise model is doing a good job. Only the coverage of the \(\alpha\) parameter of the noise model is on the low side.

# correlation between errors, AR1 noise model
sigh = 0.1
rho = 0.9
alphatrue = -1 / np.log(rho)
ptrue2 = np.hstack((ptrue, alphatrue))
pnames2 = pnames + ["alpha"]
p2, sig2, coverage2, rsq2, rmse2 = experiment(
    nexp=nexp, nparam=5, model=model2, ptrue=ptrue2, pnames=pnames2, sigh=sigh, rho=rho
)
coverage2
..........
A n a d alpha
coverage percentage 95.0 94.7 94.8 94.4 91.8

Parameters are estimated correctly when no noise model is added#

In Experiments 1 and 2, the parameters were estimated for synthetic head data with correlated errors in two ways. First, a pastas model was used without a noise model and second a pastas model as used with a noise model. It was shown that the pastas model without a noise model did a poor job in estimating the standard error of the parameters, as expected. But this does not mean that the estimated parameters themselves were biased. On the contrary, the following two graphs demonstrate that the estimated parameters with or without a noise model are essentially similar. The first graph shows a histogram of parameter \(A\) with and without the noise model. Note that the two histograms pretty much overlap. The second graph plots the estimated values of \(A\) with a noise model vs. the estimated values of \(A\) without a noise model. These values plot almost on a straight 45\(^\circ\) line, which indicates that the values of \(A\) only differ slightly with or without a noise model.

plt.figure(figsize=(8, 3))
for i in range(4):
    plt.subplot(2, 2, i + 1)
    plt.hist(p1.iloc[:, i], bins=20, density=True)
    plt.hist(p2.iloc[:, i], bins=20, density=True, alpha=0.5)
    if i == 1:
        plt.legend(["w/o noise model", "w/ noise model"], bbox_to_anchor=(1.05, 1))
    plt.title(pnames[i], fontsize="small")
plt.tight_layout()
../_images/f998d338524a4d6a6ecb9d16918c77b2834465bfbb8fec46ce14a30d3d203ec4.png
plt.figure(figsize=(6, 6))
for i in range(4):
    plt.subplot(2, 2, i + 1, aspect=1)
    plt.plot(p1.iloc[:, i], p2.iloc[:, i], ".", markersize=1)
    plt.xlabel("w/o noise model")
    plt.ylabel("w/ noise model")
    plt.title(pnames[i], fontsize="small")
plt.tight_layout()
../_images/ee06523ff55026d6a7e920baf17a893206ab1a9983c3708f7d69ccddc026bd11.png

The ultimate test to determine whether parameters estimated without a noise model are not significantly different from parameters estimated with a noise model is to check whether the parameters estimated without a noise model are within the 95% confidence interval of the parameters estimated with the noise model. The estimates values of \(n\) are outside the 95% confidence interval more than 300 times out of 1000. That is quite a bit and is an indication that \(n\) cannot be estimated very accurately for this dataset.

print("parameters without noise model within 95% confidence interval with noise model")
print("A, n, a, d")
(np.abs(p1.values - p2.values[:, :-1]) < 1.96 * sig2.values[:, :-1]).sum(0)
parameters without noise model within 95% confidence interval with noise model
A, n, a, d
array([1000,  669,  980, 1000])

Parameter uncertainty is larger when the correlation factor is larger#

The experiments are repeated for the case that the standard deviation of the error is still \(\sigma=0.1\) but the correlation between errors from one day to the next is increased to \(\phi=0.99\) (\(\alpha\approx 99.5\) d). The coverage of the estimated parameter uncertainty is somewhat smaller than 95% when using the AR1 noise model.

sigh = 0.1
rho = 0.99
p3, sig3, coverage3, rsq3, rmse3 = experiment(
    nexp=nexp, nparam=4, model=model1, ptrue=ptrue, pnames=pnames, sigh=sigh, rho=rho
)
print("coverage without noise model")
display(coverage3)
#
alphatrue = -1 / np.log(rho)
ptrue2 = np.hstack((ptrue, alphatrue))
p4, sig4, coverage4, rsq4, rmse4 = experiment(
    nexp=nexp, nparam=5, model=model2, ptrue=ptrue2, pnames=pnames2, sigh=sigh, rho=rho
)
print("coverage with AR1 noise model")
display(coverage4)
..........

coverage without noise model
..........

coverage with AR1 noise model
A n a d
coverage percentage 11.8 26.8 16.0 11.4
A n a d alpha
coverage percentage 90.8 94.9 91.8 89.1 81.6

It is sometimes difficult to estimate the parameters when the correlation \(\phi\) is very large.#

The difficulty lies in the model when no noise model is used. It seems the wrong optimal is sometimes found and it is more difficult to estimate \(n\). This requires further investigation.

plt.figure(figsize=(8, 3))
for i in range(4):
    plt.subplot(2, 2, i + 1)
    plt.hist(p3.iloc[:, i], bins=20, density=True)
    plt.hist(p4.iloc[:, i], bins=20, density=True, alpha=0.5)
    if i == 1:
        plt.legend(["w/o noise model", "w noise model"], bbox_to_anchor=(1.05, 1))
    plt.title(pnames[i], fontsize="small")
plt.tight_layout()
../_images/066c3b39688c562df1a46af7d5347226eeae8d2a25e12d8d3fd10889564e0877.png
plt.figure(figsize=(6, 4))
for i in range(4):
    plt.subplot(2, 2, i + 1, aspect=1)
    plt.plot(p3.iloc[:, i], p4.iloc[:, i], ".", markersize=1)
    plt.title(pnames[i], fontsize="small")
    plt.xlabel("w/o noise model")
    plt.ylabel("w/ noise model")
plt.tight_layout()
../_images/933abf9eea5285d99fcf927dc025855c2090a95d26cb20438f7b8f2a47be1469.png

The parameters estimated without a noise model are sometimes significantly different from parameters estimated with a noise model. Below, it is computed how many times the parameters estimated without a noise model are within the 95% confidence interval of the parameters estimated with the noise model. The largest deviation is indeed for the parameters \(n\) and \(a\) as could also be seen from the graphs above.

(np.abs(p3.values - p4.values[:, :-1]) < 1.96 * sig4.values[:, :-1]).sum(0)
array([996, 240, 702, 997])