## ----include = FALSE---------------------------------------------------------- knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 5, dpi = 96, out.width = "100%", fig.align = "center" ) ## ----setup-------------------------------------------------------------------- library(lame) library(ggplot2) set.seed(6886) ## ----tibble_bridge, eval = FALSE---------------------------------------------- # rx_net <- netify::netify( # rx, # actor1 = "patient_id", actor2 = "drug", # weight = "prescribed", # mode = "bipartite", # symmetric = FALSE, # missing_to_zero = FALSE # ) # # fit <- ame(rx_net, family = "binary", mode = "bipartite", R = 2) ## ----simulate_cross_sectional------------------------------------------------- # simulate a student-course enrollment network n_students <- 30 n_courses <- 20 # true latent positions (unobserved in practice) U_true <- matrix(rnorm(n_students * 2, 0, 0.8), n_students, 2) V_true <- matrix(rnorm(n_courses * 2, 0, 0.8), n_courses, 2) # interaction matrix: dimension 1 has positive affinity, # dimension 2 has negative (students high on dim 2 avoid courses high on dim 2) G_true <- matrix(c(1, 0.5, 0.5, -1), 2, 2) # generate enrollment probabilities and binary outcomes # negative intercept keeps enrollment rate realistic (~25-30%) eta <- -0.8 + U_true %*% G_true %*% t(V_true) prob <- pnorm(eta) Y_bipartite <- matrix(rbinom(n_students * n_courses, 1, prob), n_students, n_courses) rownames(Y_bipartite) <- paste0("Student", 1:n_students) colnames(Y_bipartite) <- paste0("Course", 1:n_courses) cat("Network dimensions:", dim(Y_bipartite), "\n") cat("Enrollment rate:", round(mean(Y_bipartite), 2), "\n") ## ----fit_cross_sectional, message=FALSE, warning=FALSE------------------------ # burn and nscan are small so the vignette builds quickly # for real analyses, use burn >= 1000 and nscan >= 5000. fit_cross <- ame( Y = Y_bipartite, mode = "bipartite", R_row = 2, # latent dimensions for students R_col = 2, # latent dimensions for courses family = "binary", burn = 100, nscan = 500, odens = 5, verbose = FALSE, # save thinned U/V draws so latent_positions() can report posterior SDs posterior_opts = posterior_options(save_UV = TRUE) ) summary(fit_cross) ## ----visualize_cross_sectional, fig.height=5, fig.alt="Bipartite latent-space biplot placing students and courses in the same 2D coordinate system, with proximity indicating a higher predicted enrollment probability."---- uv_plot(fit_cross, layout = "biplot") + ggtitle("Bipartite Latent Space: Students and Courses") ## ----interaction_heatmap, fig.height=6, fig.alt="Heatmap of the fitted multiplicative structure U G V' across student-course pairs using a diverging colour-blind-safe ColorBrewer RdBu palette with the scale capped at the 99th percentile of absolute fitted values: red tiles mark positive latent affinity (predicted enrollment above baseline), blue tiles mark negative latent affinity (predicted below baseline), and near-white tiles mark a posterior estimate near zero."---- G_mult <- fit_cross$U %*% fit_cross$G %*% t(fit_cross$V) G_df <- data.frame( Student = rownames(G_mult)[row(G_mult)], Course = colnames(G_mult)[col(G_mult)], Fitted = as.vector(G_mult) ) # order axes numerically (Student1, Student2, ...) instead of the default # alphabetical factor order (Student1, Student10, Student11, ...) G_df$Student <- factor(G_df$Student, levels = paste0("Student", n_students:1)) G_df$Course <- factor(G_df$Course, levels = paste0("Course", 1:n_courses)) # rdbu reversed (direction = -1): positive => red, negative => blue, # zero => white. By default scale_fill_distiller() centres white at the # midpoint of the DATA range, not at zero -- so we pass symmetric limits # c(-mx, mx) to force the white midpoint onto zero, which is the right # choice for a signed effect. We cap the fill at the 99th percentile of # |UGV'| (clamping the few more extreme cells to the cap) so a single # outlying cell cannot wash the rest of the map to near-white. RdBu is # colour-blind-safe per ColorBrewer's protanopia / deuteranopia checks. mx <- quantile(abs(G_df$Fitted), 0.99, na.rm = TRUE) G_df$Fitted_capped <- pmax(pmin(G_df$Fitted, mx), -mx) ggplot(G_df, aes(x = Course, y = Student, fill = Fitted_capped)) + geom_tile() + scale_fill_distiller(palette = "RdBu", direction = -1, limits = c(-mx, mx)) + labs(title = "Fitted Multiplicative Structure (UGV')", subtitle = "Red = positive, blue = negative; colour scale capped at the 99th percentile", x = "Course", y = "Student", fill = "U G V'") + theme_bw() + theme(panel.border = element_blank(), axis.ticks = element_blank(), legend.position = "top", axis.text.x = element_text(angle = 45, hjust = 1), axis.text.y = element_text(size = 6)) ## ----simulate_longitudinal---------------------------------------------------- n_periods <- 5 n_users <- 20 n_items <- 15 true_rho <- 0.9 G_long <- matrix(c(1, 0.3, 0.3, -0.8), 2, 2) U_t <- vector("list", n_periods) V_t <- vector("list", n_periods) U_t[[1]] <- matrix(rnorm(n_users * 2), n_users, 2) V_t[[1]] <- matrix(rnorm(n_items * 2), n_items, 2) for(t in 2:n_periods) { U_t[[t]] <- true_rho * U_t[[t-1]] + sqrt(1 - true_rho^2) * matrix(rnorm(n_users * 2), n_users, 2) V_t[[t]] <- true_rho * V_t[[t-1]] + sqrt(1 - true_rho^2) * matrix(rnorm(n_items * 2), n_items, 2) } Y_list <- list() for(t in 1:n_periods) { eta_t <- U_t[[t]] %*% G_long %*% t(V_t[[t]]) # continuous-valued interactions (normal family): a numerically stable # outcome for demonstrating the dynamic bipartite sampler end to end. Y_list[[t]] <- eta_t + matrix(rnorm(n_users * n_items, 0, 0.5), n_users, n_items) rownames(Y_list[[t]]) <- paste0("User", 1:n_users) colnames(Y_list[[t]]) <- paste0("Item", 1:n_items) } names(Y_list) <- paste0("T", 1:n_periods) cat("Time periods:", length(Y_list), "\n") cat("Dimensions per period:", dim(Y_list[[1]]), "\n") cat("Average edge weight:", round(mean(sapply(Y_list, mean)), 2), "\n") cat("True latent AR(1) coefficient:", true_rho, "\n") ## ----fit_longitudinal_static, message=FALSE----------------------------------- # iterations are small so the vignette builds quickly; use burn >= 1000 # and nscan >= 5000 for real analyses. fit_static <- lame( Y = Y_list, mode = "bipartite", R_row = 2, R_col = 2, family = "normal", dynamic_uv = FALSE, dynamic_ab = FALSE, burn = 100, nscan = 500, odens = 5, verbose = FALSE, plot = FALSE ) summary(fit_static) ## ----fit_longitudinal_dynamic, message=FALSE---------------------------------- # same reduced iterations as above for vignette speed. fit_dynamic <- lame( Y = Y_list, mode = "bipartite", R_row = 2, R_col = 2, family = "normal", dynamic_uv = TRUE, dynamic_ab = TRUE, burn = 100, nscan = 500, odens = 5, verbose = FALSE, plot = FALSE ) summary(fit_dynamic) ## ----dynamic_rho_recovery----------------------------------------------------- cat(sprintf("True rho_uv = %.2f | posterior mean = %.3f | 95%% CI = [%.3f, %.3f]\n", true_rho, mean(fit_dynamic$rho_uv), quantile(fit_dynamic$rho_uv, 0.025), quantile(fit_dynamic$rho_uv, 0.975))) ## ----fit_dynamic_G, message=FALSE--------------------------------------------- fit_dynG <- lame( Y = Y_list, mode = "bipartite", R_row = 2, R_col = 2, family = "normal", dynamic_uv = TRUE, dynamic_ab = TRUE, dynamic_G = TRUE, burn = 100, nscan = 500, odens = 5, verbose = FALSE, plot = FALSE ) # fit$G_cube_post_mean has dimensions R_row x R_col x n_periods if (!is.null(fit_dynG$G_cube_post_mean)) { cat("G_cube_post_mean dimensions:", dim(fit_dynG$G_cube_post_mean), "\n") cat("Frobenius norm of posterior-mean G_t per period:\n") print(round(apply(fit_dynG$G_cube_post_mean, 3, function(g) sqrt(sum(g^2))), 3)) cat("Rotation-drift ratio (>= 5 flags latent-rotation domination):", round(fit_dynG$G_rotation_drift$ratio, 2), "\n") } else { cat("G_cube was not attached (no bipartite + RA>0 + RB>0 case).\n") } ## ----visualize_longitudinal, fig.height=5, fig.alt="Trajectory plot tracing each actor's latent position from the first to the last period; paths drift gradually and stay clustered rather than scattering."---- uv_plot(fit_dynamic, plot_type = "trajectory", label.nodes = FALSE) + guides(color = "none") + ggtitle("Dynamic latent trajectories") ## ----visualize_longitudinal_highlight, fig.height=5, fig.alt="Trajectory plot with three highlighted actors (User1, User5, Item3) coloured and named in the legend while all other trajectories are greyed out for context."---- uv_plot(fit_dynamic, plot_type = "trajectory", highlight = c("User1", "User5", "Item3"), label.nodes = FALSE) + ggtitle("Highlighted actor trajectories") ## ----trace_dynamic, fig.height=6, fig.alt="MCMC trace plots and posterior densities for the sampled dynamic bipartite model parameters (intercept and variance components); well-mixed caterpillar traces indicate adequate sampling."---- trace_plot(fit_dynamic, exclude = c("Dyadic Correlation", "Sender-Receiver Covariance")) ## ----model_comparison, fig.height=4, fig.alt="Side-by-side goodness-of-fit panels comparing posterior predictive coverage of bipartite sender and receiver degree heterogeneity for the static versus dynamic model."---- # gof plots for each model; four-cycles is omitted because it is # constant on this dense continuous network (see text above) gof_plot(fit_static, statistics = c("sd.row", "sd.col")) gof_plot(fit_dynamic, statistics = c("sd.row", "sd.col")) ## ----model_comparison_boxplot, fig.height=4, fig.alt="Boxplots of posterior predictive bipartite statistics (row-mean SD and column-mean SD) faceted by static and dynamic models, with horizontal reference lines at observed values."---- gof_static <- fit_static$GOF gof_dynamic <- fit_dynamic$GOF # extract posterior predictive samples (exclude column 1, which is observed) # each element is a matrix [n_time x n_mcmc], so colMeans averages across time extract_ppc <- function(gof, stat) { mat <- gof[[stat]] colMeans(mat[, -1, drop = FALSE]) } # observed values (column 1, averaged across time periods) obs_vals <- c( mean(gof_static$sd.rowmean[, 1]), mean(gof_static$sd.colmean[, 1]) ) gof_df <- data.frame( value = c( extract_ppc(gof_static, "sd.rowmean"), extract_ppc(gof_dynamic, "sd.rowmean"), extract_ppc(gof_static, "sd.colmean"), extract_ppc(gof_dynamic, "sd.colmean") ), model = rep(rep(c("Static", "Dynamic"), each = ncol(gof_static$sd.rowmean) - 1), 2), statistic = rep(c("Row Mean SD", "Column Mean SD"), each = 2 * (ncol(gof_static$sd.rowmean) - 1)) ) obs_df <- data.frame( statistic = c("Row Mean SD", "Column Mean SD"), observed = obs_vals ) ggplot(gof_df, aes(x = model, y = value)) + geom_boxplot() + geom_hline(data = obs_df, aes(yintercept = observed), linetype = 2) + facet_wrap(~statistic, scales = "free_y") + labs(title = "GOF Comparison: Static vs Dynamic", subtitle = "Dashed Line = Observed Value", x = "Model", y = "Simulated Statistic") + 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) ) ## ----dimension_selection, eval=FALSE------------------------------------------ # # include R=0 as a no-latent-space baseline # dims_to_test <- list( # c(0, 0), # c(1, 1), # c(2, 2) # ) # # # compute observed GOF statistics from the data # obs_gof <- gof_stats(Y_bipartite, mode = "bipartite") # # gof_results <- list() # for(i in seq_along(dims_to_test)) { # fit_temp <- ame( # Y = Y_bipartite, # mode = "bipartite", # R_row = dims_to_test[[i]][1], # R_col = dims_to_test[[i]][2], # family = "binary", # burn = 200, # nscan = 2500, # odens = 5, # verbose = FALSE # ) # # posterior predictive tail probability: proportion of simulated # # statistics at or above the observed value (one-sided) # gof_results[[i]] <- c( # R_row = dims_to_test[[i]][1], # R_col = dims_to_test[[i]][2], # pval_rowmean = mean(fit_temp$GOF[, "sd.rowmean"] >= obs_gof["sd.rowmean"]), # pval_colmean = mean(fit_temp$GOF[, "sd.colmean"] >= obs_gof["sd.colmean"]), # pval_fourcycles = mean(fit_temp$GOF[, "four.cycles"] >= obs_gof["four.cycles"]) # ) # } # # do.call(rbind, gof_results) ## ----------------------------------------------------------------------------- lp <- latent_positions(fit_cross) head(lp) # filter to just the row nodes (students) lp_students <- lp[lp$type == "U", ] cat("Student positions:", nrow(lp_students), "rows\n")