We have a real Twitter interaction network among 46
people in the Seattle tech/startup scene: 393
undirected ties, weighted by traffic (interaction count),
with two node attributes — male (binary) and
followers (follower count).
We generally need two (edges and node attributes).
library(igraph)
library(dplyr)
library(tidyr)
library(ggplot2)
library(ggraph)
fileLink_edges <- "https://github.com/MagallanesAtAlacip/datafiles/raw/main/edges.csv"
fileLink_attributes <- "https://github.com/MagallanesAtAlacip/datafiles/raw/main/nodeattributes.csv"
edges <- read.csv(fileLink_edges)
attributes <- read.csv(fileLink_attributes)
edges|>head()
## source target traffic
## 1 rachelerman mattmcilwain 35
## 2 rachelerman DaveParkerSEA 5
## 3 rachelerman toddbishop 7
## 4 rachelerman ashannstew 4
## 5 rachelerman LeslieFeinzaig 7
## 6 rachelerman akipman 7
attributes |>head()
## name male followers
## 1 rachelerman 0 7139
## 2 mattmcilwain 1 2804
## 3 DaveParkerSEA 1 3328
## 4 toddbishop 1 24500
## 5 ashannstew 0 3382
## 6 LeslieFeinzaig 0 15900
Use the previous data frames to create the network.
graph_from_data_frame() takes the edge list as the first
argument and the node attribute table as vertices — this
attaches male and followers to every node in
one step, instead of the two-step dictionary approach some tools
require.
EliteNet <- graph_from_data_frame(edges, directed = FALSE, vertices = attributes)
EliteNet
## IGRAPH c409524 UN-- 46 393 --
## + attr: name (v/c), male (v/n), followers (v/n), traffic (e/n)
## + edges from c409524 (vertex names):
## [1] rachelerman--mattmcilwain rachelerman--DaveParkerSEA
## [3] rachelerman--toddbishop rachelerman--ashannstew
## [5] rachelerman--LeslieFeinzaig rachelerman--akipman
## [7] rachelerman--matt_oppy rachelerman--gilbert
## [9] rachelerman--juliesandler rachelerman--BradSmi
## [11] rachelerman--crashdev rachelerman--ShaunaCausey
## [13] rachelerman--john_gabbert rachelerman--moniguzman
## [15] rachelerman--mattmday rachelerman--Rich_Barton
## + ... omitted several edges
A scoping decision: traffic is stored
as an edge attribute, but density, diameter, centrality, and community
detection below are computed on the unweighted graph.
This talk is about topological structure — who is reachable from whom,
who bridges gaps, whether communities exist — and mixing in interaction
volume would answer a different question (“who talks most”). Weighting
by traffic is a legitimate follow-up analysis, just not
this one.
A view of edges and node attributes:
head(igraph::as_data_frame(EliteNet, what = "edges"), 5)
## from to traffic
## 1 rachelerman mattmcilwain 35
## 2 rachelerman DaveParkerSEA 5
## 3 rachelerman toddbishop 7
## 4 rachelerman ashannstew 4
## 5 rachelerman LeslieFeinzaig 7
head(igraph::as_data_frame(EliteNet, what = "vertices"), 5)
## name male followers
## rachelerman rachelerman 0 7139
## mattmcilwain mattmcilwain 1 2804
## DaveParkerSEA DaveParkerSEA 1 3328
## toddbishop toddbishop 1 24500
## ashannstew ashannstew 0 3382
A simple call to plot() will draw the graph:
plot(EliteNet, vertex.label = NA, vertex.size = 5, edge.color = "grey80")
This is the naive first look — no color, no attributes, no seed. It confirms a graph exists and nothing else. Every plot from here on earns its place by testing a specific claim.
A network is “connected” if there exists a path between any pair of nodes (undirected networks).
is_connected(EliteNet)
## [1] TRUE
TRUE — all 46 people are mutually
reachable. That matters mechanically, too:diameter()on a disconnected graph in igraph returnsInffor the unreachable pairs rather than erroring, which would silently give a misleading “worst case” number — so this check is what makes the diameter figure below trustworthy, not just a formality.
From 0 to 1, where 1 makes it a “complete” network: there is a link between every pair of nodes.
edge_density(EliteNet)
## [1] 0.3797101
0.38. Of the 1,035 possible pairs among 46 people, 393 (38%) are actually connected. For a social network at this scale, that’s dense — this reads less like a loose follower graph and more like a tight, high-contact group who mostly already know each other.
When two vertices are connected, one can reach the other using multiple edges. The geodesic is the shortest path between two connected vertices. The diameter is the maximum geodesic in a network.
diameter(EliteNet)
## [1] 3
That is the worst-case scenario for a person to reach another: having
diameter - 1intermediaries.
Visuals are key for complementary analysis. But it is not an easy task.
Assortativity measures whether nodes tend to connect to other nodes similar to themselves. Closer to 1 means higher assortativity, closer to -1 means disassortativity, while 0 is no assortativity. Assortativity can be measured in three complementary ways.
a. Categorical assortativity: tendency for nodes to connect with other nodes sharing the same category.
# igraph's assortativity_nominal needs integer category codes starting at 1
gender_types <- V(EliteNet)$male + 1
assortativity_nominal(EliteNet, types = gender_types, directed = FALSE)
## [1] 0.04310211
With r ~ 0.043, gender is effectively irrelevant: same-sex and cross-sex ties are distributed no differently than chance would predict.
Layouts help show the complexity of a graph without becoming an analytical tool on their own. If patterns are not shockingly clear, eye bias may lead us to interpret visual artifacts as if they meant something.
Let’s prepare a layout to see if we can effectively interpret the
close-to-zero value of assortativity for the male attribute
— coloring nodes by gender to see if the (lack of) pattern is
visible:
set.seed(42)
lay <- layout_with_fr(EliteNet, niter = 2000)
plot(EliteNet, layout = lay,
vertex.color = ifelse(V(EliteNet)$male == 1, "steelblue", "orange"),
vertex.label = NA, vertex.size = 6, edge.color = "grey85")
A next step is to look at the edge pattern directly. Let’s split the edges into same-sex and cross-sex:
el <- as_edgelist(EliteNet, names = FALSE)
male_vec <- V(EliteNet)$male
same_sex <- el[male_vec[el[,1]] == male_vec[el[,2]], , drop = FALSE]
cross_sex <- el[male_vec[el[,1]] != male_vec[el[,2]], , drop = FALSE]
plot(EliteNet, layout = lay,
vertex.color = ifelse(V(EliteNet)$male == 1, "steelblue", "orange"),
vertex.label = NA, vertex.size = 6,
edge.color = NA)
# cross-sex edges first, in grey
plot(graph_from_edgelist(cross_sex, directed = FALSE), layout = lay, add = TRUE,
vertex.label = NA, vertex.size = 6, vertex.color = NA,
edge.color = adjustcolor("grey60", alpha.f = 0.5))
# same-sex edges on top, in crimson
plot(graph_from_edgelist(same_sex, directed = FALSE), layout = lay, add = TRUE,
vertex.label = NA, vertex.size = 6, vertex.color = NA,
edge.color = adjustcolor("firebrick", alpha.f = 0.4))
Can you detect if gender homophily exists?
Most of the time, visuals require knowing what math actually happened behind the scenes. In this case, assortativity needs a simple cross-tabulation as the initial input:
male_named <- setNames(V(EliteNet)$male, V(EliteNet)$name)
el_names <- as_edgelist(EliteNet, names = TRUE)
mix <- table(male_named[el_names[,1]], male_named[el_names[,2]])
mix
##
## 0 1
## 0 100 99
## 1 89 105
mix is a raw crosstab of edge endpoints by
male — not yet symmetrized or normalized. Because the graph
is undirected, an edge listed as (female, male) and one listed as (male,
female) mean the same thing, so we symmetrize before normalizing:
mix_sym <- (mix + t(mix)) / 2
mix_sym
##
## 0 1
## 0 100 94
## 1 94 105
M <- mix_sym / sum(mix_sym)
M
##
## 0 1
## 0 0.2544529 0.2391858
## 1 0.2391858 0.2671756
| Cell | Meaning |
|---|---|
M[1,1] |
Fraction of edges connecting female <-> female |
M[1,2] |
Fraction of edges connecting female <-> male |
M[2,1] |
Fraction of edges connecting male <-> female |
M[2,2] |
Fraction of edges connecting male <-> male |
We can compute assortativity from the table:
a <- rowSums(M)
r_manual <- (sum(diag(M)) - sum(a^2)) / (1 - sum(a^2))
r_manual
## [1] 0.04310211
| Line | What it does | Mathematical meaning |
|---|---|---|
a <- rowSums(M) |
Row sums of the mixing matrix | \(a_i = \sum_j e_{ij}\) — fraction of all edge-ends attached to group \(i\) |
sum(diag(M)) |
Sum of diagonal elements | \(\sum_i e_{ii}\) — fraction of same-type edges |
sum(a^2) |
Sum of squared marginals | \(\sum_i a_i^2\) — what the diagonal would be under independence |
1 - sum(a^2) |
Denominator | Normalization factor — maximum possible deviation from independence |
\[r = \frac{\sum_i e_{ii} - \sum_i a_i^2}{1 - \sum_i a_i^2}\]
This matches assortativity_nominal() above, confirming
the built-in function isn’t a black box. A simple heatmap makes the lack
of assortativity visible:
M_df <- as.data.frame(as.table(M))
colnames(M_df) <- c("row", "col", "value")
ggplot(M_df, aes(x = col, y = row, fill = value)) +
geom_tile(color = "white") +
geom_text(aes(label = round(value, 3)), color = "white") +
scale_fill_viridis_c(limits = c(0, 0.5)) +
labs(x = NULL, y = NULL, fill = "fraction") +
theme_minimal()
b. Assortativity (numerical):
assortativity(EliteNet, values = V(EliteNet)$followers, directed = FALSE)
## [1] -0.04515662
The result above suggests no disassortativity by follower count. Value too close to zero.
followers_vec <- V(EliteNet)$followers
ggraph(EliteNet, layout = "manual", x = lay[,1], y = lay[,2]) +
geom_edge_link(color = "grey85", alpha = 0.7) +
geom_node_point(aes(color = followers), size = 4) +
scale_color_continuous(palette = "viridis")+
theme_void()
It is not easy to read the lack of assortativity from the plot above.
Again: reflect on what we’re representing mathematically. The question
is do popular people tend to connect with popular people?
or with unpopular ones? Let’s look at the edge behavior
directly:
pairs_df <- data.frame(
u_fol = followers_vec[el[,1]],
v_fol = followers_vec[el[,2]]
)
cor(pairs_df$u_fol, pairs_df$v_fol)
## [1] -0.04957249
Note: unlike a directed edge list from a pandas-style source,
igraph::assortativity() already treats the graph as
undirected internally and symmetrizes both edge directions before
correlating — so the number above from a naive
u/v correlation on a single,
arbitrarily-ordered edge list can differ slightly from
assortativity()’s own result. To see the “true” symmetric
version by hand, add both directions explicitly, exactly like the
built-in function does:
pairs_sym <- rbind(
data.frame(u_fol = followers_vec[el[,1]], v_fol = followers_vec[el[,2]]),
data.frame(u_fol = followers_vec[el[,2]], v_fol = followers_vec[el[,1]])
)
cor(pairs_sym$u_fol, pairs_sym$v_fol)
## [1] -0.04515662
Now we can use pairs_sym for the scatter plot:
ggplot(pairs_sym, aes(x = u_fol, y = v_fol)) +
geom_point(alpha = 0.25, color = "grey40") +
geom_smooth(method = "lm", color = "darkorange", se = TRUE) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", alpha = 0.5) +
labs(x = "u followers", y = "v followers",
title = "Follower count of connected pairs") +
theme_minimal()
c. Degree assortativity: connections based on a different kind of popularity — becoming a hub or a spoke.
assortativity_degree(EliteNet, directed = FALSE)
## [1] -0.2541114
The result above suggests disassortativity: poorly-connected nodes tend to connect with highly-connected ones.
deg <- degree(EliteNet)
ggraph(EliteNet, layout = "manual", x = lay[,1], y = lay[,2]) +
geom_edge_link(color = "grey85", alpha = 0.7) +
geom_node_point(aes(color = deg, size = deg), alpha = 0.7) +
scale_color_continuous(palette = "viridis") +
scale_size(range = c(3, 12),guide = NULL) + #not this legens
theme_void()
We can use degree the same way we used followers as a node attribute:
deg_u <- deg[el[,1]] - 1
deg_v <- deg[el[,2]] - 1
pairs_excess <- rbind(
data.frame(u_excess = deg_u, v_excess = deg_v),
data.frame(u_excess = deg_v, v_excess = deg_u)
)
cor(pairs_excess$u_excess, pairs_excess$v_excess)
## [1] -0.2541114
When computing degree assortativity, we subtract 1 because the edge
you’re standing on is already spent. The question is not “do popular
people have popular friends?” but “given this friendship, do their
remaining friendships cluster?” The -1 accounts
for the connection you’ve already used to get there.
library(ggExtra)
p <- ggplot(pairs_excess, aes(x = u_excess, y = v_excess)) +
geom_point(alpha = 0.3, size = 1.5, color = "steelblue") +
geom_smooth(method = "lm", color = "firebrick") +
theme_minimal()
ggMarginal(p, type = "histogram", fill = "steelblue", color = "white")
ggplot(pairs_excess, aes(x = u_excess, y = v_excess)) +
geom_hex(bins = 15) +
scale_fill_viridis_c(option = "viridis", direction = -1) +
labs(title = "Excess degree of connected pairs") +
theme_minimal()
eig <- eigen_centrality(EliteNet)$vector
clos <- closeness(EliteNet)
betw <- betweenness(EliteNet)
DFCentrality <- data.frame(
person = V(EliteNet)$name,
Eigenvector = eig,
Closeness = clos,
Betweenness = betw
)
head(DFCentrality)
## person Eigenvector Closeness Betweenness
## rachelerman rachelerman 0.8089981 0.01666667 37.827847
## mattmcilwain mattmcilwain 0.4840837 0.01333333 4.649199
## DaveParkerSEA DaveParkerSEA 0.9154314 0.01851852 97.138235
## toddbishop toddbishop 1.0000000 0.02040816 109.933771
## ashannstew ashannstew 0.5803118 0.01388889 4.529823
## LeslieFeinzaig LeslieFeinzaig 0.5885513 0.01388889 3.949373
One note on the plot below: raw eigenvector values span a wide range and, plotted linearly, small differences near the bottom disappear. Rather than an unexplained exponent, we min-max scale eigenvector centrality to a fixed marker-size range (2-10) — the transform is linear and documented, so size differences you see are proportional to the underlying centrality, not an artifact of the scaling choice.
eig_min <- min(DFCentrality$Eigenvector)
eig_max <- max(DFCentrality$Eigenvector)
DFCentrality$size <- 2 + 8 * (DFCentrality$Eigenvector - eig_min) / (eig_max - eig_min)
ggplot(DFCentrality, aes(x = Betweenness, y = Closeness, size = size, label = person)) +
geom_point(alpha = 0.5, color = "#4C72B0") +
geom_text(size = 2.5, alpha = 0.6, vjust = -1) +
scale_size_identity() +
labs(title = "Centrality scatterplot (size = eigenvector centrality, linearly scaled)") +
theme_minimal() +
theme(legend.position = "none")
The previous results tell us that a couple of people are salient:
top_hubs <- DFCentrality %>% arrange(desc(Eigenvector)) %>% head(2) %>% pull(person)
top_hubs
## [1] "toddbishop" "DaveParkerSEA"
Let’s highlight those:
hub_flag <- V(EliteNet)$name %in% top_hubs
plot(EliteNet, layout = lay,
vertex.label = ifelse(hub_flag, V(EliteNet)$name, NA),
vertex.label.cex = 0.8, vertex.label.color = "blue",
vertex.color = ifelse(hub_flag, "red", "grey70"),
vertex.size = ifelse(hub_flag, 8, 4),
edge.color = adjustcolor("grey70", alpha.f = 0.3))
Before hunting for communities, we first ask: does EliteNet have more triangles than chance would produce? If not, any “communities” we find might be algorithmic artifacts — patterns forced by the algorithm rather than real social structure. Transitivity measures “friend of a friend is a friend” — the raw material for communities. We compare EliteNet to a random null model with the same number of nodes and edges. If the ratio is near 1, triangles are random noise.
elite_trans <- transitivity(EliteNet, type = "global")
cat(sprintf("EliteNet transitivity: %.4f\n", elite_trans))
## EliteNet transitivity: 0.5504
n <- vcount(EliteNet)
m <- ecount(EliteNet)
set.seed(123)
random_transitivities <- replicate(100, transitivity(sample_gnm(n, m), type = "global"))
cat(sprintf("Random transitivity: %.4f +/- %.4f\n",
mean(random_transitivities), sd(random_transitivities)))
## Random transitivity: 0.3780 +/- 0.0091
cat(sprintf("Ratio Elite/Random: %.2f\n", elite_trans / mean(random_transitivities)))
## Ratio Elite/Random: 1.46
EliteNet’s transitivity is meaningfully higher than random — triangles are overrepresented. Communities have structural raw material; detection is worth pursuing.
Let’s use the Louvain algororithm:
set.seed(123)
partition_louvain <- cluster_louvain(EliteNet)
length(partition_louvain)
## [1] 4
V(EliteNet)$community <- membership(partition_louvain)
head(igraph::as_data_frame(EliteNet, what = "vertices"))
## name male followers community
## rachelerman rachelerman 0 7139 1
## mattmcilwain mattmcilwain 1 2804 1
## DaveParkerSEA DaveParkerSEA 1 3328 2
## toddbishop toddbishop 1 24500 3
## ashannstew ashannstew 0 3382 4
## LeslieFeinzaig LeslieFeinzaig 0 15900 2
ggraph(EliteNet) +
geom_edge_link(color = "grey60", alpha = 0.2) +
geom_node_point(aes(color = factor(community)), size = 6) +
scale_color_brewer(palette = "Dark2") +
labs(color = "Community",
title = sprintf("Louvain communities (n = %d)", length(partition_louvain))) +
theme_void()
Modularity Q: if we get positive values (1 being the top value), we can consider there to be good community structure (wiki). The higher the modularity, the denser the connections within a partition and the sparser the connections between partitions.
modularity(EliteNet, membership(partition_louvain))
## [1] 0.110428
| Modularity Q | Interpretation |
|---|---|
| < 0.3 | Weak/no meaningful communities |
| 0.3 - 0.7 | Moderate community structure |
| > 0.7 | Strong communities (possibly over-partitioned) |
Let’s prepare an ad-hoc plot. The density within a community, versus a density of the edges between them.
mem <- membership(partition_louvain)
k <- length(partition_louvain)
sz <- as.integer(table(mem))
comm_edges <- matrix(0, k, k)
el_num <- as_edgelist(EliteNet, names = FALSE)
mem_num <- as.integer(mem)
for (i in seq_len(nrow(el_num))) {
cu <- mem_num[el_num[i,1]]; cv <- mem_num[el_num[i,2]]
comm_edges[cu,cv] <- comm_edges[cu,cv] + 1
comm_edges[cv,cu] <- comm_edges[cv,cu] + 1
}
dens <- matrix(0, k, k)
for (i in 1:k) {
for (j in 1:k) {
if (i == j) {
dens[i,j] <- if (sz[i] > 1) comm_edges[i,j] / (sz[i] * (sz[i] - 1)) else 0
} else {
dens[i,j] <- comm_edges[i,j] / (sz[i] * sz[j])
}
}
}
dens_rounded <- round(dens, 3)
dimnames(dens_rounded) <- list(as.character(1:k), as.character(1:k))
dens_df <- as.data.frame(as.table(dens_rounded))
colnames(dens_df) <- c("comm_i", "comm_j", "density")
ggplot(dens_df, aes(x = comm_j, y = comm_i, fill = density)) +
geom_tile(color = "white") +
geom_text(aes(label = density), color = "white", size = 3) +
scale_fill_viridis_c() +
labs(x = NULL, y = NULL) +
theme_minimal()
The diagonal is denser than the off-diagonal — communities are not
pure fiction. But the off-diagonal is far from empty. Let’s add the
ratio: community-internal density divided by the max
density to any other community.
ratios <- sapply(1:k, function(i) dens[i,i] / max(dens[i, -i]))
ratio_table <- data.frame(
community = 1:k,
size = sz,
internal = round(diag(dens), 3),
max_external = round(sapply(1:k, function(i) max(dens[i, -i])), 3),
ratio = round(ratios, 2)
)
ratio_table
## community size internal max_external ratio
## 1 1 15 0.505 0.333 1.51
## 2 2 15 0.562 0.360 1.56
## 3 3 11 0.582 0.382 1.52
## 4 4 5 0.900 0.382 2.36
As expected, diagonal values are higher than off diagonals. Now you show that ratio are just above 2.3.
Notice, visualization can not be honest, you can force visuals:
V(EliteNet)$louvain <- factor(V(EliteNet)$community)
V(EliteNet)$degree <- degree(EliteNet)
ggraph(EliteNet, layout = "hive",
axis = louvain,
sort.by = degree) +
geom_edge_hive(color = "grey80", alpha = 0.25) +
geom_axis_hive(aes(colour = louvain), label = FALSE) +
geom_node_point(aes(colour = louvain)) +
geom_node_text(aes(label = name), repel = TRUE, max.overlaps = 50, size = 2.5) +
coord_fixed() +
theme_void()