ARMA(1,1) Noise Model for Pastas#

R.A. Collenteur, University of Graz, May 2020

In this notebook an Autoregressive-Moving-Average (ARMA(1,1)) noise model is developed for Pastas models. This new noise model is tested against synthetic data generated with NumPy or Statsmodels’ ARMA model. This noise model is tested on head time series with a regular time step.

Warning

It should be noted that the time step may be non-equidistant in this formulation, but this model is not yet tested for irregular time steps.

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.7.0
Python version: 3.11.10
NumPy version: 2.0.2
Pandas version: 2.2.3
SciPy version: 1.14.1
Matplotlib version: 3.9.2
Numba version: 0.60.0

1. Develop the ARMA(1,1) Noise Model for Pastas#

The following formula is used to calculate the noise according to the ARMA(1,1) process:

\[\upsilon(t_i) = r(t_i) - r(t_{i-1}) \text{e}^{-\Delta t_i / \alpha} - \upsilon(t_{i-1}) \text{e}^{-\Delta t_i / \beta}\]

where \(\upsilon\) is the noise, \(\Delta t_i\) the time step between the residuals (\(r\)), and respectively \(\alpha\) [days] and \(\beta\) [days] the parameters of the AR and MA parts of the model. The model is named ArmaNoiseModel and can be found in noisemodel.py. It is added to a Pastas model as follows: ml.add_noisemodel(ps.ArmaNoiseModel())

2. Generate synthetic head time series#

# Read in some data
rain = (
    pd.read_csv("../examples/data/rain_260.csv", index_col=0, parse_dates=[0]).squeeze()
    / 1000
)

# Set the True parameters
Atrue = 800
ntrue = 1.1
atrue = 200
dtrue = 20

# Generate the head
step = ps.Gamma().block([Atrue, ntrue, atrue])
h = dtrue * np.ones(len(rain) + step.size)
for i in range(len(rain)):
    h[i : i + step.size] += rain[i] * step
head = pd.DataFrame(
    index=rain.index,
    data=h[: len(rain)],
)
head = head["1990":"2015"]

# Plot the head without noise
plt.figure(figsize=(10, 2))
plt.plot(head, "k.", label="head")
plt.legend(loc=0)
plt.ylabel("Head (m)")
plt.xlabel("Time (years)");
../_images/0c0aa54d7b32250f4127e2abc3b9982159a5e8c6625f10562216a98933c59091.png

3. Generate ARMA(1,1) noise and add it to the synthetic heads#

In the following code-block, noise is generated using an ARMA(1,1) process using NumPy. An alternative procedure is available from Statsmodels (commented out now). More information about the ARMA model can be found on the statsmodels website. The noise is added to the head series generated in the previous code-block.

# reproduction of random numbers
np.random.seed(1234)
alpha = 0.95
beta = 0.1

# generate samples using Statsmodels
# import statsmodels.api as stats
# ar = np.array([1, -alpha])
# ma = np.r_[1, beta]
# arma = stats.tsa.ArmaProcess(ar, ma)
# noise = arma.generate_sample(head[0].index.size)*np.std(head.values) * 0.1

# generate samples using NumPy
random_seed = np.random.RandomState(1234)

noise = random_seed.normal(0, 1, len(head)) * np.std(head.values) * 0.1
a = np.zeros_like(head[0])

for i in range(1, noise.size):
    a[i] = noise[i] + noise[i - 1] * beta + a[i - 1] * alpha

head_noise = head[0] + a
plt.plot(a)
plt.plot(noise)
[<matplotlib.lines.Line2D at 0x7fb06f6f46d0>]
../_images/ce5a26267378c5d8af1cf5fb60a362553c3683d556be4fed431155d555011f85.png

4. Create and solve a Pastas Model#

ml = ps.Model(head_noise)
sm = ps.StressModel(rain, ps.Gamma(), name="recharge", settings="prec")
ml.add_stressmodel(sm)
ml.add_noisemodel(ps.ArmaNoiseModel())

ml.solve(tmin="1991", tmax="2015-06-29", report=True)
axes = ml.plots.results(figsize=(10, 5))
axes[-2].plot(ps.Gamma().step([Atrue, ntrue, atrue]))
Fit report 0                       Fit Statistics
=================================================
nfev    29                     EVP          88.33
nobs    8946                   R2            0.88
noise   True                   RMSE          0.11
tmin    1991-01-01 00:00:00    AICc     -62720.73
tmax    2015-06-29 00:00:00    BIC      -62678.14
freq    D                      Obj           4.03
warmup  3650 days 00:00:00     ___               
solver  LeastSquares           Interp.         No

Parameters (6 optimized)
=================================================
                optimal     initial  vary
recharge_A   734.086018  217.313623  True
recharge_n     1.089086    1.000000  True
recharge_a   191.477259   10.000000  True
constant_d    20.177978   21.873653  True
noise_alpha   20.575383    1.000000  True
noise_beta     0.433754    1.000000  True
[<matplotlib.lines.Line2D at 0x7fb06d348f10>]
../_images/baac857711a65ad7f7d4f6156c0838bdbf9c842799adca10b400216210862ca1.png

5. Did we find back the original ARMA parameters?#

print(np.exp(-1.0 / ml.parameters.loc["noise_alpha", "optimal"]).round(2), "vs", alpha)
print(
    np.exp(-1.0 / np.abs(ml.parameters.loc["noise_beta", "optimal"])).round(2),
    "vs.",
    beta,
)
0.95 vs 0.95
0.1 vs. 0.1

The estimated parameters for the noise model are almost the same as the true parameters, showing that the model works for regular time steps.

6. So is the autocorrelation removed correctly?#

ml.plots.diagnostics(figsize=(10, 4));
../_images/6f1909b14828591fdf32d6592941c948fcd2ecd69676a5d805434ec0313da228.png

That seems okay. It is important to understand that this noisemodel will only help in removing autocorrelations at the first time lag, but not at larger time lags, compared to its AR(1) counterpart.

7. What happens if we use an AR(1) model?#

ml = ps.Model(head_noise)
ml.add_noisemodel(ps.ArNoiseModel())
sm = ps.StressModel(rain, ps.Gamma(), name="recharge", settings="prec")
ml.add_stressmodel(sm)

ml.solve(tmin="1991", tmax="2015-06-29", report=False)
axes = ml.plots.results(figsize=(10, 5))
axes[-2].plot(ps.Gamma().step([Atrue, ntrue, atrue]))

print(np.exp(-1.0 / ml.parameters.loc["noise_alpha", "optimal"]).round(2), "vs", alpha)
0.96 vs 0.95
../_images/01d9e280aae1e3992c8414091985bb23cfc62a14e5ae98654abdfdb3abfdfefb.png
ml.plots.diagnostics(figsize=(10, 4));
../_images/24bc4737156bd4333bdbdf170ed1551fe74a8d69cfb1538257ee5605d0b62257.png

Significant autocorrelation is still present at lag 1 and the parameter of the AR(1) is overestimated, trying to correct for the lack of an MA(1) part. This is to be expected, as the MA(1) process generates a strong autocorrelation at this first time lag. The negative autocorrelation in the first few time steps is a result of the overestimation of the AR(1) parameter.

A possible effect of failing to remove the autocorrelation at lag 1 may be that the parameter standard errors are under- or overestimated. Although that does not seem the case for this synthetic, real life examples may suffer from this.

8. Test the ARMA NoiseModel for irregular time steps#

In this final step the ARMA NoiseModel is tested for irregular timesteps, using the indices from a real groundwater level time series. It is clear from the example below that the ARMA NoiseModel does not yet work for irregular time steps, as (unlike the AR(1) model in Pastas) no weights are applied yet.

index = (
    pd.read_csv("../examples/data/test_index.csv", parse_dates=True, index_col=0)
    .index.round("D")
    .drop_duplicates()
)
head_irregular = head_noise.reindex(index)
ml = ps.Model(head_irregular)
ml.add_noisemodel(ps.ArNoiseModel())
sm = ps.StressModel(rain, ps.Gamma(), name="recharge", settings="prec")
ml.add_stressmodel(sm)
ml.add_noisemodel(ps.ArmaNoiseModel())

ml.solve(tmin="1991", tmax="2015-06-29", report=False)
axes = ml.plots.results(figsize=(10, 5))
axes[-2].plot(ps.Gamma().step([Atrue, ntrue, atrue]))

print(np.exp(-1.0 / ml.parameters.loc["noise_alpha", "optimal"]).round(2), "vs", alpha)
print(np.exp(-1.0 / ml.parameters.loc["noise_beta", "optimal"]).round(2), "vs.", beta)
0.95 vs 0.95
0.08 vs. 0.1
../_images/575a566890a644ba0f1899ca19f5a153ea674822c10a964e3622e1ea0072a67e.png
ml.plots.diagnostics(figsize=(10, 4));
../_images/ce68d8e30fdde83845be5e9b74eb845640202bb3273648b2a8da6be29a0ab386.png