## ----setup, include = FALSE--------------------------------------------------- knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.align = "center" ) ## ----load-data---------------------------------------------------------------- library(lame) library(ggplot2) set.seed(6886) # load the Add Health friendship network data(addhealthc3) # convert valued network to binary (any nomination = friendship) Y <- (addhealthc3$Y > 0) * 1 X_nodes <- addhealthc3$X n <- nrow(Y) cat("Students:", n, "\n") cat("Friendships:", sum(Y, na.rm = TRUE), "\n") cat("Network density:", round(mean(Y, na.rm = TRUE), 3), "\n") ## ----viz-network, fig.width=10, fig.height=4, fig.alt="Two faceted histograms showing the distribution of nominations sent and nominations received per student in the Add Health friendship network, illustrating heterogeneity in sociality and popularity."---- out_degree <- rowSums(Y, na.rm = TRUE) in_degree <- colSums(Y, na.rm = TRUE) degree_df <- data.frame( Degree = c(out_degree, in_degree), Type = rep(c("Nominations sent", "Nominations received"), each = n) ) ggplot(degree_df, aes(x = Degree)) + geom_histogram(binwidth = 1) + facet_wrap(~Type, ncol = 2) + labs(x = "Number of Ties", y = "Count") + theme_bw() + theme( panel.border = element_blank(), axis.ticks = element_blank(), legend.position = "top", strip.background = element_rect(fill = "black", color = "black"), strip.text = element_text(color = "white", hjust = 0) ) ## ----create-covariates-------------------------------------------------------- # `outer(x, x, FUN)` builds an n x n matrix whose (i,j) entry is FUN(x[i], x[j]). # So `outer(female, female, "==")` returns TRUE where students i and j share the # same gender. Multiplying by 1 converts TRUE/FALSE to 1/0. # # homophily indicators: 1 if same, 0 if different same_female <- outer(X_nodes[,"female"], X_nodes[,"female"], "==") * 1 same_race <- outer(X_nodes[,"race"], X_nodes[,"race"], "==") * 1 same_grade <- outer(X_nodes[,"grade"], X_nodes[,"grade"], "==") * 1 # absolute grade difference; same shape, but a continuous covariate grade_diff <- abs(outer(X_nodes[,"grade"], X_nodes[,"grade"], "-")) # pack into a 3D array (n x n x p) Xdyad <- array(NA, dim = c(n, n, 4)) Xdyad[,,1] <- same_female Xdyad[,,2] <- same_race Xdyad[,,3] <- same_grade Xdyad[,,4] <- grade_diff dimnames(Xdyad)[[3]] <- c('same_female', 'same_race', 'same_grade', 'grade_diff') for(k in 1:4) diag(Xdyad[,,k]) <- NA # nodal covariates (sender and receiver characteristics) Xrow <- X_nodes[, c("female", "grade")] Xcol <- X_nodes[, c("female", "grade")] ## ----fit-model, message=FALSE, warning=FALSE---------------------------------- fit <- ame(Y, Xdyad = Xdyad, Xrow = Xrow, Xcol = Xcol, R = 2, # 2D latent space family = "binary", # probit model for 0/1 data rvar = TRUE, # sender random effects cvar = TRUE, # receiver random effects dcor = TRUE, # dyadic correlation (reciprocity) burn = 100, # compact burn-in for this worked example nscan = 500, # compact post-burn-in run for the vignette odens = 25, # thinning verbose = FALSE, gof = TRUE) ## ----print-summary------------------------------------------------------------ summary(fit) ## ----trace-plots, fig.width=10, fig.height=8, fig.alt="MCMC trace and density plots for each regression coefficient; stable traces fluctuate around a steady mean and the density plots are smooth and unimodal."---- trace_plot(fit, params = "beta", ncol = 3) ## ----trace-rho, fig.width=8, fig.height=5, fig.alt="MCMC trace and density plot for the dyadic correlation (rho); the trace moves slowly relative to the regression coefficients, illustrating the slow mixing discussed in the text."---- trace_plot(fit, params = "variance", include = "Dyadic Correlation") ## ----as-draws-rhat-ess, eval = requireNamespace("posterior", quietly = TRUE), message = FALSE, warning = FALSE---- library(posterior) draws <- posterior::as_draws(fit) # draws_array [iter, chain, var] posterior::summarise_draws(draws) # mean, sd, q5, q95, rhat, # ess_bulk, ess_tail per param ## ----ame-parallel-rhat, eval = FALSE------------------------------------------ # # run this with four chains and longer runs for a convergence assessment # fit_mc <- ame_parallel( # Y, Xdyad = Xdyad, Xrow = Xrow, Xcol = Xcol, # family = "binary", R = 2, # burn = 1000, nscan = 5000, odens = 25, # n_chains = 4, cores = 4, # use available cores when running locally # combine_method = "pool", # pooled fit retains chain identity # verbose = FALSE # ) # posterior::summarise_draws(posterior::as_draws(fit_mc)) ## ----mh-counters-------------------------------------------------------------- str(fit$mh_counters) ## ----prior-summary-cross------------------------------------------------------ prior_summary(fit) ## ----loo-cross, eval = FALSE-------------------------------------------------- # # run this after fitting both candidates with converged chains # fit_ll <- ame(Y, Xdyad = Xdyad, Xrow = Xrow, Xcol = Xcol, # family = "binary", R = 2, # burn = 1000, nscan = 5000, odens = 25, # save_log_lik = TRUE, verbose = FALSE) # # # alternative model: drop the dyadic covariate # fit_ll_null <- ame(Y, Xrow = Xrow, Xcol = Xcol, # family = "binary", R = 2, # burn = 1000, nscan = 5000, odens = 25, # save_log_lik = TRUE, verbose = FALSE) # # loo_dyad <- loo::loo(fit_ll) # loo_null <- loo::loo(fit_ll_null) # # loo_dyad # read the Pareto-k table before any elpd number # # loo::loo_compare(list(with_dyad = loo_dyad, no_dyad = loo_null)) ## ----gof-plot, fig.width=10, fig.height=6, fig.alt="Goodness-of-fit panels comparing observed network statistics (dashed orange vertical line) against the posterior predictive histograms (grey) for sender and receiver degree heterogeneity, dyadic dependence, triadic dependence, and transitivity. Observed and predicted differ on both colour and linetype so the cue survives grayscale printing and colour-blind viewing."---- gof_plot(fit) ## ----custom-gof--------------------------------------------------------------- # classic clustering coefficient: transitive triples / two-paths trans_ratio <- function(Y) { Yb <- Y; Yb[is.na(Yb)] <- 0 # NA -> 0 for matrix multiply YY <- Yb %*% Yb triangles <- sum(YY * Yb) # transitive triples two_path <- sum(YY) - sum(diag(YY)) # two-paths (potential triangles) c(trans_ratio = triangles / max(two_path, 1)) } gof_custom <- gof(fit, custom_gof = trans_ratio, nsim = 50, verbose = FALSE) obs <- gof_custom[1, "trans_ratio"] sim <- gof_custom[-1, "trans_ratio"] # posterior-predictive p-value (right tail): near 0 = under-predicted c(observed = round(obs, 3), sim_mean = round(mean(sim), 3), pp_p_right = round(mean(sim >= obs), 3)) ## ----latent-space, fig.width=8, fig.height=6, fig.alt="Biplot of the estimated 2D latent space: triangles mark each student's sender position and circles mark receiver positions on the two latent dimensions; students plotted near each other have similar friendship patterns beyond what the covariates explain."---- uv_plot(fit, layout = "biplot", show.edges = FALSE, label.nodes = FALSE) ## ----individual-effects, fig.width=10, fig.height=8, fig.alt="Two stacked lollipop charts showing the posterior-mean sender effect (sociality) and receiver effect (popularity) for each student as a point connected by a stem to a dashed zero line, sorted from the most negative effect on the left to the most positive on the right; positive values sit above the line and negative below."---- p1 <- ab_plot(fit, effect = "sender", sorted = TRUE, title = "Sender Effects (Sociality)") p2 <- ab_plot(fit, effect = "receiver", sorted = TRUE, title = "Receiver Effects (Popularity)") library(patchwork) p1 / p2 ## ----predictions-------------------------------------------------------------- pred_resp <- predict(fit, type = "response") # how well does the model classify? Y_vec <- as.vector(Y) pred_vec <- as.vector(pred_resp) keep <- !is.na(Y_vec) # simple classification at threshold 0.5 pred_binary <- (pred_vec[keep] > 0.5) * 1 confusion <- table(Actual = Y_vec[keep], Predicted = pred_binary) knitr::kable(confusion, caption = "Confusion Matrix (threshold = 0.5)") accuracy <- sum(diag(confusion)) / sum(confusion) baseline <- max(mean(Y_vec[keep]), 1 - mean(Y_vec[keep])) # always-predict-modal-class cat("\nAccuracy:", round(accuracy, 3), " | Baseline (predict modal class):", round(baseline, 3), " | Lift:", round(accuracy - baseline, 3), "\n") ## ----auc-eval, eval = requireNamespace("pROC", quietly = TRUE) && requireNamespace("precrec", quietly = TRUE)---- # pROC: ROC curve and AUROC # install.packages("pROC") roc_obj <- pROC::roc(response = Y_vec[keep], predictor = pred_vec[keep], quiet = TRUE) auroc <- as.numeric(pROC::auc(roc_obj)) # precrec: both ROC and Precision-Recall AUCs (the latter is more # informative when the positive class is rare, as in friendship networks) # install.packages("precrec") ev <- precrec::evalmod(scores = pred_vec[keep], labels = Y_vec[keep]) precrec::auc(ev) # returns AUROC and PR-AUC side-by-side cat("AUROC:", round(auroc, 3), "\n", "Baseline AUROC (random ranker):", 0.5, "\n", "Baseline PR-AUC (density of Y):", round(mean(Y_vec[keep]), 3), "\n") ## ----heldout------------------------------------------------------------------ # 80/20 stratified mask of off-diagonal dyads set.seed(6886) off_diag <- which(row(Y) != col(Y) & !is.na(Y)) pos_idx <- off_diag[Y[off_diag] == 1] neg_idx <- off_diag[Y[off_diag] == 0] test_idx <- c(sample(pos_idx, round(0.2 * length(pos_idx))), sample(neg_idx, round(0.2 * length(neg_idx)))) Y_train <- Y Y_train[test_idx] <- NA # mask test dyads from the likelihood fit_train <- ame(Y_train, Xdyad = Xdyad, Xrow = Xrow, Xcol = Xcol, R = 2, family = "binary", rvar = TRUE, cvar = TRUE, dcor = TRUE, burn = 30, nscan = 100, odens = 5, # compact vignette run verbose = FALSE, gof = FALSE) pred_train <- predict(fit_train, type = "response") # `evaluate_heldout()` scores predictions on a logical TEST mask: # columns n_eval, auroc, auprc, brier, logloss test_mask <- matrix(FALSE, nrow(Y), ncol(Y), dimnames = dimnames(Y)) test_mask[test_idx] <- TRUE evaluate_heldout(y_obs = Y, y_pred = pred_train, mask = test_mask, family = "binary") cat("Test dyads:", length(test_idx), " | positives:", sum(Y[test_idx] == 1), "\n") ## ----simulation, fig.width=8, fig.height=4, fig.alt="Posterior predictive check histogram of simulated network densities (grey bars) with a dashed Okabe-Ito orange vertical line marking the observed density; the encoding is dual on colour and linetype so the cue survives greyscale printing and colour-blind viewing. Overlap indicates the model reproduces the observed density."---- sims <- simulate(fit, nsim = 100) # compare simulated vs observed density sim_densities <- sapply(sims$Y, function(y) mean(y, na.rm = TRUE)) obs_density <- mean(Y, na.rm = TRUE) ggplot(data.frame(density = sim_densities), aes(x = density)) + geom_histogram(bins = 20, fill = "grey60") + # dual-encode the observed density as colour AND linetype so the cue # survives greyscale printing and colour-blind viewing (Okabe-Ito orange). geom_vline(xintercept = obs_density, color = "#D55E00", linewidth = 1, linetype = "dashed") + labs(title = "Posterior Predictive Check: Network Density", subtitle = "Dashed orange line = observed; grey histogram = simulated from model", x = "Network Density (Mean Tie Probability)", y = "Replicate Count") + theme_bw() + theme(panel.border = element_blank(), axis.ticks = element_blank(), legend.position = "top")