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 format of each segment is generally response ~ changepoint ~ predictors + sigma(variance_predictors), except for the first segment where there is no change point (response ~ predictors + sigma(variance_predictors)). Here is a simple and a complex formula

For convenience, you can omit the response and the change point in segment 2+, in which case the former response and an intercept-only (as opposed to random/varying) change point is 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, sample = FALSE)
summary(fit)

Notice how it added the response and the change point 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:

These parameter names are saved in fit$pars. Let us specify a somewhat complex model to show off some parameter names:

model = list(
  # int_1
  score ~ 1,

  # cp_1, cp_1_sd, cp_1_id, x_2
  1 + (1|id) ~ 0 + year,

  # cp_2, cp_2_sd, cp_2_condition, int_2, x_2
  1 + (1|condition) ~ 1 + rel(year)
)

# Intepret, but do not sample.
fit = mcp(model, sample = FALSE)
str(fit$pars, vec.len = 99)  # Compact display

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
int_1 = 5
int_2 = 10

# Ifelse version
y_ifelse = ifelse(x <= cp_1, yes = int_1, no = int_2)

# Indicator equivalent using dummy helpers
cp_0 = -Inf
cp_2 = Inf
y_indicator = (x > cp_0) * (x <= cp_1) * int_1 +  # Between cp_0 and cp_1
              (x > cp_1) * (x <= cp_2) * int_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) * int_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, sample = FALSE, par_x = "x")
fit$jags_code

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) * int_2")

Let us see this in action:

model = list(y ~ 0 + x,
                ~ 0 + x)
fit = mcp(model, sample = FALSE)
fit$jags_code

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

mcp allows for specifying relative intercepts and change points through rel(). 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
int_1 = 5
int_2 = 3  # let's model this as relative

# Indicator version.
cp_0 = -Inf
y_indicator = (x > cp_0) * int_1 +  # Note: no (x < cp_1)
              (x > cp_1) * (int_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.



lindeloev/mcp documentation built on Oct. 2, 2024, 1:52 a.m.