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).

Open the files

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

Creating the Network from DataFrames

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.

Exploration of the network

Basic metrics

Connectedness

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 returns Inf for 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.

Density

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.

Diameter

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 - 1 intermediaries.

Visuals

Visuals are key for complementary analysis. But it is not an easy task.

Assortativity

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()

Exploration of network nodes

  • Eigenvector centrality tells you how well-connected a vertex is; vertices with the highest values are the most influential, as they connect to other well-connected vertices.
  • Closeness tells you how close a vertex is to every other vertex. A vertex with high closeness can share information faster than the rest.
  • Betweenness tells you how critical a vertex is for connecting vertices that aren’t directly connected.
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))

Communities

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()