In Homework 1 we studied the phone-calls layer of the Copenhagen Networks Study (Sapiezynski et al., 2019). We argued that phone calls are a strong-tie signal because you do not call just anyone. The calls graph had 504 nodes and 2,654 edges, which is too sparse for the link-prediction exercise in Homework 2 (the spec asks for more than 300 nodes and more than 5,000 edges).
For Homework 2 we therefore move to a different layer of the same study: the Bluetooth proximity scans. Bluetooth captures physical co-presence (two phones detecting each other within radio range), and it is widely used in computational social science as a proxy for face-to-face contact (Sekara & Lehmann, 2014). The dataset records about 5.5 million scan events between 692 students over four weeks.
After the aggregation rules described in Section 1 we obtain an undirected network with 662 nodes and 9,251 edges. The network is sparse but link prediction is still not trivial on it, and it forms one connected component.
Our pipeline follows the link-prediction protocol from Lecture 4 and Workshop 3: delete a fraction of real edges, sample a matched non-edge set as the negative class, score every candidate pair with structural similarity heuristics, and ask a binary classifier to recover the deleted edges. Three things go beyond Workshop 3: (i) seven heuristics instead of three, drawing on Lecture 4 slides 19-24 and 32-33; (ii) 10-fold cross-validation, which the homework brief asks for; (iii) GLM coefficients compared with Random Forest permutation importance to identify which heuristic does the most predictive work.
library(tidyverse)
library(igraph)
library(ggraph)
library(scales)
library(caret)
library(pROC)
library(randomForest)
The raw file bt_symmetric.csv (98 MB, 5,474,289 rows)
records every Bluetooth scan event. Each row has columns
timestamp, user_a, user_b,
rssi, with two sentinel encodings documented by the
authors: user_b == -1 is an empty scan (no device detected)
and user_b == -2 is a non-experiment device (devices
outside the cohort are all assigned the same id). After dropping the
sentinels we have 2,426,279 real participant-pair
scans.
To turn the scan stream into an undirected network we apply two filters, both standard in the CNS-derived literature:
Pre-built artifacts from this aggregation live in
bt_network.rds (igraph object) and
bt_edges.csv (edgelist with weight = count of
mutual scans plus mean RSSI and time of first/last contact).
g <- readRDS("bt_network.rds")
g
IGRAPH 263f699 UNW- 662 9251 --
+ attr: name (v/c), weight (e/n), mean_rssi (e/n), t_first (e/n),
| t_last (e/n)
+ edges from 263f699 (vertex names):
[1] 20 --21 48 --49 90 --91 57 --101 144--181 12 --244 176--263 221--263
[9] 119--283 104--288 318--375 62 --401 414--421 12 --454 260--459 263--472
[17] 461--492 311--524 343--553 16 --572 146--611 279--617 96 --634 207--677
[25] 19 --47 244--454 249--530 568--599 519--663 188--673 182--183 170--390
[33] 455--621 222--471 445--624 235--370 83 --105 263--370 455--282 215--664
[41] 235--472 221--472 267--452 22 --220 297--374 95 --190 378--505 215--673
[49] 282--621 285--523 208--422 64 --525 419--557 190--665 90 --665 263--69
+ ... omitted several edges
deg <- degree(g)
trans <- transitivity(g, type = "global")
comp <- components(g)
tibble(
Metric = c("Nodes (|V|)", "Edges (|E|)", "Density",
"Mean degree", "Median degree", "Max degree",
"Nodes with degree > 10",
"Connected components", "Largest component",
"Global transitivity (clustering)"),
Value = c(comma(vcount(g)), comma(ecount(g)),
sprintf("%.4f", edge_density(g)),
sprintf("%.2f", mean(deg)),
as.character(median(deg)), as.character(max(deg)),
as.character(sum(deg > 10)),
as.character(comp$no),
sprintf("%s (%.1f%%)", comma(max(comp$csize)),
100*max(comp$csize)/vcount(g)),
sprintf("%.4f", trans))
) %>%
kable(caption = "Table 1. Basic properties of the Bluetooth proximity network.",
align = c("l", "r"))
| Metric | Value |
|---|---|
| Nodes (|V|) | 662 |
| Edges (|E|) | 9,251 |
| Density | 0.0423 |
| Mean degree | 27.95 |
| Median degree | 26 |
| Max degree | 98 |
| Nodes with degree > 10 | 553 |
| Connected components | 1 |
| Largest component | 662 (100.0%) |
| Global transitivity (clustering) | 0.3474 |
The graph is a single connected component covering 100% of the nodes, density is 0.0423 (about 4 in 100 possible pairs are linked), and the mean degree is 28. The number worth flagging is global transitivity 0.347: roughly one in three two-hop paths closes into a triangle. For comparison, the calls network in HW1 had transitivity 0.24 and the Facebook layer in the same study has 0.24. Physical co-presence is the most clustered of the three CNS layers, which makes sense since students sit in the same lecture halls, share dorms, and study together. Triadic closure is the main way ties form in this network, so we should expect the heuristics that count common neighbours (Jaccard, Adamic-Adar, Resource Allocation) to do well.
set.seed(101)
comm_full <- cluster_louvain(g)
mem <- membership(comm_full)
sz <- sizes(comm_full)
top10 <- as.integer(names(sort(sz, decreasing = TRUE))[1:10])
mem_lab <- ifelse(mem %in% top10,
paste0("C", match(mem, top10)),
"other")
V(g)$comm_lab <- factor(mem_lab,
levels = c(paste0("C", 1:10), "other"))
palette10 <- c(
"#1b9e77","#d95f02","#7570b3","#e7298a","#66a61e",
"#e6ab02","#a6761d","#666666","#1f78b4","#b2df8a","grey80")
ggraph(g, layout = "fr") +
geom_edge_link(aes(alpha = weight), color = "grey60", show.legend = FALSE) +
geom_node_point(aes(size = degree(g), fill = comm_lab),
shape = 21, stroke = 0.15, color = "grey20") +
scale_edge_alpha(range = c(0.04, 0.55), trans = "log10") +
scale_size_area(max_size = 5, guide = guide_legend(order = 1)) +
scale_fill_manual(values = palette10, name = "Louvain community",
guide = guide_legend(order = 2,
override.aes = list(size = 4))) +
theme_void(base_size = 11) +
labs(size = "Degree",
title = sprintf("Copenhagen BT proximity (n = %d, m = %s, Q = %.3f)",
vcount(g), comma(ecount(g)), modularity(comm_full)))
Figure 1. Bluetooth proximity network (Fruchterman-Reingold layout). Nodes are colored by Louvain community (only the 10 largest are highlighted; smaller communities are shown in grey). Node area is proportional to degree, edge transparency to the log count of mutual scans.
p1 <- tibble(k = deg) %>%
ggplot(aes(k)) +
geom_histogram(bins = 30, fill = "steelblue", color = "white") +
labs(x = "Degree", y = "Number of nodes", subtitle = "Linear scale") +
theme_minimal(base_size = 11)
# CCDF: P(K >= k). Smooth and well-behaved on log-log
ccdf <- tibble(k = sort(unique(deg[deg > 0]))) %>%
mutate(p = sapply(k, function(x) mean(deg >= x)))
p2 <- ccdf %>%
ggplot(aes(k, p)) +
geom_step(color = "steelblue", linewidth = 0.8) +
geom_point(color = "steelblue", size = 1.2) +
scale_x_log10() + scale_y_log10(labels = scales::label_number()) +
labs(x = "Degree k (log)", y = "P(K ≥ k) (log)",
subtitle = "Complementary CDF (log-log)") +
theme_minimal(base_size = 11)
gridExtra::grid.arrange(p1, p2, ncol = 2)
Figure 2. Degree distribution. Left: linear histogram. Right: complementary cumulative distribution function (1 - CDF) on log-log axes, which avoids the empty-bin artefacts that affect log-binned histograms.
We follow the Liben-Nowell & Kleinberg (2003) protocol: delete a
fraction of real edges from \(G\) to
get \(G'\), treat the deleted
endpoints as the positive class (label = 1), and sample a matched
negative class (label = 0) of non-edges that the classifier should
reject. We delete nlinks = 5000 edges, slightly more than
half the graph, which leaves the heuristics enough deleted ties to
actually recover.
The standard caveat from Workshop 3 applies to negative sampling. A uniformly random pair of nodes is almost always disconnected in a sparse network, so negatives drawn that way are trivial to classify and the model just learns that “isolated pairs are not friends”, which is not what we want. We use Workshop 3’s solution and sample negatives only between well-connected nodes (degree > 10 in \(G\)), which forces the classifier to draw a real distinction. With 553 well-connected nodes (out of 662) there are plenty of candidates.
set.seed(42)
nlinks <- 5000
indexes_deleted <- sample(seq_len(ecount(g)), nlinks)
deleted_endpoints <- ends(g, E(g)[indexes_deleted])
true_edges <- data.frame(
X1 = deleted_endpoints[, 1],
X2 = deleted_endpoints[, 2],
stringsAsFactors = FALSE
)
Gprime <- delete_edges(g, indexes_deleted)
most_connected <- V(g)[degree(g) > 10]
mc_names <- as_ids(most_connected)
false_edges_list <- vector("list", nlinks)
filled <- 0L; attempts <- 0L
while (filled < nlinks) {
attempts <- attempts + 1L
pair <- sample(mc_names, 2L, replace = FALSE)
if (!are_adjacent(g, pair[1], pair[2])) {
filled <- filled + 1L
false_edges_list[[filled]] <- pair
}
}
false_edges <- as.data.frame(do.call(rbind, false_edges_list),
stringsAsFactors = FALSE)
colnames(false_edges) <- c("X1", "X2")
true_edges <- data.frame(true_edges, obs = 1L)
false_edges <- data.frame(false_edges, obs = 0L)
total_edges <- rbind(true_edges, false_edges)
colnames(total_edges)[1:2] <- c("id1", "id2")
| Quantity | Value |
|---|---|
| Edges in G | 9,251 |
| Edges in G’ after deletion | 4,251 |
| Edges deleted (positive class) | 5,000 |
| Well-connected nodes (deg > 10) | 553 |
| Hard non-edges sampled (negative class) | 5,000 |
| Rejection-sampling attempts | 5,285 |
| Rejection rate | 5.39% |
| Total candidate pairs | 10,000 |
We end up with a balanced dataset of 10,000 candidate pairs: 5,000 deleted-real edges and 5,000 hard non-edges.
We end up with a balanced dataset of 10,000 candidate pairs, half deleted-real and half hard non-edges.
We compute seven structural similarity heuristics on \(G'\), the observed graph (not on \(G\), since computing on \(G\) would leak the deleted edges back into the features through the degrees and neighbourhoods of their endpoints).
| # | Heuristic | Formula | Lecture 4 slide |
|---|---|---|---|
| 1 | Common neighbors (CN) | \(\lvert\Gamma(x)\cap\Gamma(y)\rvert\) | 20 |
| 2 | Jaccard coefficient | \(\frac{\lvert\Gamma(x)\cap\Gamma(y)\rvert}{\lvert\Gamma(x)\cup\Gamma(y)\rvert}\) | 21 |
| 3 | Adamic-Adar (AA) | \(\sum_{z\in\Gamma(x)\cap\Gamma(y)}\frac{1}{\log k_z}\) | 22 |
| 4 | Preferential Attachment (PA) | \(k_x\cdot k_y\) | 23 |
| 5 | Graph distance (inverse) | \(1/d_{xy}\) | 19 |
| 6 | Resource Allocation (RA) | \(\sum_{z\in\Gamma(x)\cap\Gamma(y)}\frac{1}{k_z}\) | 33 |
| 7 | Same community (Louvain) | \(\mathbf{1}[c(x)=c(y)]\) | 32 |
The first four (CN, Jaccard, AA, PA) are the ones Lecture 4 uses in its “Link prediction algorithm” example (slide 28) and the ones we implemented in Workshop 3. The other three (graph distance, RA, same-community) come from Lecture 4 slides 19, 32, and 33; the lecture covers them but Workshop 3 did not. We add them so we can check whether the community structure itself adds predictive value beyond what the local neighbourhood heuristics already capture.
# Louvain on G' (not G) so we don't leak the deleted edges.
# Louvain is stochastic; setting the seed makes it reproducible.
set.seed(321)
comm <- cluster_louvain(Gprime)
V(Gprime)$comm <- membership(comm)
| Quantity | Value |
|---|---|
| Louvain communities on G’ | 33 |
| Modularity Q | 0.7564 |
# Pre-compute neighborhoods on Gprime once, distances on demand.
n1 <- neighborhood(Gprime, order = 1, nodes = total_edges$id1)
n2 <- neighborhood(Gprime, order = 1, nodes = total_edges$id2)
deg_Gp <- degree(Gprime)
comm_v <- V(Gprime)$comm
names(comm_v) <- as_ids(V(Gprime))
# Distance matrix from the rows we need is too expensive to precompute
# fully (~440k entries), but distances() with from/to is fast.
total_edges$sim_cn <- 0L
total_edges$sim_jacc <- 0
total_edges$sim_aa <- 0
total_edges$sim_pref <- 0L
total_edges$sim_dist <- 0
total_edges$sim_ra <- 0
total_edges$sim_comm <- 0L
t0 <- Sys.time()
for (i in seq_len(nrow(total_edges))) {
v1 <- total_edges$id1[i]; v2 <- total_edges$id2[i]
nei_a <- setdiff(as_ids(n1[[i]]), v1)
nei_b <- setdiff(as_ids(n2[[i]]), v2)
inter <- intersect(nei_a, nei_b)
uni <- union(nei_a, nei_b)
total_edges$sim_cn[i] <- length(inter)
total_edges$sim_jacc[i] <- if (length(uni) == 0) 0 else length(inter)/length(uni)
if (length(inter) == 0) {
total_edges$sim_aa[i] <- 0
total_edges$sim_ra[i] <- 0
} else {
k_z <- deg_Gp[inter]
aa_kz <- k_z[k_z > 1] # 1/log(1) is undefined; skip
total_edges$sim_aa[i] <- if (length(aa_kz) == 0) 0 else sum(1/log(aa_kz))
ra_kz <- k_z[k_z > 0]
total_edges$sim_ra[i] <- if (length(ra_kz) == 0) 0 else sum(1/ra_kz)
}
total_edges$sim_pref[i] <- length(nei_a) * length(nei_b)
# Topological (unweighted) hop count, per Lecture 4 slide 19.
# weights = NA forces igraph to use BFS instead of Dijkstra on the
# weighted graph. The "weight" edge attribute is co-presence count, not
# a cost we want to walk along.
d <- distances(Gprime, v = v1, to = v2, weights = NA)[1, 1]
total_edges$sim_dist[i] <- if (is.infinite(d) | d == 0) 0 else 1/d
total_edges$sim_comm[i] <- as.integer(comm_v[v1] == comm_v[v2])
}
elapsed <- as.numeric(difftime(Sys.time(), t0, units = "secs"))
total_edges %>%
group_by(obs) %>%
summarise(
`Common neighbors` = mean(sim_cn),
Jaccard = mean(sim_jacc),
`Adamic-Adar` = mean(sim_aa),
`Pref. Attachment` = mean(sim_pref),
`Graph distance (1/d)` = mean(sim_dist),
`Resource Allocation` = mean(sim_ra),
`Same community` = mean(sim_comm),
.groups = "drop"
) %>%
mutate(Class = ifelse(obs == 1, "Positive (deleted)", "Negative (non-edge)")) %>%
select(Class, everything(), -obs) %>%
kable(digits = 3,
caption = "Table 3. Mean of each heuristic by class. Positive class are the 5,000 deleted real edges; negative class are the 5,000 hard non-edges.")
| Class | Common neighbors | Jaccard | Adamic-Adar | Pref. Attachment | Graph distance (1/d) | Resource Allocation | Same community |
|---|---|---|---|---|---|---|---|
| Negative (non-edge) | 0.307 | 0.010 | 0.101 | 211.667 | 0.354 | 0.015 | 0.040 |
| Positive (deleted) | 2.783 | 0.093 | 0.974 | 312.422 | 0.468 | 0.165 | 0.463 |
heuristic_cols <- c("sim_cn","sim_jacc","sim_aa","sim_pref",
"sim_dist","sim_ra","sim_comm")
pretty_names <- c("CN","Jaccard","AA","PA","1/d","RA","Same comm")
cor_mat <- cor(total_edges[, heuristic_cols])
dimnames(cor_mat) <- list(pretty_names, pretty_names)
cor_df <- as.data.frame(cor_mat)
cor_df <- cbind(` ` = rownames(cor_df), cor_df)
kable(cor_df, digits = 2, row.names = FALSE,
caption = "Table 4. Pairwise Pearson correlation among the seven heuristics. CN, Jaccard, AA and RA share the common-neighbour numerator and are highly collinear (r > 0.85). PA and the binary same-community indicator are largely independent of that family.")
| CN | Jaccard | AA | PA | 1/d | RA | Same comm | |
|---|---|---|---|---|---|---|---|
| CN | 1.00 | 0.87 | 0.99 | 0.48 | 0.66 | 0.92 | 0.57 |
| Jaccard | 0.87 | 1.00 | 0.89 | 0.17 | 0.66 | 0.88 | 0.64 |
| AA | 0.99 | 0.89 | 1.00 | 0.44 | 0.65 | 0.97 | 0.59 |
| PA | 0.48 | 0.17 | 0.44 | 1.00 | 0.36 | 0.35 | 0.04 |
| 1/d | 0.66 | 0.66 | 0.65 | 0.36 | 1.00 | 0.62 | 0.45 |
| RA | 0.92 | 0.88 | 0.97 | 0.35 | 0.62 | 1.00 | 0.58 |
| Same comm | 0.57 | 0.64 | 0.59 | 0.04 | 0.45 | 0.58 | 1.00 |
The CN / Jaccard / AA / RA group is collinear by definition: they all sum over the same intersection of neighbours and only differ in how they weight the terms. Preferential Attachment is a different object (degree product), and the same-community dummy is mostly independent of the local heuristics. Because of this collinearity we report three different “most important” measures in Section 5: single-feature AUC (heuristic by heuristic), GLM standardised coefficients (which control for the rest), and Random Forest permutation importance (which picks up non-redundant predictive value).
The class means look right. Positives (deleted real edges) have more shared neighbours, share Louvain communities far more often, and have larger PA products. Preferential Attachment has the smallest relative gap between the two classes, so we already suspect it will be the weakest predictor, which is what Lecture 4 slide 30 reports.
fill_pal <- c("Random non-edge" = "#7570b3",
"True (deleted) edge" = "#d95f02")
total_edges %>%
pivot_longer(c(sim_cn, sim_jacc, sim_aa, sim_ra, sim_pref),
names_to = "heuristic", values_to = "score") %>%
mutate(class = ifelse(obs == 1, "True (deleted) edge", "Random non-edge"),
heuristic = recode(heuristic,
sim_cn = "Common Neighbors",
sim_jacc = "Jaccard",
sim_aa = "Adamic-Adar",
sim_pref = "Preferential Attachment",
sim_ra = "Resource Allocation"),
heuristic = factor(heuristic,
levels = c("Common Neighbors","Jaccard",
"Adamic-Adar","Resource Allocation",
"Preferential Attachment"))) %>%
ggplot(aes(score + 1e-3, fill = class)) +
geom_histogram(alpha = 0.6, position = "identity", bins = 40, color = NA) +
scale_x_log10(labels = scales::label_log()) +
facet_wrap(~ heuristic, scales = "free", ncol = 2) +
scale_fill_manual(values = fill_pal) +
labs(x = "Score (log10, with +1e-3 offset)",
y = "Number of pairs", fill = NULL) +
theme_minimal(base_size = 11) +
theme(legend.position = "bottom")
Figure 3a. Continuous heuristics: score distributions by class on a log10 x-axis (small offset added so zeros are visible). The further apart the two coloured masses are, the better the heuristic separates real ties from non-ties.
# Convert 1/d back to integer hop count for a readable axis.
# d = 0 is reserved for disconnected pairs.
dist_summary <- total_edges %>%
mutate(dist_int = ifelse(sim_dist > 0, as.integer(round(1/sim_dist)), 0L),
dist_lab = ifelse(dist_int == 0, "disconnected", as.character(dist_int)),
class = ifelse(obs == 1, "True (deleted) edge", "Random non-edge")) %>%
count(class, dist_int, dist_lab)
ordered_levels <- c(
if (any(dist_summary$dist_int == 0)) "disconnected" else NULL,
as.character(sort(setdiff(unique(dist_summary$dist_int), 0L)))
)
dist_summary %>%
mutate(dist_lab = factor(dist_lab, levels = ordered_levels)) %>%
ggplot(aes(dist_lab, n, fill = class)) +
geom_col(position = "dodge") +
scale_fill_manual(values = fill_pal) +
labs(x = "Shortest-path hop count d in G'",
y = "Number of pairs", fill = NULL,
title = "Graph distance") +
theme_minimal(base_size = 11) +
theme(legend.position = "none") -> p_dist
total_edges %>%
mutate(class = ifelse(obs == 1, "True (deleted) edge", "Random non-edge"),
same = ifelse(sim_comm == 1, "Same community", "Different community")) %>%
count(class, same) %>%
ggplot(aes(same, n, fill = class)) +
geom_col(position = "dodge") +
scale_fill_manual(values = fill_pal) +
labs(x = NULL, y = "Count", fill = NULL,
title = "Same Louvain community") +
theme_minimal(base_size = 11) +
theme(legend.position = "bottom") -> p_comm
gridExtra::grid.arrange(p_dist, p_comm, ncol = 2)
Figure 3b. Discrete heuristics shown on linear axes. Graph distance takes only a handful of integer values (so 1/d is also discrete) and same-community is binary, which is why a log axis is not informative for them.
The Homework 2 spec calls for cross-validation, going beyond Workshop
3’s single train/test split. We use 10-fold
cross-validation via caret::train, holding out the
same observations across all seven features so feature comparisons are
fair.
We fit two models:
glm with
family = binomial(link = "logit")), the model from Workshop
3 and Lecture 4. The coefficients tell us which feature still matters
after we control for the others.# caret needs a factor outcome for classification with ROC metric.
total_edges$obs_f <- factor(total_edges$obs, levels = c(0, 1),
labels = c("no", "yes"))
ctrl <- trainControl(
method = "cv",
number = 10,
classProbs = TRUE,
summaryFunction = twoClassSummary,
savePredictions = "final"
)
set.seed(42)
fit_glm <- train(
obs_f ~ sim_cn + sim_jacc + sim_aa + sim_pref + sim_dist + sim_ra + sim_comm,
data = total_edges,
method = "glm",
family = binomial(link = "logit"),
metric = "ROC",
trControl = ctrl
)
| Model | CV folds | AUC (ROC) | Sensitivity | Specificity |
|---|---|---|---|---|
| Logistic regression (glm) | 10 | 0.8810 | 0.8820 | 0.7504 |
| Feature | Estimate | Std. error | z value | p value |
|---|---|---|---|---|
| (Intercept) | -2.521 | 0.165 | -15.24 | 1.8e-52 |
| Common neighbors | -2.713 | 1.616 | -1.68 | 0.0932 |
| Jaccard | 11.689 | 2.380 | 4.91 | 9.0e-07 |
| Adamic-Adar | 12.874 | 7.151 | 1.80 | 0.0718 |
| Preferential Attachment | 0.001 | 0.000 | 3.14 | 0.0017 |
| Graph distance (1/d) | 2.637 | 0.480 | 5.49 | 4.0e-08 |
| Resource Allocation | -19.988 | 14.976 | -1.33 | 0.1820 |
| Same community | 1.526 | 0.095 | 16.09 | 2.8e-58 |
set.seed(42)
fit_rf <- train(
obs_f ~ sim_cn + sim_jacc + sim_aa + sim_pref + sim_dist + sim_ra + sim_comm,
data = total_edges,
method = "rf",
metric = "ROC",
trControl = ctrl,
tuneGrid = data.frame(mtry = c(2, 3, 4)),
ntree = 300,
importance = TRUE
)
| mtry | AUC (ROC) | Sensitivity | Specificity |
|---|---|---|---|
| 2 | 0.8829 | 0.8706 | 0.7798 |
| 3 | 0.8824 | 0.8678 | 0.7678 |
| 4 | 0.8831 | 0.8682 | 0.7676 |
We use the cross-validated out-of-fold predictions from
caret, then evaluate the GLM at two probability thresholds:
the Workshop 3 default of 0.30 and the textbook 0.50.
oof <- fit_glm$pred %>%
arrange(rowIndex) %>%
mutate(obs01 = ifelse(obs == "yes", 1L, 0L))
cm_summary <- function(thr) {
pred_thr <- factor(ifelse(oof$yes > thr, "yes", "no"), levels = c("no","yes"))
cm <- confusionMatrix(pred_thr, oof$obs, positive = "yes")
list(
table = as.data.frame.matrix(cm$table),
metrics = c(Accuracy = unname(cm$overall["Accuracy"]),
Sensitivity = unname(cm$byClass["Sensitivity"]),
Specificity = unname(cm$byClass["Specificity"]),
Kappa = unname(cm$overall["Kappa"]),
F1 = unname(cm$byClass["F1"]))
)
}
cm_03 <- cm_summary(0.3)
cm_05 <- cm_summary(0.5)
| Predicted Actual | no | yes |
|---|---|---|
| no | 3864 | 755 |
| yes | 1136 | 4245 |
| Metric | Value |
|---|---|
| Accuracy | 0.811 |
| Sensitivity (recall on positives) | 0.849 |
| Specificity (recall on negatives) | 0.773 |
| Cohen’s kappa | 0.622 |
| F1 score | 0.818 |
| Predicted Actual | no | yes |
|---|---|---|
| no | 4410 | 1248 |
| yes | 590 | 3752 |
| Metric | Value |
|---|---|
| Accuracy | 0.816 |
| Sensitivity (recall on positives) | 0.750 |
| Specificity (recall on negatives) | 0.882 |
| Cohen’s kappa | 0.632 |
| F1 score | 0.803 |
Following Lecture 4 slide 30 (which compares heuristics by their relative performance vs. random and vs. common neighbors), we compute the rank-based AUC for every single heuristic and for the fitted GLM.
auc_simple <- function(score, label) {
pos <- score[label == 1]; neg <- score[label == 0]
rk <- rank(c(pos, neg))
(sum(rk[seq_along(pos)]) - length(pos)*(length(pos)+1)/2) /
(length(pos) * length(neg))
}
y <- total_edges$obs
rf_oof <- fit_rf$pred %>%
filter(mtry == fit_rf$bestTune$mtry) %>%
arrange(rowIndex) %>%
mutate(obs01 = ifelse(obs == "yes", 1L, 0L))
auc_tbl <- tibble(
Predictor = c("Common neighbors", "Jaccard", "Adamic-Adar",
"Preferential Attachment", "Graph distance (1/d)",
"Resource Allocation", "Same community",
"GLM (all 7 features, OOF)", "Random Forest (all 7 features, OOF)"),
Type = c(rep("single heuristic", 7), "combined model", "combined model"),
AUC = c(
auc_simple(total_edges$sim_cn, y),
auc_simple(total_edges$sim_jacc, y),
auc_simple(total_edges$sim_aa, y),
auc_simple(total_edges$sim_pref, y),
auc_simple(total_edges$sim_dist, y),
auc_simple(total_edges$sim_ra, y),
auc_simple(total_edges$sim_comm, y),
auc_simple(oof$yes, oof$obs01),
auc_simple(rf_oof$yes, rf_oof$obs01)
)
) %>% arrange(desc(AUC))
| Predictor | Type | AUC |
|---|---|---|
| Random Forest (all 7 features, OOF) | combined model | 0.8830 |
| GLM (all 7 features, OOF) | combined model | 0.8811 |
| Adamic-Adar | single heuristic | 0.8635 |
| Jaccard | single heuristic | 0.8628 |
| Resource Allocation | single heuristic | 0.8627 |
| Common neighbors | single heuristic | 0.8588 |
| Graph distance (1/d) | single heuristic | 0.8137 |
| Same community | single heuristic | 0.7118 |
| Preferential Attachment | single heuristic | 0.6210 |
One thing to clarify about this comparison: the individual heuristics have no fitted parameters, so their rank-AUC is the same on the training data and on a held-out fold (no overfitting is possible). The combined GLM and RF rows use out-of-fold predictions from the 10-fold CV, while the single-heuristic rows use the full dataset. The comparison is still fair because all we measure is how well a score separates positives from negatives.
roc_tbl <- function(score, label, name) {
ord <- order(-score)
y_o <- label[ord]
tibble(
predictor = name,
fpr = c(0, cumsum(y_o == 0) / sum(y_o == 0)),
tpr = c(0, cumsum(y_o == 1) / sum(y_o == 1))
)
}
# Compute AUC for every series, sort descending so the legend tracks performance
roc_inputs <- list(
"Common neighbors" = list(s = total_edges$sim_cn, l = y),
"Jaccard" = list(s = total_edges$sim_jacc, l = y),
"Adamic-Adar" = list(s = total_edges$sim_aa, l = y),
"Resource Allocation" = list(s = total_edges$sim_ra, l = y),
"Preferential Attachment" = list(s = total_edges$sim_pref, l = y),
"Same community" = list(s = total_edges$sim_comm, l = y),
"GLM (combined)" = list(s = oof$yes, l = oof$obs01),
"Random Forest" = list(s = rf_oof$yes, l = rf_oof$obs01)
)
aucs <- sapply(roc_inputs, function(x) auc_simple(x$s, x$l))
order_idx <- order(-aucs)
labels_in_order <- sprintf("%s (AUC = %.3f)",
names(roc_inputs)[order_idx],
aucs[order_idx])
roc_df <- bind_rows(lapply(seq_along(roc_inputs), function(i) {
x <- roc_inputs[[i]]
roc_tbl(x$s, x$l,
sprintf("%s (AUC = %.3f)", names(roc_inputs)[i], aucs[i]))
})) %>%
mutate(predictor = factor(predictor, levels = labels_in_order))
ggplot(roc_df, aes(fpr, tpr, color = predictor)) +
geom_abline(slope = 1, intercept = 0, lty = 2, color = "grey60") +
geom_line(linewidth = 0.9) +
coord_equal() +
scale_color_viridis_d(option = "D", end = 0.92) +
labs(x = "False positive rate", y = "True positive rate", color = NULL) +
theme_minimal(base_size = 11) +
theme(legend.position = "right", legend.text = element_text(size = 9))
Figure 4. ROC curves for the seven single heuristics and the two combined classifiers (GLM and Random Forest). Legend is sorted by AUC. Combined-model curves use out-of-fold cross-validated predictions; single-heuristic curves use full-sample scores (no fitting, so no overfitting risk).
Permutation importance is the usual way to answer “which feature matters most” when the features are correlated. The Random Forest shuffles each feature in turn and measures how much the accuracy drops.
imp <- importance(fit_rf$finalModel, type = 1) # type 1 = mean decrease in accuracy
imp_df <- tibble(
feature = rownames(imp),
importance = as.numeric(imp[, 1])
) %>%
mutate(Feature = recode(feature,
sim_cn = "Common Neighbors",
sim_jacc = "Jaccard",
sim_aa = "Adamic-Adar",
sim_pref = "Preferential Attachment",
sim_dist = "Graph distance (1/d)",
sim_ra = "Resource Allocation",
sim_comm = "Same community"),
Family = case_when(
feature %in% c("sim_cn","sim_jacc","sim_aa","sim_ra") ~ "Common-neighbour family",
feature == "sim_pref" ~ "Degree-based",
feature == "sim_dist" ~ "Path-based",
feature == "sim_comm" ~ "Community-based"
)) %>%
arrange(desc(importance))
ggplot(imp_df, aes(reorder(Feature, importance), importance, fill = Family)) +
geom_col() +
coord_flip() +
scale_fill_manual(values = c("Common-neighbour family" = "#1b9e77",
"Degree-based" = "#d95f02",
"Community-based" = "#7570b3",
"Path-based" = "#666666")) +
labs(x = NULL, y = "Mean decrease in accuracy",
fill = NULL) +
theme_minimal(base_size = 11) +
theme(legend.position = "bottom")
Figure 5. Random Forest permutation importance (mean decrease in accuracy when each feature is shuffled). Bars are coloured by category: structural local heuristics, mesoscopic structure, and degree-based features. Single-fit values; run-to-run variance from the RF training step is not shown.
| Rank | Feature | Family | Importance |
|---|---|---|---|
| 1 | Preferential Attachment | Degree-based | 51.9 |
| 2 | Resource Allocation | Common-neighbour family | 29.9 |
| 3 | Same community | Community-based | 28.4 |
| 4 | Jaccard | Common-neighbour family | 27.8 |
| 5 | Adamic-Adar | Common-neighbour family | 26.2 |
| 6 | Common Neighbors | Common-neighbour family | 8.1 |
| 7 | Graph distance (1/d) | Path-based | 6.0 |
single_heuristics <- c("Common neighbors","Jaccard","Adamic-Adar",
"Preferential Attachment","Graph distance (1/d)",
"Resource Allocation","Same community")
winner_auc <- auc_tbl$predictor[auc_tbl$predictor %in% single_heuristics][1]
glm_coef <- summary(fit_glm$finalModel)$coefficients
glm_z <- abs(glm_coef[-1, "z value"]) # drop intercept
winner_glm <- names(which.max(glm_z))
winner_glm_pretty <- recode(winner_glm,
sim_cn = "Common Neighbors", sim_jacc = "Jaccard",
sim_aa = "Adamic-Adar", sim_pref = "Preferential Attachment",
sim_dist = "Graph distance", sim_ra = "Resource Allocation",
sim_comm = "Same community")
cat("- **Single best heuristic by AUC:** ", winner_auc, "\n")
cat("- **Largest GLM |z|-statistic:** ", winner_glm_pretty, "\n")
cat("- **Top RF permutation importance:** ", imp_df$feature[1], "\n")
The three measures give three different answers, and the disagreement is informative:
So “important” depends on what you ask. A heuristic can score high by itself (AA), it can keep its weight once the others are in the model (same-community), or it can carry information the others miss (PA). The three criteria answer different questions. For the question on the homework brief (“Which heuristic is the most important? Why?”) our answer is Adamic-Adar on the marginal criterion, since it has the highest single-feature AUC. Two caveats: (i) AA, Jaccard and Resource Allocation tie at AUC = 0.863 to three decimals, so picking any one of the four as “the” winner overstates what the data show; and (ii) the answer changes under the other two criteria, which is why we report all three.
The results map onto the mechanisms from Lecture 4 slide 6 (Rivera, Soderstrom & Uzzi 2010). The strong performance of the common-neighbour family (AA, Jaccard, RA, CN, all within 0.005 AUC of each other) is triadic closure (slide 12): students who share friends tend to be tied themselves. AA does slightly better than plain CN because it down-weights common neighbours of high-degree hubs. Sharing a niche friend with degree 5 says more than sharing a hub with degree 80, which is the same idea behind Granovetter’s weak ties. The same-community result in the GLM is community-level homophily (slide 11). PA’s win in the RF is the degree mechanism (slide 6.2.4), not as a marginal predictor but as a residual signal the other features do not capture. So all three mechanisms leave traces in the data, not just triadic closure.
The model performs well on this dataset, but Lecture 4 (slides 32-35) and Workshop 3’s caveats together prescribe a clear menu of extensions. We sketch six.
genders.csv file. A binary “same gender”
feature captures the homophily mechanism of Lecture 4 slide 11 directly.
Adding it to the GLM would test whether sociodemographic homophily
explains residual variance not picked up by structure alone.Use of Artificial Intelligence. Claude Sonnet 4.7 / Opus 4.7 (Anthropic, 2026) was used as a programming assistant for: the data-acquisition pipeline, the seven-heuristic feature loop, the caret cross-validation setup, and prose editing of methodology paragraphs. All methodological choices, threshold defenses, and substantive interpretations are our own.
Data. Sapiezynski, P., Stopczynski, A., Lassen, D. D., & Lehmann, S. (2019). Interaction data from the Copenhagen Networks Study [Data set]. figshare. https://doi.org/10.6084/m9.figshare.7267433
References.
Group submission: Tone Varberg Sabri, Abdullah Tadmuri, Maiheliya Maimaitimin. Master in Computational Social Science, UC3M.
sessionInfo()
R version 4.5.1 (2025-06-13)
Platform: aarch64-apple-darwin20
Running under: macOS Tahoe 26.4.1
Matrix products: default
BLAS: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRblas.0.dylib
LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
locale:
[1] C.UTF-8/UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
time zone: Europe/Madrid
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
other attached packages:
[1] randomForest_4.7-1.2 pROC_1.19.0.1 caret_7.0-1
[4] lattice_0.22-7 scales_1.4.0 ggraph_2.2.2
[7] igraph_2.2.0 lubridate_1.9.4 forcats_1.0.0
[10] stringr_1.6.0 dplyr_1.1.4 purrr_1.2.0
[13] readr_2.1.5 tidyr_1.3.1 tibble_3.3.0
[16] ggplot2_4.0.0 tidyverse_2.0.0 knitr_1.50
loaded via a namespace (and not attached):
[1] tidyselect_1.2.1 viridisLite_0.4.2 timeDate_4041.110
[4] farver_2.1.2 viridis_0.6.5 S7_0.2.0
[7] fastmap_1.2.0 tweenr_2.0.3 digest_0.6.37
[10] rpart_4.1.24 timechange_0.3.0 lifecycle_1.0.5
[13] survival_3.8-3 magrittr_2.0.4 compiler_4.5.1
[16] rlang_1.1.7 sass_0.4.10 tools_4.5.1
[19] yaml_2.3.10 data.table_1.17.2 labeling_0.4.3
[22] graphlayouts_1.2.2 plyr_1.8.9 RColorBrewer_1.1-3
[25] withr_3.0.2 stats4_4.5.1 nnet_7.3-20
[28] grid_4.5.1 polyclip_1.10-7 e1071_1.7-16
[31] future_1.67.0 globals_0.18.0 iterators_1.0.14
[34] MASS_7.3-65 cli_3.6.5 rmarkdown_2.30
[37] generics_0.1.4 future.apply_1.20.0 reshape2_1.4.5
[40] tzdb_0.5.0 proxy_0.4-27 cachem_1.1.0
[43] ggforce_0.5.0 splines_4.5.1 parallel_4.5.1
[46] vctrs_0.6.5 hardhat_1.4.2 Matrix_1.7-3
[49] jsonlite_2.0.0 hms_1.1.3 ggrepel_0.9.6
[52] listenv_0.9.1 foreach_1.5.2 gower_1.0.2
[55] jquerylib_0.1.4 recipes_1.3.1 parallelly_1.45.1
[58] glue_1.8.0 codetools_0.2-20 stringi_1.8.7
[61] gtable_0.3.6 pillar_1.11.1 htmltools_0.5.8.1
[64] ipred_0.9-15 lava_1.8.1 R6_2.6.1
[67] tidygraph_1.3.1 evaluate_1.0.3 memoise_2.0.1
[70] bslib_0.9.0 class_7.3-23 Rcpp_1.1.1
[73] gridExtra_2.3 nlme_3.1-168 prodlim_2025.04.28
[76] xfun_0.52 ModelMetrics_1.2.2.2 pkgconfig_2.0.3