mcp takes a list of formulas, and defines the change
point as the point on the x-axis where the data shifts from being
generated by one formula to the next. So with N formulas,
you have N - 1 change points. A list with just one formula
thus correspond to normal regression with 0 change points.
The formulas are called “segments” because they divide the (observed)
x-axis into N segments.
Formula format for segments
The three formula parts are called response,
cp, and predictor. The general format
is response ~ cp ~ predictor, except for the first segment,
which has no change point and therefore uses
response ~ predictor. Here cp names a formula
part; it is not a literal variable name. Distributional terms such as
sigma() live in the predictor part.
- The response is just the name of a column in your data.
-
The cp part can be
1for a population-level change point or1 + (1|group)for group-level change-point deviations. -
The predictor part can contain
0(no intercept),1(an intercept), columns from your data whose effects you want to model, and group-level effects, such as(1|group)and(factor||group). -
Distributional predictors, such as those inside
sigma(), have their own article, but follow the same predictor rules, including group-level effects.
Predictor group-level effects use familiar
lme4/brms syntax. For example,
y ~ 1 + (1|id) gives each id a deviation from
the population intercept. Use || for several independent
group coefficients: (1 + x||id) adds an intercept and
numeric slope, while (factor||id) adds an intercept and
treatment-coded factor contrasts. (0 + factor||id) instead
adds one independent coefficient for each factor level. Each coefficient
has its own population-level SD.
A group-level term carries into later segments until another term for
the same distributional parameter and grouping factor replaces the whole
block; use (0|id) to turn it off. Correlated
multi-coefficient | terms, such as (1 + x|id),
and group-level terms inside ar() or ma() are
not yet supported.
Transformations in formulas are evaluated on the original predictor
values, as in lm() and glm(). The change-point
predictor is the exception: bare par_x and polynomial bases
such as I(par_x^2) use distance from the segment onset so
that they can describe joined segment shapes.
For convenience, you can omit the response and cp parts in segment
2+. The previous response and an intercept-only population-level change
point are then assumed. When you call summary(fit), it will
show the explicit representation. Let us see this in action for this
model where we predict score as a function of
time in three segments, i.e., with two change points:
library(mcp)
model = list(
score ~ 1, # intercept
score ~ 1 ~ 0 + time, # joined slope
~ time # disjoined slope. "score ~ 1 ~ 1 + time" is implicit here.
)
# Interpret, but do not sample.
fit = mcp(model, data = data.frame(score = 1:10, time = 1:10), sample = FALSE)
summary(fit)## Family: gaussian(link = 'identity')
## Segments:
## 1: score ~ 1
## 2: score ~ 1 ~ 0 + time
## 3: score ~ 1 ~ time
##
## No samples. Nothing to summarise.
Notice how it added the response and cp parts to the last segment?
mcp is heavily inspired by brms which again
is inspired by lme4::lmer.
Here
is a bit of history on that.
Parameter names
mcp automatically assigns names to the parameters in the
format type_i where i is the segment number.
Specifically:
-
Intercept_iis the intercept in the ith segment. -
year_iis the slope in on the data columnyearin the ith segment.x_iis the slope on the data columnxin the ith segment. The slope takes name after the data it is regressed on. -
cp_iis the ith change point. Notice thatcp_iis specified in segmenti + 1.cp_1occurs when there are two segments, andcp_2when there are three segments, etc. OBS: future versions may start at cp_2.. -
cp_i_groupis the varying deviations fromcp_i. See varying change points in mcp. -
cp_i_sdis the population-level standard deviation of the varying effects. -
sigma_*are variance parameters about which you can read more here). With an explicitsigma()formula these are log-SD coefficients. If nosigma()is supplied, the implicitsigma_1is instead the common response-scale SD initiated in the first segment. -
arj_iare autocorrelation coefficients of orderjfor segmenti(read more here).
These parameter names are saved in fit$pars. Let us
specify a somewhat complex model to show off some parameter names:
model = list(
# Intercept_1
score ~ 1,
# cp_1, cp_1_sd, cp_1_id, year_2
1 + (1|id) ~ 0 + year,
# cp_2, cp_2_sd, cp_2_condition, Intercept_3, year_3
1 + (1|condition) ~ 1 + year
)
# Interpret, but do not sample.
fit = mcp(model, data = data.frame(score = 1:10, year = 1:10, id = 1:10, condition = rep(c("A", "B"), 5)), sample = FALSE)
str(fit$pars, vec.len = 99) # Compact display## List of 10
## $ x : chr "year"
## $ y : chr "score"
## $ cp : chr [1:2] "cp_1" "cp_2"
## $ fixed : chr [1:4] "Intercept_1" "year_2" "Intercept_3" "year_3"
## $ population: chr [1:9] "cp_1" "cp_1_sd" "cp_2" "cp_2_sd" "Intercept_1" "year_2" "Intercept_3" "year_3" "sigma_1"
## $ varying : chr [1:2] "cp_1_id" "cp_2_condition"
## $ sigma : chr "sigma_1"
## $ arma : chr(0)
## $ trials : NULL
## $ weights : NULL
## - attr(*, "class")= chr [1:2] "mcplist" "list"
Modeling intercept change points
A change point is simply like an ifelse statement or
multiplying with indicators (0s and 1s):
# Model parameters
x = 1:20
cp_1 = 12
Intercept_1 = 5
Intercept_2 = 10
# Ifelse version
y_ifelse = ifelse(x <= cp_1, yes = Intercept_1, no = Intercept_2)
# Indicator equivalent using dummy helpers
cp_0 = -Inf
cp_2 = Inf
y_indicator = (x > cp_0) * (x <= cp_1) * Intercept_1 + # Between cp_0 and cp_1
(x > cp_1) * (x <= cp_2) * Intercept_2 # Between cp_1 and cp_2
# Show it
par(mfrow = c(1,2))
plot(x, y_ifelse, main = "ifelse(x <= cp_1)")
plot(x, y_indicator, main = "(x > cp_1) * Intercept_2")
The magic of (Bayesian) MCMC sampling is that it can actually infer
the change point from this simple formulation. We let mcp
write the JAGS code for this simple two-plateaus model and see how it
uses the indicator formulation of change points:
model = list(y ~ 1, ~ 1)
fit = mcp(model, data = data.frame(y = 1:10, x = 1:10), sample = FALSE, par_x = "x")
fit$jags_code## model {
## # mcp helper values
## cp_0 = CONST1_
## cp_2 = CONST2_
##
## # Priors for population-level effects
## cp_1 ~ dunif(CONST1_, CONST2_) # Within the observed change-point span
## Intercept_1 ~ dt(5.5, 1/(3.7)^2, 3) # Robustly centered mean intercept with a minimum scale of 2.5
## Intercept_2 ~ dt(5.5, 1/(3.7)^2, 3) # Robustly centered mean intercept with a minimum scale of 2.5
## sigma_1 ~ dt(0, 1/(3.7)^2, 3) T(0,) # Positive residual SD calibrated on the response scale
##
## # 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
##
## # Formula for mu
## link_mu_[i_] =
##
## # Segment 1: y ~ 1
## (x[i_] >= cp_0) * (x[i_] < cp_1) * inprod(rhs_matrix_[i_, c(1)], c(Intercept_1)) * 1 +
##
## # Segment 2: y ~ 1 ~ 1
## (x[i_] >= cp_1) * inprod(rhs_matrix_[i_, c(2)], c(Intercept_2)) * 1
##
## # Formula for sigma
## link_sigma_[i_] =
##
## # Segment 1: y ~ 1
## (x[i_] >= cp_0) * inprod(rhs_matrix_[i_, c(3)], c(sigma_1)) * 1
##
## # Likelihood and log-density for family = gaussian()
## mu_[i_] = link_mu_[i_]
## sigma_[i_] = max(1e-03, link_sigma_[i_])
## y[i_] ~ dnorm(mu_[i_], 1 / sigma_[i_]^2) # SD as precision
## }
## }
Look at the section called # Fitted value which is the
(automatically generated) model that was discussed above. Some
unnecessary stuff is added to segment 1 just because it makes the code
easier to generate. (x[i_] >= cp_0 when
cp_0 is the smallest value of x is, of course,
always true).
Modeling slope change points
We can use the same principle to model change points on slopes.
However, we have to “take off” where the previous slope left us on the
y-axis. That is, we have to regard whatever y-value the previous segment
ended with as a kind of intercept-at-x=0 in the frame of the new
segment. The intercept of segment 2 is cp_1 * slope_1 and
the slope in segment 2 is x * (slope_2 - cp_1).
# Model parameters
x = 1:20
cp_1 = 12
slope_1 = 2
slope_2 = -1
# Ifelse version
y_ifelse = ifelse(x <= cp_1,
yes = slope_1 * x,
no = cp_1 * slope_1 + slope_2 * (x - cp_1))
# Indicator version. pmin() is a vectorized min()
cp_0 = -Inf
y_indicator = (x > cp_0) * slope_1 * pmin(x, cp_1) +
(x > cp_1) * slope_2 * (x - cp_1)
# Show it
par(mfrow = c(1,2))
plot(x, y_ifelse, main = "ifelse(x <= cp_1)")
plot(x, y_indicator, main = "(x > cp_1) * Intercept_2")
Let us see this in action:
model = list(y ~ 0 + x,
~ 0 + x)
fit = mcp(model, data = data.frame(y = 1:10, x = 1:10), sample = FALSE)
fit$jags_code## model {
## # mcp helper values
## cp_0 = CONST1_
## cp_2 = CONST2_
##
## # Priors for population-level effects
## cp_1 ~ dunif(CONST1_, CONST2_) # Within the observed change-point span
## x_1 ~ dt(0, 1/(0.8222222)^2, 3) # Regularizing mean coefficient scaled to a reference predictor change
## x_2 ~ dt(0, 1/(0.8222222)^2, 3) # Regularizing mean coefficient scaled to a reference predictor change
## sigma_1 ~ dt(0, 1/(3.7)^2, 3) T(0,) # Positive residual SD calibrated on the response scale
##
## # 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
##
## # Formula for mu
## link_mu_[i_] =
##
## # Segment 1: y ~ 0 + x
## (x[i_] >= cp_0) * inprod(rhs_matrix_[i_, c(1)], c(x_1)) * x_local_1_[i_] +
##
## # Segment 2: y ~ 1 ~ 0 + x
## (x[i_] >= cp_1) * inprod(rhs_matrix_[i_, c(2)], c(x_2)) * x_local_2_[i_]
##
## # Formula for sigma
## link_sigma_[i_] =
##
## # Segment 1: y ~ 0 + x
## (x[i_] >= cp_0) * inprod(rhs_matrix_[i_, c(3)], c(sigma_1)) * 1
##
## # Likelihood and log-density for family = gaussian()
## mu_[i_] = link_mu_[i_]
## sigma_[i_] = max(1e-03, link_sigma_[i_])
## y[i_] ~ dnorm(mu_[i_], 1 / sigma_[i_]^2) # SD as precision
## }
## }
Again, look at the #Fitted value to see the
indicator-version in action. And again, mcp adds something
about cp_0 = -Inf and cp_2 = Inf, just for
internal convenience.
You will find the exact same formula for y_ = ... if you
do print(fit$simulate), though this function contains a
whole lot of other stuff too.
Modeling relative slopes and intercepts
Relative parameters and changepoints can be modeled by specifying
prior relationships or parameter offsets. Relative slopes are easy: just
replace x_2 with x_1 + x_2. You could do the
same if all segments are intercept-only. However, if the previous
segment had a slope, we want the intercept to be relative to where that
“ended”. The mcp solution is to only “turn off” the
“hanging intercept” from that slope’s ending
(pmin(x, cp_i)) when the model encounters an absolute
intercept. An indicator does this.
# Model parameters
x = 1:20
cp_1 = 12
Intercept_1 = 5
Intercept_2 = 3 # let's model this as relative
# Indicator version.
cp_0 = -Inf
y_indicator = (x > cp_0) * Intercept_1 + # Note: no (x < cp_1)
(x > cp_1) * (Intercept_2)
# Plot it
plot(x, y_indicator, main = "Relative intercept")
You can look at fit$jags_code and
fit$simulate() to see this in action.
