## ----setup, include = FALSE--------------------------------------------------- knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 5, eval = TRUE ) ## ----fit---------------------------------------------------------------------- library(lame) set.seed(2026) n_fc <- 30 T_fc <- 6 rho_true <- 0.7 beta_bar <- 0.5 beta_t_true <- numeric(T_fc) beta_t_true[1] <- 0.9 for (t in 2:T_fc) { beta_t_true[t] <- beta_bar + rho_true * (beta_t_true[t - 1] - beta_bar) + rnorm(1, 0, 0.15) } Xdyad <- lapply(seq_len(T_fc), function(t) { x <- matrix(rnorm(n_fc * n_fc), n_fc, n_fc) array(x, dim = c(n_fc, n_fc, 1), dimnames = list(NULL, NULL, "trade")) }) a_fc <- rnorm(n_fc, 0, 0.4) b_fc <- rnorm(n_fc, 0, 0.4) Y <- lapply(seq_len(T_fc), function(t) { eta <- -0.5 + beta_t_true[t] * Xdyad[[t]][, , 1] + outer(a_fc, b_fc, "+") Yt <- matrix(rbinom(n_fc * n_fc, 1, pnorm(eta)), n_fc, n_fc) diag(Yt) <- NA rownames(Yt) <- colnames(Yt) <- sprintf("a%02d", seq_len(n_fc)) Yt }) names(Y) <- paste0("t", seq_len(T_fc)) round(beta_t_true, 3) # the truth we recover sapply(Y, function(y) round(mean(y, na.rm = TRUE), 3)) # per-period density fit <- lame( Y, Xdyad = Xdyad, family = "binary", R = 0, dynamic_beta = "dyad", # the trade coefficient is AR(1) dynamic_beta_kind = "ar1", # mean-reverting; switch to "rw1" for drift nscan = 150, burn = 30, odens = 5, verbose = FALSE ) # the recovered per-period coefficient tracks the simulated path coef_path <- coef(fit) trade_row <- grep("^trade[._]dyad$|trade", rownames(coef_path), value = TRUE)[1] round(coef_path[trade_row, ], 3) ## ----forecast----------------------------------------------------------------- set.seed(1) # predict(h=) propagates the AR(1) state stochastically; seed for reproducibility # 3-step-ahead forecast on the link scale (linear predictor) fc_link <- predict(fit, h = 3, type = "link") length(fc_link) # 3 (one matrix per future period) dim(fc_link[[1]]) # n x n (30 x 30 here; unipartite) # response-scale forecast applies the family inverse link per draw fc_resp <- predict(fit, h = 3, type = "response") # binary: each entry is a posterior-mean predicted probability range(fc_resp[[1]], na.rm = TRUE) # by_draw = TRUE returns the full [n, n, h, n_draws] array fc_full <- predict(fit, h = 3, type = "response", by_draw = TRUE) dim(fc_full) # n x n x 3 x n_draws # interval = "credible" returns a list of length-3 (lower / median / upper) # matrices per period at the requested quantiles (default 95%) fc_ci <- predict(fit, h = 3, type = "response", interval = "credible") str(fc_ci[[1]]) # list($lower, $median, $upper), each n x n ## ----forecast-plot, fig.width = 7, fig.height = 3.4, fig.alt="Left: heatmap of predicted tie probabilities among the 30 actors for the first forecast period, darker cells indicating higher predicted probability. Right: the across-dyad average predicted probability at horizons h = 1, 2, 3 with a shaded 95 percent credible band that widens with the horizon."---- library(ggplot2) library(patchwork) # left: forecast heatmap for the first future period P1 <- fc_resp[[1]] rn <- rownames(P1); if (is.null(rn)) rn <- sprintf("a%02d", seq_len(nrow(P1))) cn <- colnames(P1); if (is.null(cn)) cn <- sprintf("a%02d", seq_len(ncol(P1))) heat_df <- data.frame( sender = factor(rn[row(P1)], levels = rev(rn)), receiver = factor(cn[col(P1)], levels = cn), prob = as.vector(P1) ) p_heat <- ggplot(heat_df, aes(receiver, sender, fill = prob)) + geom_tile() + scale_fill_viridis_c(limits = c(0, 1), name = "P(tie)") + labs(title = "Forecast for t7", x = "Receiver", y = "Sender") + theme_bw(base_size = 9) + theme(panel.border = element_blank(), axis.text = element_blank(), axis.ticks = element_blank(), panel.grid = element_blank(), legend.position = "top") # right: across-dyad average predicted probability + average 95% band horizon_df <- data.frame( period = factor(paste0("t", T_fc + seq_along(fc_ci)), levels = paste0("t", T_fc + seq_along(fc_ci))), median = sapply(fc_ci, function(m) mean(m$median, na.rm = TRUE)), lower = sapply(fc_ci, function(m) mean(m$lower, na.rm = TRUE)), upper = sapply(fc_ci, function(m) mean(m$upper, na.rm = TRUE)) ) p_horizon <- ggplot(horizon_df, aes(period, median, group = 1)) + geom_ribbon(aes(ymin = lower, ymax = upper), fill = "grey85") + geom_line() + geom_point(size = 2) + labs(title = "Average forecast by horizon", x = "Forecast period", y = "Mean P(tie)") + theme_bw(base_size = 9) + theme(panel.border = element_blank(), axis.ticks = element_blank(), legend.position = "top") p_heat + p_horizon ## ----counterfactual----------------------------------------------------------- set.seed(1) # forecast draws are stochastic; seed so the printed delta summary reproduces # baseline scenario: freeze the trade covariate at the last observed period last_X <- Xdyad[[length(Xdyad)]] X_future <- list(last_X, last_X, last_X) fc_cf <- predict(fit, h = 3, type = "response", newdata = X_future) # counterfactual: shift every dyad's trade covariate up by one unit # going forward. Because `trade` is a standardised (mean-zero) covariate, # an additive +1 shift -- "one standard deviation more trade for every # pair" -- is the interpretable counterfactual; a multiplicative scaling # would be meaningless on a centred covariate (see caution 2 below). X_future_up <- lapply(X_future, function(x) { x[, , 1] <- x[, , 1] + 1 x }) fc_up <- predict(fit, h = 3, type = "response", newdata = X_future_up) # delta is the per-dyad change in posterior-mean cooperation probability # at h = 3, attributable solely to the +1 trade shift delta_h3 <- fc_up[[3]] - fc_cf[[3]] summary(as.vector(delta_h3)) ## ----autoplot, fig.width = 7, fig.height = 4, fig.alt="In-sample posterior coefficient paths from autoplot.lame: each panel shows the posterior median line and a 95 percent credible interval ribbon across the fitted training periods (t1-t6) for one regression coefficient. This plots the estimated in-sample path, not a forecast."---- library(ggplot2) autoplot(fit, probs = c(0.025, 0.5, 0.975)) + labs(title = "Posterior coefficient paths", subtitle = "median + 95% interval", x = "Time period", y = "Coefficient value") ## ----sparse-fail-------------------------------------------------------------- set.seed(2026) n_sp <- 20 T_sp <- 4 beta_drift <- c(0.4, 0.9, 1.4, 1.9) # drifts up; never mean-reverts X_sp <- lapply(seq_len(T_sp), function(t) { x <- matrix(rnorm(n_sp * n_sp), n_sp, n_sp) array(x, dim = c(n_sp, n_sp, 1), dimnames = list(NULL, NULL, "sanction_risk")) }) Y_sp <- lapply(seq_len(T_sp), function(t) { eta <- -2 + beta_drift[t] * X_sp[[t]][, , 1] Yt <- matrix(rbinom(n_sp * n_sp, 1, pnorm(eta)), n_sp, n_sp) diag(Yt) <- NA rownames(Yt) <- colnames(Yt) <- sprintf("s%02d", seq_len(n_sp)) Yt }) names(Y_sp) <- paste0("t", seq_len(T_sp)) sapply(Y_sp, function(y) round(mean(y, na.rm = TRUE), 3)) # sparse early on fit_sparse <- lame( Y_sp, Xdyad = X_sp, family = "binary", R = 0, dynamic_beta = "dyad", dynamic_beta_kind = "ar1", nscan = 400, burn = 100, odens = 5, verbose = FALSE ) round(quantile(fit_sparse$RHO_BETA, c(0.5, 0.975)), 3) ## ----sparse-fail-warn--------------------------------------------------------- set.seed(1) fc_sparse <- predict(fit_sparse, h = 6, type = "response", interval = "credible") ## ----sparse-fail-width, fig.width = 6, fig.height = 3.2, fig.alt="Line plot of the across-dyad mean 95 percent forecast interval width at horizons one through six for two fits. The mean-reverting Step 1 fit's width is flat from roughly horizon four on, the saturation an AR(1) below the unit root predicts, while the near-unit-root sparse fit's width is still rising at horizon six, roughly double its one-step value."---- set.seed(1) fc_healthy <- predict(fit, h = 6, type = "response", interval = "credible") width_by_h <- function(fc) { sapply(fc, function(m) mean(m$upper - m$lower, na.rm = TRUE)) } w_healthy <- width_by_h(fc_healthy) w_sparse <- width_by_h(fc_sparse) round(rbind(mean_reverting = w_healthy, near_unit_root = w_sparse), 3) # at h = 3, how many of the dyads the covariate actually moves (top # decile of |sanction_risk|) get an interval spanning nearly all of # [0, 1]? x_last <- X_sp[[T_sp]][, , 1] hi_x <- abs(x_last) >= quantile(abs(x_last), 0.9) deg_h3 <- mean(fc_sparse[[3]]$lower[hi_x] < 0.05 & fc_sparse[[3]]$upper[hi_x] > 0.95, na.rm = TRUE) round(deg_h3, 2) width_df <- data.frame( h = rep(seq_along(w_healthy), 2), fit = rep(c("mean-reverting (Step 1 fit)", "near-unit-root (sparse fit)"), each = length(w_healthy)), width = c(w_healthy, w_sparse) ) ggplot(width_df, aes(h, width, linetype = fit)) + geom_line() + geom_point(size = 1.8) + labs(title = "Forecast interval width by horizon", x = "Forecast horizon h", y = "Mean 95% interval width") + theme_bw(base_size = 9) + theme(panel.border = element_blank(), axis.ticks = element_blank(), legend.position = "top", legend.title = element_blank()) ## ----lfo---------------------------------------------------------------------- # refit on Y[1:(t-1)] for each t in periods and score Y[[t]]. # Skip the very first leave-out (would leave a 1-period training window # which is too short for dynamic_beta); use the last 2 origins. set.seed(1) # the internal h=1 forecast draws inside lfo() are stochastic; seed for reproducibility T_fit <- length(fit$YPM) lfo_periods <- tail(seq_len(T_fit), max(1L, min(2L, T_fit - 2L))) lfo_res <- lfo(fit, periods = lfo_periods, refit = TRUE, nscan = 100, burn = 25, odens = 5, verbose = FALSE) print(lfo_res) ## ----pit---------------------------------------------------------------------- # fit on the first five periods, hold out the sixth fit_train <- lame( Y[1:5], Xdyad = Xdyad[1:5], family = "binary", R = 0, dynamic_beta = "dyad", nscan = 100, burn = 30, odens = 5, verbose = FALSE ) set.seed(1) # the randomised PIT draws are stochastic; seed for reproducibility pit <- forecast_pit(fit_train, y_future = list(Y[[6]])) print(pit) ## ----pit-plot, fig.height = 3.6, fig.alt="PIT calibration plot for the held-out period: the empirical distribution of probability-integral-transform values compared with the Uniform(0,1) reference. Bars or a curve tracking the reference indicate calibration; a U-shape signals an over-confident (too-narrow) forecast and a central hump signals an under-confident (too-wide) one."---- plot(pit) ## ----loo-compare, eval = FALSE------------------------------------------------ # # run this after fitting both candidates with converged chains # fit_static <- lame( # Y, Xdyad = Xdyad, family = "binary", R = 0, # nscan = 5000, burn = 1000, odens = 25, # save_log_lik = TRUE, # verbose = FALSE # ) # fit_dyn <- lame( # Y, Xdyad = Xdyad, family = "binary", R = 0, # dynamic_beta = "dyad", # nscan = 5000, burn = 1000, odens = 25, # save_log_lik = TRUE, # verbose = FALSE # ) # # # rank the two fits: the row with elpd_diff = 0 is the best, # # subsequent rows show elpd_diff and its SE relative to it. Pass a NAMED # # list so the rows are labelled by model name rather than "model1"/"model2". # cmp <- loo::loo_compare(list(fit_static = loo::loo(fit_static), # fit_dyn = loo::loo(fit_dyn))) # cmp ## ----loo-compare-read, eval = FALSE------------------------------------------- # best <- rownames(cmp)[1] # runnerup <- rownames(cmp)[2] # diff_val <- abs(round(cmp[2, "elpd_diff"], 1)) # se_val <- round(cmp[2, "se_diff"], 1) # ratio <- round(diff_val / se_val, 1) ## ----chunked, eval = FALSE---------------------------------------------------- # fit_chk <- lame( # Y, Xdyad = Xdyad, family = "binary", R = 0, # dynamic_beta = "dyad", # nscan = 2000, burn = 500, odens = 5, # save_log_lik = "chunked", # per-column-chunk binary files # log_lik_chunk_size = 5000L, # verbose = FALSE # ) # loo::loo(fit_chk) # reads the chunks transparently