Skip to contents

Serial dependence is common in time series. Add autoregressive terms with ar(p), moving-average terms with ma(q), or both in the segment formulas:

model = list(y ~ 1 + x + ar(1) + ma(1))

The most common use case is still ar(1). Like other mcp terms, AR and MA coefficients carry over to later segments until another term for the same component changes them. An ar(p) or ma(q) declaration replaces that whole component: if ar(1) follows ar(2), the lag-2 coefficient is zero in the later segment. Both accept a regression formula, such as ar(1, 1 + x) or ma(1, 0 + x).

GARMA definition

mcp implements AR and MA as a generalized ARMA (GARMA) recurrence on the response-family link scale. If b_t is the ordinary regression predictor from the segment formulas and \eta_t is the predictor including serial dependence, then

\begin{aligned} \text{AR}_t &= \sum_{j=1}^{p} \phi_{j,t} \left[g(y^*_{t-j}) - b_{t-j}\right] \\ \text{MA}_t &= \sum_{k=1}^{q} \theta_{k,t} \left[g(y^*_{t-k}) - \eta_{t-k}\right] \\ \eta_t &= b_t + \text{AR}_t + \text{MA}_t \end{aligned}

where \phi_{j,t} is the lag-j autoregressive (AR) coefficient at time t, \theta_{k,t} is the lag-k moving-average (MA) coefficient at time t, g(\cdot) is the link function, and y^* is the boundary-constrained observation. Thus ar() uses lagged link-scale residuals relative to the ordinary regression, while ma() uses lagged one-step innovations.

mcp does regression with change points on the coefficients \phi_{j,t} and \theta_{k,t}. See below for applied examples.

Unavailable lags at the beginning of the series contribute zero. This same recurrence is used by JAGS, fitted values, predictions, log likelihoods, and fresh-series simulation.

A key feature of this recurrence is that AR/MA memory flows continuously across change points:

  • For an order-N component in a new segment, the last N observations before the change point are input into the first \eta_t in the new segment, weighted by the new segment’s AR/MA parameters.
  • AR/MA lags do not reset at segment boundaries; they only reset at the very beginning of the whole dataset, or across independent series when using series = "column_name" inside ar() or ma() (e.g. ar(1, series = id)).
  • Because change point locations \Delta are estimated with posterior uncertainty, the observation boundary where AR/MA parameters switch varies conditionally across MCMC draws.

GARMA currently supports only the default links for gaussian() (identity), binomial() (logit), bernoulli() (logit), poisson() (log), and negbinomial() (log). Non-default links are rejected for now.

Warning: The AR and MA coefficients are modeled directly and are not jointly constrained to the stationary or invertible regions. With higher-order terms such as ar(2), independently bounded coefficients can violate the usual root conditions. Coefficients that change across predictors or segments, such as ar(1, 1 + x) or model = list(y ~ ar(1), ~ ar(1)), instead define a time-varying process to which the usual constant-coefficient conditions do not directly apply. The same applies to ma().

Observation boundary

The transformed observation y^* keeps log and logit residuals finite when counts lie on a link boundary. The default boundary = 0.1 replaces zero counts by 0.1 for Poisson and negative-binomial models. For binomial models, counts are constrained to the interval from 0.1 to trials - 0.1 before conversion to a rate (n_t = 1 for bernoulli(), constraining y^* to [0.1, 0.9]). It has no effect for Gaussian models.

The default should usually be left unchanged. If needed, set it on either term, for example ar(1, boundary = 0.01) + ma(1). AR and MA share one boundary within a segment, and the boundary may differ between segments. Supplying different AR and MA boundaries in the same segment is an error.

Simple example

Let’s simulate some data using the mcp_example() function with the “ar” settings:

library(mcp)
future::plan(future::multisession, workers = 3)
set.seed(42)  # Make the script deterministic

ar_data = mcp_example_data("ar")
## Generating residuals for AR(N) model since the response column/argument was not provided.
head(ar_data)
##      price time
## 1 26.85479    1
## 2 19.91843    2
## 3 22.81123    3
## 4 24.27657    4
## 5 24.15365    5
## 6 21.77232    6

See how this was generated in fit$example_code. We model this as a plateau (1) with a second-order autoregressive residual (ar(2)) followed by a joined slope (0 + time) with a negative first-order autoregressive residual (ar(1)):

model = list(
  price ~ 1 + ar(2),  # Intercept_1, ar1_1, ar2_1
  ~ 0 + time + ar(1)  # time_2, ar1_2; turns off second-order AR
)
fit = mcp(model, ar_data, seed = 42)

Let’s plot it and we see that AR was strong in the first segment and weaker-but-negative in the second:

set.seed(42)
plot(fit)

We can summarise the inferred coefficients:

summary(fit)
## Family: gaussian
## Links: mu = identity; sigma = identity
## Iterations: 3000 from 3 chains.
## Segments:
##   1: price ~ 1 + ar(2)
##   2: price ~ 1 ~ 0 + time + ar(1)
## 
## Change point parameters:
##     variable  mean    sd  lower  upper rhat ess_bulk ess_tail   sim match
##  cp_1        74.53 3.312 69.872 80.784 1.00      507     1109 74.50    OK
## 
## Population-level parameters:
##     variable  mean    sd  lower  upper rhat ess_bulk ess_tail   sim match
##  Intercept_1 21.05 1.345 18.706 23.976 1.00      609     1095 20.00    OK
##  time_2       0.46 0.044  0.375  0.547 1.00     1117     1169  0.50    OK
##  sigma_1      5.31 0.350  4.678  6.036 1.00     4813     4484  5.00    OK
##  ar1_1        0.43 0.110  0.215  0.646 1.00     4351     6249  0.40    OK
##  ar1_2       -0.32 0.153 -0.620 -0.013 1.00     6270     4991 -0.30    OK
##  ar2_1        0.18 0.109 -0.036  0.395 1.00     4704     6946  0.15    OK

The naming syntax is [component][order]_[segment] for intercepts. For example, ar1_2 is the first-order autoregressive coefficient and ma1_2 the first-order moving-average coefficient in segment 2. Slopes use the usual mcp parameter names, e.g., ar1_x_3 for a slope on AR(1) in segment 3.

Comparing the columns mean and sim we see that the AR coefficients are reasonably recovered. In fact, the posterior mean is almost always exactly the same as arima(data, order = c(N, 0, 0)) (see below), so the non-perfect fits are due to randomness in the simulation - not in the fit.

For Gaussian GARMA models, sigma describes the innovations, i.e., the part of the residuals not explained by AR and MA coefficients. sd(fit$data$price) is therefore generally higher. In this case, the SD of raw data in the plateau is 9. As always, it is good to assess posteriors and convergence more directly:

Sometimes, the trace plot shows that the change point (cp_1) is not well identified with this model and data. As discussed in the article on tips, tricks, and debugging, you could combine a more informative prior with more samples (mcp(..., warmup = 10000, iter = 10000)), if this is a problem.

You can do hypothesis testing with GARMA models using hypothesis(). Read more here or scroll down for an applied example.

Tips, comments, and warnings

The AR and MA terms apply to link-scale residuals from the ordinary regression. In time-series jargon, this is a dynamical regression model where the ordinary regression parameters make up the deterministic structure. See further comments in the section on priors below.

To control the direction of a change, put a one-sided prior on the corresponding coefficient, e.g., ar1_x_1 = "dnorm(0, 1) T(0, )". See recommendations in the section on priors.

Regression on AR/MA coefficients

You can specify how the coefficients change with x using ar(p, formula) and ma(q, formula). For example, ar(1, 1 + time) models a steady change in AR(1) strength. These regression formulas use an identity link, so slopes can easily produce non-stationary or non-invertible coefficients. See the section on priors for ways to constrain them. The same formula is applied separately to every order in the term.

Population-level mcp formula syntax is available inside both terms, so you could use ar(3, 1 + I(x^2) + exp(z)) or ma(1, 0 + group). Group-level terms such as (1|id) are not supported inside ar() or ma(). Transformed predictors must stay within the transformation’s domain; for example, values passed to log() and sqrt() must be non-negative.

Combining ar(), ma(), and sigma()

You can combine ar() and ma() with any regression model and with group-level change points. For Gaussian models, sigma() controls the innovation standard deviation:

model = list(
  y ~ 1 + ar(1) + ma(1) + sigma(1),
  ~ 0 + x + ar(1) + sigma(1),
  ~ 1
)

Order your data

AR and MA lags apply to the order of rows in the data frame without taking into account the distance between values of x. This has two important implications:

  • You probably want to sort your data according to your x. Just do data = data[order(data$x), ].
  • Adjacent data points that lie years apart are modeled to be just as (auto)correlated as adjacent points lying seconds apart.

For grouped longitudinal data, identify independent residual histories with series inside ar() or ma() and sort each series by time:

model = list(
  y ~ 1 + ar(1, series = id),
  ~ 0 + x + ar(1)
)
fit = mcp(model, ar_series_data)

Rows belonging to each series must be contiguous. AR and MA lags reset at each series boundary. The series argument inside ar()/ma() is separate from group-level effects such as (1 | id): either can be used without the other.

Simulating autocorrelated change point data

Assessing the correctness of autocorrelation is less intuitive than seeing e.g. a mean fit. However, we can verify mcp up against more tested-and-tried functions such as arima() in base R. Let us simulate a single AR(3) segment, i.e., without change points, and see if it fits:

# Model
model = list(response ~ 1 + ar(3))

# Simulate data
df = data.frame(time = 1:200, response = 0)
empty = mcp(model, df, sample = FALSE, par_x = "time")
set.seed(42)  # For consistent "random" results
df$response = empty$simulate(
    empty,
    df,
    Intercept_1 = 20,
    ar1_1 = 0.7, 
    ar2_1 = 0.2, 
    ar3_1 = -0.4, 
    sigma_1 = 8)
## Generating residuals for AR(N) model since the response column/argument was not provided.
# Base arima AR(3)
arima(df$response, order = c(3, 0, 0))
## 
## Call:
## arima(x = df$response, order = c(3, 0, 0))
## 
## Coefficients:
##          ar1     ar2      ar3  intercept
##       0.6944  0.2297  -0.4078    19.5891
## s.e.  0.0643  0.0798   0.0650     1.1356
## 
## sigma^2 estimated as 60.17:  log likelihood = -694.1,  aic = 1398.19

OK, we can see that the ar coefficients and sigma (sigma = sqrt(sigma^2)) is simulated correctly, if taking arima() as ground truth. Inferring with mcp is straightforward:

fit = mcp(model, df, par_x = "time", seed = 42)

The Bayesian parameter estimates are in perfect correspondence with arima(), even where they deviate a tiny bit from the simulation parameters (due to the inherent randomness in simulating data):

summary(fit)
## Family: gaussian
## Links: mu = identity; sigma = identity
## Iterations: 3000 from 3 chains.
## Segments:
##   1: response ~ 1 + ar(3)
## 
## Population-level parameters:
##     variable  mean    sd  lower upper rhat ess_bulk ess_tail  sim match
##  Intercept_1 19.68 1.181 17.401 22.04 1.00     5214     4632 20.0    OK
##  sigma_1      7.89 0.391  7.159  8.71 1.00     5669     4937  8.0    OK
##  ar1_1        0.69 0.065  0.563  0.82 1.00     3341     5035  0.7    OK
##  ar2_1        0.23 0.080  0.074  0.39 1.00     2289     4392  0.2    OK
##  ar3_1       -0.40 0.066 -0.529 -0.27 1.00     3485     5620 -0.4    OK

Inferring an autocorrelation-only change

One “side-effect” of the mcp implementation of autocorrelation using ar() in the formulas is that you can infer when autocorrelation parameters and structures change.

Let’s simulate a change point in autocorrelation and see if we can infer it later:

# The model
model = list(
  y ~ 1 + x + ar(1),  # Slope
  ~ 0 + x + ar(1)  # Slope
)

# Get predictions
df = data.frame(x = seq(0, 100, length.out = 200), y = 0)
empty = mcp(model, df, sample = FALSE)
set.seed(42)
df$y = empty$simulate(
  empty,
  df,
  cp_1 = 60,
  Intercept_1 = 20,
  x_1 = 1, x_2 = 1,  # same slope
  ar1_1 = 0.8, ar1_2 = 0.2,
  sigma_1 = 5)

… and we use a prior to equate the slopes of each segment (read more about using priors to equate parameters and define constants). Now let’s see if we can recover these parameters. We use sample = "both" because we will do a Savage-Dickey test later.

prior = list(x_2 = "x_1")  # Set the two slopes equal
fit = mcp(model, data = df, prior = prior, iter = 8000, sample = "both", seed = 42)

Let’s plot the full model prediction using plot(fit). You could use plot(fit, geom_data = "line") for a more classical line plot of the time series data. Set plot(fit, arma = FALSE) to omit all AR and MA effects.

We plot it together with the change in the ar1 parameter using plot_dpar(fit):

library(patchwork)
set.seed(42)
plot(fit) /  # Patchwork syntax to show on separate rows
  plot_dpar(fit, dpar = "ar1", lines = 100)

We recovered the parameters, including the change point (see mean but also the helpful sim and match columns):

summary(fit)
## Family: gaussian
## Links: mu = identity; sigma = identity
## Iterations: 8000 from 3 chains.
## Segments:
##   1: y ~ 1 + x + ar(1)
##   2: y ~ 1 ~ 0 + x + ar(1)
## 
## Change point parameters:
##     variable   mean    sd lower upper rhat ess_bulk ess_tail  sim match
##  cp_1        63.655 5.469 51.15 73.39 1.00     1418     2602 60.0    OK
## 
## Population-level parameters:
##     variable   mean    sd lower upper rhat ess_bulk ess_tail  sim match
##  Intercept_1 21.172 2.488 16.29 26.02 1.00      473      914 20.0    OK
##  x_1          0.982 0.031  0.92  1.04 1.00      489     1000  1.0    OK
##  x_2          0.982 0.031  0.92  1.04 1.00      489     1000  1.0    OK
##  sigma_1      4.880 0.251  4.42  5.40 1.00    11538    12974  5.0    OK
##  ar1_1        0.780 0.055  0.67  0.89 1.00     7442    15510  0.8    OK
##  ar1_2        0.094 0.149 -0.20  0.39 1.00     4744     6453  0.2    OK

We can also plot some of the parameters. As usual, we see that the change point is not well defined by any known distribution. The fact that the posterior mean is around 60 does not (necessarily) mean that there is a high credence in this value. Usually, I find that any bi- or N-modality on the posterior matches well with what you would guess from looking at the raw data. As they say: Bayesian inference is common sense applied to data.

set.seed(42)
plot_pars(fit, regex_pars = "cp_1|ar_*")

As usual, we can test hypotheses (read more here). We can also ask how much more likely it is (relative to the prior) that there the two autocorrelations are equal compared to them differing. Because we sampled both the prior and posterior (mcp(..., sample = "both")), we can do a Savage-Dickey density ratio test:

hypothesis(fit, "ar1_1 = ar1_2")
## Warning: Savage-Dickey Bayes factor was computed using default prior(s) for `ar1_1` and `ar1_2`. Point Bayes factors are sensitive to the prior distribution; consider specifying informed priors.
## Warning: The tested value is in a sparse tail of the prior or posterior draws; the Savage-Dickey estimate may be unreliable.
##          hypothesis      mean     lower     upper prob           BF
## 1 ar1_1 - ar1_2 = 0 0.6857727 0.3770224 0.9878358   NA 3.441065e-05

In this case, the evidence for equality is very small so it was rarely visited by the sampler and hence the precision is low .

Of course, we can also do directional tests. For example, what is the evidence that ar1_1 is more than 0.3 greater than ar1_2? Answer: More than 100 to one.

hypothesis(fit, "ar1_1 - 0.3 > ar1_2")
##                hypothesis      mean      lower     upper     prob       BF
## 1 ar1_1 - 0.3 - ar1_2 > 0 0.3857727 0.07702242 0.6878358 0.993125 306.7896

Priors on AR and MA coefficients

The default prior on each AR and MA intercept is a truncated normal distribution:

dnorm(0, 0.5) T(-1, 1)

It is symmetric around zero and gently shrinks away from the boundaries: its central 95% interval is approximately [-0.84, 0.84]. This is a regularizing prior on each direct coefficient, not a joint stationarity or invertibility constraint for orders above one.

brms also models AR and MA coefficients directly within [-1, 1], but uses flat default priors over that range. Thus mcp keeps the familiar parameter interpretation while adding mild regularization toward zero; fits should generally be similar when the likelihood is informative.

Categorical contrasts default to dnorm(0, 0.25). Numeric slopes are also normal and scaled so that one representative predictor change has prior SD 0.25. Thus approximately 95% of the prior change lies within \pm 0.5.

The defaults express no assumption about the sign. For a time series where positive first-order autocorrelation is expected, alternatives include ar1_1 = "dunif(0, 1)" or ar1_1 = "dnorm(0.5, 0.5) T(0, 1)". Read more about specifying and checking priors.

Here is a complete list of the (default) priors in the model above:

## # A tibble: 7 × 5
##   parameter   segment dpar  prior                                            bounds          
##   <chr>         <int> <chr> <chr>                                            <chr>           
## 1 cp_1              2 cp    dirichlet(alpha = 1)                             [min(x), max(x)]
## 2 Intercept_1       1 mu    student_t(df = 3, location = 72.4, scale = 35.2) none            
## 3 x_1               1 mu    student_t(df = 3, location = 0, scale = 0.352)   none            
## 4 x_2               2 mu    x_1                                              none            
## 5 sigma_1           1 sigma student_t(df = 3, location = 0, scale = 35.2)    [0.001, Inf]    
## 6 ar1_1             1 ar    normal(mean = 0, sd = 0.5)                       [-1, 1]         
## 7 ar1_2             2 ar    normal(mean = 0, sd = 0.5)                       [-1, 1]

We can also visualize the priors because we sampled the prior. prior = TRUE works in most mcp functions, including plot() and summary().

set.seed(42)
plot_pars(fit, prior = TRUE)

Notice that the plot smoothes the posteriors at sharp cutoffs, slightly misrepresenting the true distribution.

Let’s inspect the priors for a more advanced AR model, since you would often have to inform these:

model = list(
  y ~ 1 + ar(2, 1 + x),
  ~ 0 + ar(1, 1 + I(x^2))
)
empty = mcp(model, data = data.frame(y = 1:10, x = 1:10), sample = FALSE)
prior_summary(empty)
## # A tibble: 9 × 5
##   parameter   segment dpar  prior                                          bounds          
##   <chr>         <int> <chr> <chr>                                          <chr>           
## 1 cp_1              2 cp    dirichlet(alpha = 1)                           [min(x), max(x)]
## 2 Intercept_1       1 mu    student_t(df = 3, location = 5.5, scale = 3.7) none            
## 3 sigma_1           1 sigma student_t(df = 3, location = 0, scale = 3.7)   [0.001, Inf]    
## 4 ar1_1             1 ar    normal(mean = 0, sd = 0.5)                     [-1, 1]         
## 5 ar1_x_1           1 ar    normal(mean = 0, sd = 0.02777778)              none            
## 6 ar1_2             2 ar    normal(mean = 0, sd = 0.5)                     [-1, 1]         
## 7 ar1_xE2_2         2 ar    normal(mean = 0, sd = 0.00308642)              none            
## 8 ar2_1             1 ar    normal(mean = 0, sd = 0.5)                     [-1, 1]         
## 9 ar2_x_1           1 ar    normal(mean = 0, sd = 0.02777778)              none

AR and MA coefficient regressions use an identity link, while their residual inputs use the supported response-family link described above. Careful priors become especially important with higher orders or coefficient slopes, where individual values in [-1, 1] do not ensure a stable recurrence. After fitting, mcp() checks up to 500 posterior draws at up to 100 observed predictor rows and warns if the checked-draw violation rate exceeds the corresponding ar or ma threshold in diagnostics (10% by default). fit$simulate() checks the supplied coefficient trajectory before generating a fresh series. For predictor- or segment-varying coefficients these are local smoke tests, not proofs of global stationarity or invertibility.

Here are a few ways in which you may want to inform the AR or MA parameters:

  • For a constant AR(2) process, stationarity requires -1 < \phi_2 < 1 and \phi_2 - 1 < \phi_1 < 1 - \phi_2. One dependent-prior construction that stays in this region is ar2_1 = "dunif(-1, 1)" together with ar1_1 = "dunif(ar2_1 - 1, 1 - ar2_1)". The simpler suggestion ar2_1 = "dunif(0, ar1_1)" is not sufficient. Higher orders require the corresponding joint root condition.
  • Slopes can quickly make AR coefficients non-stationary or MA coefficients non-invertible. Constrain their magnitude with reference to a plausible change over the predictor range. For example, a shallow negative change over the observed x-span could use "dnorm(0, 0.1 / (max(x) - min(x))) T(, 0)".

Notes on observed data vs simulated data

JAGS, fitted(), predict(), and log_lik() evaluate a finite, conditional recurrence: the response history supplies earlier residuals, and unavailable residuals before the start of the series are set to zero.

  • fitted() and predict() use posterior imputations for the actual missing response data, so it is conditional on the actual data values/order for the ar/ma modeling.
  • log_lik() is unavailable if the data has missingness before some observed data.
  • In contrast, posterior_predict() and pp_check() generate each series from the model posteriors without regards to the original data. This makes serial summaries of posterior replications, such as their autocorrelation and run lengths, meaningful.

JAGS code

Here is the JAGS code for the second simulation example, i.e., the one with a single slope going from AR(2) to AR(1). You can print fit$simulate and see that it runs much of the same code.

fit$jags_code
## model {
##   # mcp helper values
##   cp_0 = CONST1_
##   cp_2 = CONST2_
## 
##   # Priors for population-level effects
##   cp_frac_1_ ~ dbeta(1, 1)  # Relative fraction of remaining span (Uniform order statistics)
##   cp_1 = cp_0 + cp_frac_1_ * (cp_2 - cp_0)  # Ordered change point
##   ar1_1 ~ dnorm(0, 1/(0.5)^2) T(-1,1)  # Zero-centered regularizing dependence coefficient
##   ar1_2 ~ dnorm(0, 1/(0.5)^2) T(-1,1)  # Zero-centered regularizing dependence coefficient
##   Intercept_1 ~ dt(72.4, 1/(35.2)^2, 3)   # Robustly centered mean intercept with a minimum scale of 2.5
##   x_1 ~ dt(0, 1/(0.352)^2, 3)   # Regularizing mean coefficient scaled to a reference predictor change
##   x_2 = x_1  # Same value as x_1
##   sigma_1 ~ dt(0, 1/(35.2)^2, 3) T(0.001,)  # Positive residual SD calibrated on the response scale
## 
##   # Apply GARMA recursion to link-scale residuals
##   resid_garma_[1] = 0
##   for (i_ in 2:length(x)) {
##     resid_garma_[i_] = ar1_[i_] * resid_abs_[i_ - 1]
##   }
##   # Model and likelihood
##   for (i_ in 1:length(x)) {
##     # par_x local to each segment
##     x_local_1_[i_] = min(x[i_], cp_1)
##     x_local_2_[i_] = min(x[i_], cp_2) - cp_1
##     
##     # GARMA observation boundary
##     garma_boundary_[i_] =
##       (x[i_] < cp_1) * 0.1 +
##       (x[i_] >= cp_1) * 0.1
##     
##     # Formula for ar1
##     ar1_[i_] =
##       (x[i_] >= cp_0) * (x[i_] < cp_1) * inprod(rhs_matrix_[i_, c(1)], c(ar1_1)) * 1 + 
##       (x[i_] >= cp_1) * inprod(rhs_matrix_[i_, c(2)], c(ar1_2)) * 1
##     
##     # Formula for mu
##     link_mu_[i_] =
##       (x[i_] >= cp_0) * inprod(rhs_matrix_[i_, c(3)], c(Intercept_1)) * 1 + 
##       (x[i_] >= cp_0) * inprod(rhs_matrix_[i_, c(4)], c(x_1)) * x_local_1_[i_] + 
##       (x[i_] >= cp_1) * inprod(rhs_matrix_[i_, c(5)], c(x_2)) * x_local_2_[i_]
##     
##     # Formula for sigma
##     link_sigma_[i_] =
##       (x[i_] >= cp_0) * inprod(rhs_matrix_[i_, c(6)], c(sigma_1)) * 1
## 
##     # Likelihood and log-density for family = gaussian()
##     mu_[i_] = link_mu_[i_] + resid_garma_[i_]
##     sigma_[i_] = max(1e-03, link_sigma_[i_])
##     y[i_] ~ dnorm(mu_[i_], 1 / sigma_[i_]^2)
##     garma_y_[i_] = y[i_]
##     garma_link_y_[i_] = garma_y_[i_]
##     resid_abs_[i_] = garma_link_y_[i_] - link_mu_[i_]
##   }
## }