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.10.1
Python version: 3.11.12
NumPy version: 2.2.6
Pandas version: 2.3.1
SciPy version: 1.16.1
Matplotlib version: 3.10.5
Numba version: 0.61.2
DeprecationWarning: As of Pastas 1.5, no noisemodel is added to the pastas Model class by default anymore. To solve your model using a noisemodel, you have to explicitly add a noisemodel to your model before solving. For more information, and how to adapt your code, please see this issue on GitHub: https://github.com/pastas/pastas/issues/735

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/be94a078cf77f63d2f9e5536080ab2125b8765db60cb185b79ee87b844ce2cbb.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/736f80a3fba6b8d975d3b9e4b5fc4c090f8464bb47d96a78669f4ab5f21e095a.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     -226184.81
tmax    1999-12-31 00:00:00    BIC      -226160.01
freq    D                      Obj            0.00
warmup  3650 days 00:00:00     ___                
solver  LeastSquares           Interp.          No

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/089cfe8e3b76823b979957c4a2e5bf07e12b431fc7072ddeb42fdb2b17482d73.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/87ad675f7dcb52eea3de3c9b84b69c8d45772f3cb73f5a588f9dc6f635850561.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)
/tmp/ipykernel_710/2008846097.py in ?()
      1 # no correlation between errors, no noise model
      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)

/tmp/ipykernel_710/10775507.py in ?(nexp, nparam, model, ptrue, pnames, sigh, rho)
     24     rmse = np.empty(nexp)
     25     for i in range(nexp):
     26         if i % 100 == 0:
     27             print(".", end="")
---> 28         p[i], sig[i], rsq[i], rmse[i] = model(head_errors[i])
     29     print("\n")
     30 
     31     coverage = (np.abs(p - ptrue) < 1.96 * sig).sum(0)

/tmp/ipykernel_710/1914584098.py in ?(headseries, rain, returnmodel)
      7     """
      8     ml = ps.Model(oseries=headseries)
      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
     14     return (

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pastas/model.py in ?(self, tmin, tmax, freq, warmup, noise, solver, report, initial, weights, fit_constant, freq_obs, initialize, **kwargs)
    931             )
    932             self.add_solver(solver=solver)
    933 
    934         # Solve model
--> 935         success, optimal, stderr = self.solver.solve(
    936             noise=self.settings["noise"], weights=weights, **kwargs
    937         )
    938         if not success:

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pastas/solver.py in ?(self, noise, weights, callback, **kwargs)
    536                 ub=np.where(parameters.pmax.isnull(), np.inf, parameters.pmax),
    537                 keep_feasible=True,
    538             )
    539 
--> 540         self.result = least_squares(
    541             self.objfunction,
    542             bounds=bounds,
    543             x0=parameters.initial.values,

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/_lib/_util.py in ?(*args, **kwds)
    692             _workers = kwargs['workers']
    693 
    694         with MapWrapper(_workers) as mf:
    695             kwargs['workers'] = mf
--> 696             return func(*args, **kwargs)

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_lsq/least_squares.py in ?(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)
   1017 
   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)
   1022 

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_lsq/trf.py in ?(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)

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_lsq/trf.py in ?(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()
    373 
    374             cost = cost_new
    375 
--> 376             J = jac(x)
    377             njev += 1
    378 
    379             if loss_function is not None:

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py in ?(self, x)
    739     def jac(self, x):
    740         self._update_x(x)
--> 741         self._update_jac()
    742         if hasattr(self.J, "astype"):
    743             # returns a copy so that downstream can't overwrite the
    744             # internal attribute. But one can't copy a LinearOperator

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py in ?(self)
    706                 self._update_fun()
    707             else:
    708                 self._njev += 1
    709 
--> 710             self.J = self.jac_wrapped(xp_copy(self.x), f0=self.f)
    711             self.J_updated = True

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py in ?(self, x, f0, **kwds)
    441         if callable(self.jac):
    442             J = self.jac(x)
    443             self.njev += 1
    444         elif self.jac in FD_METHODS:
--> 445             J, dct = approx_derivative(
    446                 self.fun,
    447                 x,
    448                 f0=f0,

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_numdiff.py in ?(fun, x0, method, rel_step, abs_step, f0, bounds, sparsity, as_linear_operator, args, kwargs, full_output, workers)
    589         # normalize workers
    590         workers = workers or map
    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:

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_numdiff.py in ?(fun, x0, f0, h, use_one_sided, method, workers)
    682         # only f_evals (numerator) needs parallelization, the denominator
    683         # (the step size) is fast to calculate.
    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)
    689 

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_numdiff.py in ?(.0)
--> 686         def x_generator2(x0, h):
    687             for i in range(n):
    688                 # If copying isn't done then it's possible for different workers
    689                 # to see the same values of x1. (At least that's what happened

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_numdiff.py in ?(self, x)
    875 
    876         if xp.isdtype(x.dtype, "real floating"):
    877             x = xp.astype(x, self.x0.dtype)
    878 
--> 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.")

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/scipy/optimize/_differentiable_functions.py in ?(self, x)
    413     def __call__(self, x):
    414         self.nfev += 1
--> 415         return np.atleast_1d(self.fun(x))

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

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pastas/solver.py in ?(self, p, noise, weights, callback)
    570         self, p: ArrayLike, noise: bool, weights: Series, callback: CallBack
    571     ) -> ArrayLike:
    572         par = self.initial
    573         par[self.vary] = p
--> 574         return self.misfit(p=par, noise=noise, weights=weights, callback=callback)

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pastas/solver.py in ?(self, p, noise, weights, callback, returnseparate)
    117         if noise:
    118             rv = self.ml.noise(p) * self.ml.noise_weights(p)
    119 
    120         else:
--> 121             rv = self.ml.residuals(p)
    122 
    123         # Determine if weights need to be applied
    124         if weights is not None:

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pastas/model.py in ?(self, p, tmin, tmax, freq, warmup)
    539                 oseries_calib.index.asi8, sim.index.asi8, sim.values
    540             )
    541         else:
    542             # All the observation indexes are in the simulation
--> 543             sim_interpolated = sim.reindex(oseries_calib.index)
    544 
    545         # Calculate the actual residuals here
    546         res = oseries_calib.subtract(sim_interpolated)

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pandas/core/series.py in ?(self, index, axis, method, copy, level, fill_value, limit, tolerance)
   5160         fill_value: Scalar | None = None,
   5161         limit: int | None = None,
   5162         tolerance=None,
   5163     ) -> Series:
-> 5164         return super().reindex(
   5165             index=index,
   5166             method=method,
   5167             copy=copy,

~/checkouts/readthedocs.org/user_builds/pastas/envs/v1.10.1/lib/python3.11/site-packages/pandas/core/generic.py in ?(self, labels, index, columns, axis, method, copy, level, fill_value, limit, tolerance)
   5613         # if all axes that are requested to reindex are equal, then only copy
   5614         # if indicated must have index names equal here as well as values
   5615         if copy and using_copy_on_write():
   5616             copy = False
-> 5617         if all(
   5618             self._get_axis(axis_name).identical(ax)
   5619             for axis_name, ax in axes.items()
   5620             if ax is not None

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])