--- title: "Testing the last observation for instability" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Testing the last observation for instability} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4.5, dpi = 150, out.width = "100%" ) ``` ```{r library} library(proxymix) ``` ```{r engines} has_ggplot2 <- requireNamespace("ggplot2", quietly = TRUE) ``` ```{r stored-results, include = FALSE} ## The comparison table reads stored simulation results. They must come ## from the same major.minor version of proxymix as this build. res <- readRDS("results/end_of_sample.rds") major_minor <- function(v) paste(unlist(package_version(v))[1:2], collapse = ".") if (major_minor(res$proxymix_version) != major_minor(as.character(packageVersion("proxymix")))) { stop("results/end_of_sample.rds was built under proxymix ", res$proxymix_version, ", but this is proxymix ", packageVersion("proxymix"), ". Rerun the simulation and ", "data-raw/vignette_results/end_of_sample.R.", call. = FALSE) } ## Small numbers are written as plain decimals rather than in the ## scientific notation that knitr's inline hook would otherwise use. fixed <- function(v, digits) { format(round(v, digits), nsmall = digits, scientific = FALSE) } ``` ## The problem Suppose you follow a series over time, such as monthly sales, and you have a model fitted to every value so far. One new value has just arrived. You want to know whether it is consistent with the model, or whether something has changed. Most tests for a change in a series need data on both sides of the change. The breakpoint test of Chow (1960) and the sup-Wald test of Andrews (1993), which tries every possible change point, estimate the model before and after a change and compare the two. With one new value, or a handful, the model after the change cannot be estimated. Chow (1960) also proposed a predictive test that works in this case, but it assumes that the errors follow a normal distribution (Andrews, 2003). Andrews (2003) proposed end-of-sample tests for exactly this case. proxymix implements a version of them for state-space models. These models describe a series through an unobserved state, such as a level, that changes over time. ## Package capabilities - `gmm_filter()` runs the Kalman filter, which tracks the unobserved state of a series one time step at a time. Before each new value arrives, the filter forecasts it. The difference between the value and its forecast is called the innovation. - `gmm_eos_test()` tests whether the last `m` values of a series are consistent with the model. It takes the model in the same form as `gmm_filter()`. It returns a p-value, a decision at the 5% level, and the numbers used to compute them. The test statistic is built from the innovations. Each innovation is divided by its standard deviation, which the filter also reports. If the model is correct and nothing has changed, the result, $z_t$, follows a standard normal distribution. The statistic adds up the squares of the last `m` of them: $$ \mathrm{EoS}_m = \sum_{t = n-m+1}^{n} z_t^2. $$ A change in the last `m` values makes their forecasts worse and the statistic larger. `gmm_eos_test()` offers two ways, or calibrations, of turning the statistic into a p-value. - `method = "chisq"`, the default, compares the statistic with a chi-square distribution, the distribution of a sum of squared standard normal values. Its degrees of freedom, the number of squared values in the sum, are `m` times the number of variables observed at each time step. The p-value is exact when the innovations are normal and the model's parameters are known. - `method = "andrews"` compares the statistic with the same statistic computed on every earlier block of `m` consecutive values of the series. The p-value is the share of blocks at least as large as the final block, counting the final block itself. Taking p-values from the data in this way is called subsampling. It follows the P-test of Andrews (2003), with two differences: the p-value counts the tested block, and the earlier blocks are computed from the supplied model rather than from a model re-estimated without each block. The subsampling calibration does not assume normal innovations. It does make other assumptions. Its p-value is valid only as the series grows long with `m` fixed, and only if the innovations before the tested block are stationary, meaning that their distribution does not change over time. Its size in a short series can differ from nominal in either direction: the discreteness of the p-value makes it conservative at `m = 1` with the model's parameters known, but it can reject too often when the blocks are no longer exchangeable, such as with estimated parameters or the overlapping blocks of `m > 1`. ## Addressing the problem ```{r seed} set.seed(20260621) ``` ### A model and a stable series The model is a local-level model. An unobserved level moves as a random walk, taking a small normal step at each time point. Each observation is the level plus normal noise. In `gmm_filter()` form, the prior gives the starting level, `dynamics` gives the steps of the level, and `measurement` gives the noise of the observations. `gmm()` builds a mixture of normal distributions, and with `weights = 1` it is a single normal distribution. A prior variance of 10 means the starting level is known only roughly. ```{r model} prior <- gmm(weights = 1, means = list(0), covariances = list(matrix(10))) dynamics <- list(A = matrix(1), Q = matrix(0.04)) # the level's random walk measurement <- list(C = matrix(1), R = matrix(1)) # the observation noise n <- 120L level <- cumsum(c(0, rnorm(n - 1L, 0, sqrt(0.04)))) y_stable <- level + rnorm(n, 0, 1) ``` ### The same series with a broken final value A copy of the series has its final value moved up by five standard deviations of the observation noise. Both series are tested at `m = 1` with the subsampling calibration. ```{r break} y_break <- y_stable y_break[n] <- y_break[n] + 5 test_stable <- gmm_eos_test( prior, dynamics, measurement, y_stable, m = 1L, method = "andrews" ) test_break <- gmm_eos_test( prior, dynamics, measurement, y_break, m = 1L, method = "andrews" ) ``` ```{r tests-kable, echo = FALSE} eos_row <- function(x) { c( round(x$statistic, 3L), round(x$p_value, 4L), x$reject, x$method, x$m ) } knitr::kable( data.frame( field = c("statistic", "p-value", "reject at 0.05", "calibration", "window m"), stable = eos_row(test_stable), broken = eos_row(test_break) ), col.names = c("Result", "Stable series", "Broken final value"), caption = paste( "The end-of-sample test on the same series before and after its", "final value is moved up by five standard deviations of the", "observation noise." ) ) ``` ### What the filter expected The figure shows the forecasts behind the statistic. Before each value arrives, the filter forecasts it from the values before it. The shaded band spans two forecast standard deviations either side of each forecast. The statistic divides each innovation by this forecast standard deviation. ```{r filter-path} filtered <- gmm_filter( prior, dynamics, measurement, y_stable, ridge_eps = 0 ) # ridge_eps adds a tiny amount to each covariance for numerical stability; # 0 keeps the filter's recursion exact path <- filtered$summary # forecast of y_t and its standard deviation, from the filter at t - 1 pred_mean <- c(prior@means[[1L]], path$mean_1[-n]) pred_sd <- sqrt(c(prior@covariances[[1L]][1L, 1L], path$sd_1[-n]^2) + dynamics$Q[1L, 1L] + measurement$R[1L, 1L]) outside <- sum(abs(y_stable - pred_mean)[-1L] > 2 * pred_sd[-1L]) ``` ```{r fig-series, eval = has_ggplot2, echo = has_ggplot2, fig.height = 4, fig.cap = "The simulated series, the unobserved level that generated it, the level estimated by the filter, and a band of two forecast standard deviations either side of each forecast. The band starts at the second time step. The broken final value lies far outside the band.", fig.alt = "A time series of noisy observations in grey with the true and estimated level overlaid, a shaded forecast band around them, and a single isolated point at the right-hand end lying well above the band."} series_df <- data.frame( t = seq_len(n), observed = y_stable, truth = level, filtered = path$mean_1, lo = pred_mean - 2 * pred_sd, hi = pred_mean + 2 * pred_sd ) break_df <- data.frame(t = n, y = y_break[n]) ggplot2::ggplot(series_df, ggplot2::aes(t)) + ggplot2::geom_ribbon( data = series_df[-1L, ], ggplot2::aes(ymin = lo, ymax = hi), fill = "#0072B2", alpha = 0.18 ) + ggplot2::geom_point( ggplot2::aes(y = observed, colour = "observed (stable)"), size = 1.1, alpha = 0.7 ) + ggplot2::geom_line( ggplot2::aes(y = truth, colour = "true level"), linewidth = 0.8 ) + ggplot2::geom_line( ggplot2::aes(y = filtered, colour = "estimated level"), linewidth = 0.8 ) + ggplot2::geom_point( data = break_df, ggplot2::aes(t, y, colour = "broken final value"), size = 2.6 ) + ggplot2::scale_colour_manual( name = NULL, values = c( "observed (stable)" = "grey60", "true level" = "#009E73", "estimated level" = "#0072B2", "broken final value" = "#D55E00" ) ) + ggplot2::labs( x = "time step", y = "observation", title = "A break in the last observation" ) + ggplot2::theme_minimal(base_size = 11) + ggplot2::theme(legend.position = "top") ``` ```{r fig-series-skip, eval = !has_ggplot2, echo = FALSE, results = "asis"} cat("ggplot2 is not installed on this build, so the series figure is", "skipped.\n") ``` ### How the subsampling p-value is reached The subsampling calibration ranks the final block among the earlier blocks of the same series. The next figure shows that ranking for both series. ```{r fig-blocks, eval = has_ggplot2, echo = has_ggplot2, fig.height = 4, fig.cap = "The cumulative distribution of the earlier block statistics in each series, with the final block's statistic as a dashed line. The p-value is $(1 + k) / (n - 2m + 2)$, where $k$ is the number of earlier blocks at or beyond the dashed line. The stable series' final block lies inside the range of earlier blocks. The broken series' final block lies beyond every earlier block.", fig.alt = "Two panels, each showing a rising step function of the cumulative share of earlier block statistics against a logarithmic axis, with a dashed vertical line. In the left panel the line falls inside the curve. In the right panel it lies far beyond the curve's right-hand end."} blocks_df <- rbind( data.frame( block = test_stable$in_sample_blocks, series = "stable series" ), data.frame( block = test_break$in_sample_blocks, series = "broken final value" ) ) blocks_df$series <- factor( blocks_df$series, levels = c("stable series", "broken final value") ) rule_df <- data.frame( series = factor( c("stable series", "broken final value"), levels = c("stable series", "broken final value") ), statistic = c(test_stable$statistic, test_break$statistic) ) ggplot2::ggplot(blocks_df, ggplot2::aes(block)) + ggplot2::stat_ecdf(geom = "step", linewidth = 0.8, colour = "#0072B2") + ggplot2::geom_vline( data = rule_df, ggplot2::aes(xintercept = statistic), colour = "#D55E00", linetype = "dashed", linewidth = 0.8 ) + ggplot2::facet_wrap(~ series) + ggplot2::scale_x_log10(labels = function(v) { format(v, scientific = FALSE, drop0trailing = TRUE, trim = TRUE) }) + ggplot2::labs( x = "block statistic (log scale)", y = "cumulative share of earlier blocks", title = "The final block against the earlier blocks of the same series" ) + ggplot2::theme_minimal(base_size = 11) ``` ```{r fig-blocks-skip, eval = !has_ggplot2, echo = FALSE, results = "asis"} cat("ggplot2 is not installed on this build, so the block figure is", "skipped.\n") ``` ### How often each calibration rejects when the model is known The size of a test is how often it rejects when nothing has changed. A test at the 5% level should have a size of 0.05. The chunk below simulates stable series of 30 values from the same model, tests each one, and records the share of p-values below 0.05. The model's parameters are known here, as in the example above. ```{r size-study} simulate_null <- function(n_obs) { lv <- cumsum(c(0, rnorm(n_obs - 1L, 0, sqrt(0.04)))) lv + rnorm(n_obs, 0, 1) } empirical_size <- function(n_obs, method, n_rep) { p <- vapply(seq_len(n_rep), function(i1) { gmm_eos_test( prior, dynamics, measurement, simulate_null(n_obs), m = 1L, method = method )$p_value }, numeric(1L)) size <- mean(p < 0.05) c(size = size, se = sqrt(size * (1 - size) / n_rep)) } n_rep <- 250L size_chisq_30 <- empirical_size(30L, "chisq", n_rep) size_andrews_30 <- empirical_size(30L, "andrews", n_rep) ``` The subsampling p-value can take only the values $(1 + k) / (n - 2m + 2)$ for $k = 0, 1, 2, \ldots$. Its smallest value is therefore $1 / (n - 2m + 2)$. If the final block is equally likely to take any rank among the blocks, its size is the share of those values below 0.05. This holds at $m = 1$ with known parameters, apart from the first few innovations, which the rough starting level affects. It does not hold when the parameters are estimated or when $m > 1$. ```{r grid-size} grid_size <- function(n_obs, m = 1L, alpha = 0.05) { n_block <- n_obs - 2L * m + 1L p_grid <- (1L + seq.int(0L, n_block)) / (1L + n_block) mean(p_grid < alpha) } grid_30 <- grid_size(30L) grid_120 <- grid_size(120L) ``` ```{r size-checks, include = FALSE} ## the prose below says the chi-square size does not differ detectably ## from 0.05 and the subsampling size agrees with its grid value stopifnot(abs(size_chisq_30[["size"]] - 0.05) < 2 * size_chisq_30[["se"]], abs(size_andrews_30[["size"]] - grid_30) < 2 * size_andrews_30[["se"]]) ``` ```{r size-kable, echo = FALSE} old_opts <- options(knitr.kable.NA = "--") knitr::kable( data.frame( calibration = c("chi-square", "subsampling", "subsampling", "subsampling"), n_obs = c(30L, 30L, 30L, 120L), how = c("simulated", "simulated", "from the p-value grid", "from the p-value grid"), size = round(c(size_chisq_30[["size"]], size_andrews_30[["size"]], grid_30, grid_120), 4L), se = round(c(size_chisq_30[["se"]], size_andrews_30[["se"]], NA_real_, NA_real_), 4L) ), col.names = c("Calibration", "Series length", "Obtained", "Size", "Standard error"), caption = paste0( "Size at a nominal 0.05 with the model's parameters known, for m = 1. ", "Simulated sizes use ", n_rep, " stable series each. Sizes from the ", "p-value grid are exact when the final block's rank is equally likely ", "to be any rank, so they have no standard error." ) ) options(old_opts) ``` ### Comparison with strucchange, changepoint, KFAS and dlm ```{r compare-facts, include = FALSE} sim_value <- function(m, scenario, method) { s1 <- res$sim_tab$m == m & res$sim_tab$scenario == scenario & res$sim_tab$method == method res$sim_tab$rate[s1] } size_at <- function(method, m) fixed(sim_value(m, "no shift", method), 3) power_at <- function(method, m) fixed(sim_value(m, "shift", method), 3) mcse <- sqrt(0.05 * 0.95 / res$n_rep) ms_of <- function(pkg) fixed(1000 * res$time_secs[[pkg]], 0) ## the prose below rests on these orderings in the stored results size_1 <- vapply(unique(res$sim_tab$method), function(k) { sim_value(1L, "no shift", k) }, numeric(1L)) stopifnot(identical(names(size_1)[!is.na(size_1) & abs(size_1 - 0.05) <= mcse], "proxymix subsampling")) stopifnot(abs(sim_value(5L, "no shift", "strucchange sup-F") - 0.05) < min(abs(sim_value(5L, "no shift", "proxymix chi-square") - 0.05), abs(sim_value(5L, "no shift", "proxymix subsampling") - 0.05))) stopifnot(sim_value(1L, "shift", "proxymix subsampling") < min(sim_value(1L, "shift", "proxymix chi-square"), sim_value(1L, "shift", "KFAS forecast interval"), sim_value(1L, "shift", "dlm forecast interval"))) stopifnot(names(which.max(res$time_secs)) == "KFAS", res$time_secs[["strucchange"]] < 0.001, res$time_secs[["changepoint"]] < 0.001) ``` In practice the model's parameters are estimated from the same series that is tested. A simulation compared the two calibrations with six other checks in this setting. Each of `r res$n_rep` series had `r res$n` values from an autoregressive model, in which each value is `r res$phi` times the previous one plus standard normal noise. Every check was applied at $m$ = 1, 3 and 5, where it is defined. proxymix's two calibrations, KFAS, dlm and the t-test were fitted to all but the last $m$ values and then tested against those values; the `strucchange` and `cpt.mean()` checks below instead ran on the whole series. The power is how often a check rejected when the last $m$ values were raised by `r res$delta` noise standard deviations. The size is how often it rejected when they were not. The other checks were the sup-F test and the OLS-CUSUM test of `strucchange` (Zeileis et al., 2002), `cpt.mean()` of `changepoint` (Killick and Eckley, 2014), and a t-test on the forecast errors of the last $m$ values. The sup-F test looks for a change near the end of the series in the regression of each value on the previous one. The OLS-CUSUM test looks for drift, over the whole series, in the cumulative sum of the errors of that regression. `cpt.mean()` searches for changes in the mean, and it counts as rejecting when it places a change among the last $m$ values. `KFAS` (Helske, 2017) and `dlm` (Petris, 2010) each fitted their own autoregressive model and checked every tested value against its 95% forecast interval. Each interval had a level of $1 - 0.05 / m$, which splits the 5% error rate evenly over the $m$ values (a Bonferroni correction). The check then has a size of at most 0.05 when the parameters are known. ```{r compare-table, echo = FALSE} method_order <- c("proxymix chi-square", "proxymix subsampling", "strucchange sup-F", "strucchange OLS-CUSUM", "changepoint cpt.mean", "KFAS forecast interval", "dlm forecast interval", "t-test on forecast residuals") method_label <- c("proxymix, chi-square (default)", "proxymix, subsampling", "strucchange, sup-F", "strucchange, OLS-CUSUM", "changepoint, cpt.mean()", "KFAS, forecast interval", "dlm, forecast interval", "t-test on forecast errors") rate_col <- function(scenario, m) { v <- vapply(method_order, function(k) sim_value(m, scenario, k), numeric(1L)) ifelse(is.na(v), "--", format(round(v, 3L), nsmall = 3L)) } m_shown <- c(1L, 5L) cmp_tbl <- data.frame(method = method_label, stringsAsFactors = FALSE) for (m1 in m_shown) { cmp_tbl[[paste0("size_", m1)]] <- rate_col("no shift", m1) cmp_tbl[[paste0("power_", m1)]] <- rate_col("shift", m1) } knitr::kable( cmp_tbl, row.names = FALSE, align = c("l", rep("r", 2L * length(m_shown))), col.names = c("Check", as.vector(rbind(paste0("Size, m = ", m_shown), paste0("Power, m = ", m_shown)))), caption = paste0( "Rejection rates at a nominal 0.05 over ", res$n_rep, " simulated ", "series of ", res$n, " values, with parameters estimated from each ", "series. Size is the rate without a change and should be near 0.05. ", "Power is the rate when the last m values were raised by ", res$delta, " noise standard deviations. A size near 0.05 has a simulation ", "standard error of about ", fixed(mcse, 3), ". A dash marks a check ", "that cannot be computed at that m. The extended version of this ", "article also reports m = 3." ) ) ``` At a single final value, only the subsampling calibration had a size close to 0.05, at `r size_at("proxymix subsampling", 1)`. The default chi-square calibration had a size of `r size_at("proxymix chi-square", 1)`, close to that of `dlm` (`r size_at("dlm forecast interval", 1)`), and that of `KFAS` was `r size_at("KFAS forecast interval", 1)`. All three treat the estimated parameters as if they were known. Of these four forecast checks, the subsampling calibration had the lowest power, `r power_at("proxymix subsampling", 1)` against `r power_at("proxymix chi-square", 1)` for the chi-square calibration, `r power_at("KFAS forecast interval", 1)` for `KFAS` and `r power_at("dlm forecast interval", 1)` for `dlm`. At $m = 5$ neither calibration held its size (`r size_at("proxymix chi-square", 5)` and `r size_at("proxymix subsampling", 5)`), and the sup-F test came closer, at `r size_at("strucchange sup-F", 5)`. Its power was `r power_at("strucchange sup-F", 5)`, against `r power_at("proxymix chi-square", 5)` for the chi-square calibration. At $m = 3$, a column the table omits, the sup-F test had a size of `r size_at("strucchange sup-F", 3)`, far above 0.05. The OLS-CUSUM test and `cpt.mean()` rejected fewer than 5% of stable series. The OLS-CUSUM test rarely detected a change confined to the last few values, with a power of `r power_at("strucchange OLS-CUSUM", 1)` at $m = 1$ and `r power_at("strucchange OLS-CUSUM", 5)` at $m = 5$. `cpt.mean()` detected it in `r power_at("changepoint cpt.mean", 1)` of series at $m = 1$ and `r power_at("changepoint cpt.mean", 5)` at $m = 5$. `KFAS` was the slowest of the five packages. For one series at $m$ = `r res$time_m`, it took `r ms_of("KFAS")` ms and `dlm` took `r ms_of("dlm")` ms, each including its own fit. The two proxymix calibrations together took `r ms_of("proxymix")` ms, including the `arima()` fit that supplies the parameters. `strucchange` and `changepoint` each took less than 1 ms (median of five runs of `r res$time_n_call` calls each, on one computer). The code below applies every check to one simulated series with `m = 3`. It is the simulation code for a single series. It needs `strucchange`, `changepoint`, `KFAS` and `dlm`, all on CRAN, and it is not run when this vignette is built. ```{r compare-code, eval = FALSE} library(proxymix) library(strucchange) library(changepoint) library(KFAS) library(dlm) # one series of 100 values whose last m values are raised by 3 set.seed(1L) n <- 100L m <- 3L y <- as.numeric(arima.sim(list(ar = 0.6), n = n)) y[(n - m + 1L):n] <- y[(n - m + 1L):n] + 3 y_fit <- y[seq_len(n - m)] last <- (n - m + 1L):n # proxymix, with the autoregressive model fitted by arima() ar <- arima(y_fit, order = c(1L, 0L, 0L)) a1 <- coef(ar)[["ar1"]] mu <- coef(ar)[["intercept"]] s2 <- ar$sigma2 prior <- gmm(weights = 1, means = list(mu), covariances = list(matrix(s2 / (1 - a1^2)))) dynamics <- list(A = matrix(a1), b = mu * (1 - a1), Q = matrix(s2)) measurement <- list(C = matrix(1), R = matrix(0)) p_chisq <- gmm_eos_test(prior, dynamics, measurement, y, m = m, method = "chisq")$p_value p_andrews <- gmm_eos_test(prior, dynamics, measurement, y, m = m, method = "andrews")$p_value resid <- y[last] - predict(ar, n.ahead = m)$pred p_t <- if (m > 1L) t.test(resid)$p.value else NA_real_ # strucchange needs at least three observations after a break in an AR(1) reg <- data.frame(y = y[-1L], y_lag = y[-n]) n_reg <- nrow(reg) p_supf <- if (m >= 3L) { fs <- Fstats(y ~ y_lag, data = reg, from = n_reg - m, to = n_reg - 3L) unname(sctest(fs)$p.value) } else NA_real_ p_cusum <- unname(sctest(efp(y ~ y_lag, data = reg, type = "OLS-CUSUM"))$p.value) cp <- cpts(cpt.mean(y / sd(y_fit))) p_cpt <- if (any(cp >= n - m)) 0 else 1 y_bar <- mean(y_fit) kfas_update <- function(pars, model) { part <- SSMarima(ar = 0.999 * tanh(pars[1L]), Q = exp(pars[2L])) model["T", "arima"] <- part$T model["R", "arima"] <- part$R model["Q", "arima"] <- part$Q model["P1", "arima"] <- part$P1 model } kfas_fit <- fitSSM( SSModel(I(y_fit - y_bar) ~ -1 + SSMarima(ar = 0.5, Q = 1), H = 0), inits = c(0, 0), updatefn = kfas_update, method = "BFGS" ) kfas_par <- kfas_fit$optim.out$par kfas_model <- SSModel( I(y - y_bar) ~ -1 + SSMarima(ar = 0.999 * tanh(kfas_par[1L]), Q = exp(kfas_par[2L])), H = 0 ) kfas_pred <- predict(kfas_model, interval = "prediction", level = 0.95, filtered = TRUE) kfas_z <- (y[last] - y_bar - kfas_pred[last, "fit"]) / ((kfas_pred[last, "upr"] - kfas_pred[last, "lwr"]) / (2 * qnorm(0.975))) p_kfas <- min(1, m * 2 * pnorm(-max(abs(kfas_z)))) dlm_build <- function(pars) { dlmModARMA(ar = 0.999 * tanh(pars[1L]), sigma2 = exp(pars[2L]), dV = 0) } dlm_fit <- dlmMLE(y_fit - y_bar, parm = c(0, 0), build = dlm_build) dlm_filt <- dlmFilter(y - y_bar, dlm_build(dlm_fit$par)) dlm_var <- unlist(dlmSvd2var(dlm_filt$U.R, dlm_filt$D.R)) dlm_z <- (y[last] - y_bar - dlm_filt$f[last]) / sqrt(dlm_var[last]) p_dlm <- min(1, m * 2 * pnorm(-max(abs(dlm_z)))) c("proxymix chi-square" = p_chisq, "proxymix subsampling" = p_andrews, "strucchange sup-F" = p_supf, "strucchange OLS-CUSUM" = p_cusum, "changepoint cpt.mean" = p_cpt, "KFAS forecast interval" = p_kfas, "dlm forecast interval" = p_dlm, "t-test on forecast residuals" = p_t) ``` The [extended version of this article](https://max578.github.io/proxymix/articles/extended/end_of_sample.html) gives the full simulation, explains each check's size in detail, and applies all eight checks to the Nile river flow series, which changed level after 1898. ## Interpretation On the stable series the statistic is `r fixed(test_stable$statistic, 3)`, with a subsampling p-value of `r fixed(test_stable$p_value, 3)`. The test does not reject, because the last value lies where the filter expected it. Moving that value up by five noise standard deviations raises the statistic to `r fixed(test_break$statistic, 1)` and lowers the p-value to `r fixed(test_break$p_value, 4)`. The test rejects. In the first figure the broken value lies `r fixed(sqrt(test_break$statistic), 1)` forecast standard deviations from its forecast. The square of that distance is the statistic. This distance differs from five for two reasons. The forecast standard deviation is larger than the noise standard deviation, because it also includes the step of the level and the filter's uncertainty about the level. The stable value was also not exactly at its forecast. Of the `r n - 1L` stable values from the second time step on, `r outside` (`r fixed(100 * outside / (n - 1L), 1)` per cent) fall outside the band. Under the model, a band of two standard deviations leaves out about `r fixed(100 * 2 * pnorm(-2), 1)` per cent. In the second figure the broken value's statistic is larger than every one of the `r length(test_break$in_sample_blocks)` earlier block statistics. Its p-value is therefore the smallest possible, $1 / (n - 2m + 2) = `r fixed(1 / (n - 2L + 2L), 4)`$. That smallest p-value limits the subsampling calibration on short series. At $n = 30$ and $m = 1$ it is $1 / 30$, so the test rejects at the 5% level only when the last value is the most extreme of the series. Its size with known parameters is then `r fixed(grid_30, 4)` rather than 0.05. The simulated size, `r fixed(size_andrews_30[["size"]], 3)` with a standard error of `r fixed(size_andrews_30[["se"]], 3)`, agrees with this value. The chi-square calibration has no smallest p-value. Its simulated size at $n = 30$ is `r fixed(size_chisq_30[["size"]], 3)` with a standard error of `r fixed(size_chisq_30[["se"]], 3)`, which is not detectably different from 0.05. Which calibration to use depends on whether the parameters are known. With known parameters, the default `method = "chisq"` is exact when the noise is normal. The parameters are usually estimated from the series being tested. The comparison above used the default in that setting. At a single final value its size was `r size_at("proxymix chi-square", 1)` rather than 0.05. For a single final value with estimated parameters, use `method = "andrews"`. It was the only check in the comparison whose size stayed close to 0.05, and it detected the change slightly less often. It needs a series long enough that $1 / (n - 2m + 2)$ is well below 0.05. For windows of 3 or 5 values, neither calibration held its size in the comparison. At $m = 3$ their sizes were `r size_at("proxymix chi-square", 3)` and `r size_at("proxymix subsampling", 3)`. ## Limitations The size study in this article uses `r n_rep` series per calibration. A size near 0.05 then has a standard error of about `r fixed(sqrt(0.05 * 0.95 / n_rep), 3)`, so it cannot detect a departure from 0.05 of one or two percentage points. The simulations use normal noise throughout. The subsampling calibration does not assume normal noise, and the chi-square calibration does. Neither this article nor its extended version tests how the two calibrations behave when the noise has heavier tails than the normal. The comparison covers one autoregressive model, one series length, one size of change and a change confined to the tested values. It does not cover smaller or gradual changes, longer windows or other models. The test applies to a linear state-space model with normal noise, given in the same form as for `gmm_filter()`: a single-component `gmm` prior, a `dynamics` list and a `measurement` list. Process or measurement noise given as a mixture of normal distributions is rejected, because neither calibration is defined for it. The window `m` must be smaller than the series length, and the test is designed for small windows such as 1, 2 or 3. The test checks the last values against the model. It does not check the model. If the noise variances are estimated from the same short series and come out too small, the test rejects too often. ## Further reading *The closed-form operator calculus on a mixture* explains the forecast and update steps that `gmm_filter()` and this test run, and covers the Kalman filter in more detail. *Reading the entropy of a fitted mixture* covers summaries of a fitted mixture once it is in hand. *Fitting a proxy to a density you cannot sample* introduces the package's main task, fitting a mixture of normal distributions to a density. ## References Andrews, D. W. K. (1993). *Tests for parameter instability and structural change with unknown change point.* Econometrica 61(4), 821--856. . Andrews, D. W. K. (2003). *End-of-sample instability tests.* Econometrica 71(6), 1661--1694. . Chow, G. C. (1960). *Tests of equality between sets of coefficients in two linear regressions.* Econometrica 28(3), 591--605. . Helske, J. (2017). *KFAS: Exponential family state space models in R.* Journal of Statistical Software 78(10), 1--39. . Killick, R. and Eckley, I. A. (2014). *changepoint: An R package for changepoint analysis.* Journal of Statistical Software 58(3), 1--19. . Petris, G. (2010). *An R package for dynamic linear models.* Journal of Statistical Software 36(12), 1--16. . Zeileis, A., Leisch, F., Hornik, K. and Kleiber, C. (2002). *strucchange: An R package for testing for structural change in linear regression models.* Journal of Statistical Software 7(2), 1--38. . ## Reproduce Running the code with `set.seed(20260621)`, as above, reproduces the example, the figures and the size study exactly. The comparison is read from stored results of a simulation run under proxymix `r res$proxymix_version`, strucchange `r res$versions[["strucchange"]]`, changepoint `r res$versions[["changepoint"]]`, KFAS `r res$versions[["KFAS"]]` and dlm `r res$versions[["dlm"]]`, which took about `r round(res$elapsed_secs / 60)` minutes on one core. ```{r session-info, collapse = FALSE, class.output = "session-info"} sessionInfo() ```