Every “like,” friend request, and tagged photo on a platform like Facebook leaves behind a trace of who is connected to whom. That trace is a network — and networks are one of the richest sources of customer insight a marketing team can get their hands on. Two customers who share a dozen mutual friends are far more likely to respond to the same campaign than two random strangers. A customer who sits at the center of many friendship triangles is probably someone other people listen to. A pair of customers who aren’t friends yet, but share three mutual connections, is a warm introduction waiting to happen.
The original version of this walkthrough used
GraphFrames, a graph library that runs on top of Apache
Spark. Spark is built for networks with billions of edges — genuinely
“big data” scale. For a graduate marketing analytics class, though, we
rarely need that much horsepower, and installing a Spark cluster is a
poor use of a three-hour lab session.
igraph in R gives us the same core ideas —
building a graph from vertices and edges, counting triangles, computing
PageRank, expanding friend-of-a-friend circles — on a single laptop,
with syntax that’s much closer to the dplyr workflows you
already know.
This tutorial rebuilds every analysis from the original article in R, translates the code line-by-line where it’s useful to see the mapping, and reframes each technique around a marketing decision rather than a computer-science exercise.
If you’d like a gentler on-ramp before diving into the Facebook
Circles data, my earlier walkthrough, Social network analysis
using R, covers the same igraph fundamentals —
building a graph, basic centrality measures, and simple visualization —
on a smaller, simpler example. It’s a good warm-up read if any of the
igraph syntax below feels unfamiliar.
library(tidyverse) # data wrangling
library(igraph) # graph construction and algorithms
library(kableExtra) # nicer tables for the report
library(checkdown) # in-line knowledge checks
library(scales) # color/size scaling for the network plot
library(networkD3) # interactive D3.js force-directed graphs
Before we simulate thousands of Facebook users, it helps to see the core vocabulary of network analysis on a network small enough to hold in your head — your own family.
A network (or graph) is just two things:
igraph’s graph_from_literal() lets you
sketch a small graph directly in R code using a simple text notation: a
dash (-) draws an edge between two vertices, and a colon
(:) lets one vertex connect to several others at once
without repeating the dash.
g5 <- graph_from_literal(Jimmy-Mother-Farther-Wife, Jimmy-B:S, Jimmy-A, A-B, Jimmy-G-E, Jimmy-E)
plot(g5)
A few things worth noticing in this tiny graph:
Jimmy-Mother-Farther-Wife is a
chain: Jimmy connects to Mother, who connects to Farther, who
connects to Wife. Reading a chain like this is exactly how you’d trace a
path of introductions in a customer network — “who introduced whom to
whom.”Jimmy-B:S is shorthand for two edges
at once — Jimmy-B and Jimmy-S — the
: operator fans one vertex out to several others in a
single statement.A-B closes a loop: Jimmy connects to
both A and B (via Jimmy-A and Jimmy-B), and
now A and B connect to each other too. That closed loop is a
triangle — the same building block we’ll use later to
spot tightly-knit friend clusters in the Facebook Circles data.Jimmy-G-E and
Jimmy-E together mean Jimmy reaches E two
different ways: directly, and through G. Multiple paths between the same
two people is a sign of a strong tie — exactly the kind of
signal that makes friend-of-friend recommendations reliable rather than
noisy.Every concept in this section — vertices, edges, chains, triangles, multiple paths, and degree — reappears at Facebook-Circles scale later in this tutorial. The only thing that changes is size: 150 (or 4,039) users instead of 8 family members, and marketing questions instead of family trivia.
The Facebook Circles dataset comes from the Stanford Network Analysis Project (SNAP). It’s a real anonymized snapshot of Facebook friendships: roughly 4,039 users (vertices) and about 88,235 friendship links (edges), along with attributes like each user’s birthday, hometown, employer, and school.
Because the real SNAP file lives on an external server we can’t reach from this classroom environment, we’ll simulate a smaller Facebook-Circles-style network with the same shape: users as vertices, friendships as edges, and the same four marketing-relevant attributes (birthday, hometown, employer, school). Everything you learn on the simulated version transfers directly — just swap in the real CSVs from SNAP if you want to scale up outside of class.
Dataset breakdown (what we’re recreating):
set.seed(580)
n_users <- 150
vertices <- tibble(
id = 1:n_users,
birthday = sample(seq(as.Date("1985-01-01"), as.Date("2005-12-31"), by = "day"),
n_users, replace = TRUE),
hometown = sample(paste("City", LETTERS[1:8]), n_users, replace = TRUE),
employer_id = sample(1:12, n_users, replace = TRUE),
school_id = sample(1:10, n_users, replace = TRUE)
)
vertices %>%
slice_head(n = 5) %>%
kable(caption = "First 5 rows of the simulated user (vertex) table") %>%
kable_styling(latex_options = "striped")
| id | birthday | hometown | employer_id | school_id |
|---|---|---|---|---|
| 1 | 1989-08-29 | City B | 4 | 5 |
| 2 | 1991-01-04 | City B | 12 | 5 |
| 3 | 1998-04-19 | City A | 1 | 4 |
| 4 | 1986-02-27 | City H | 10 | 9 |
| 5 | 1988-04-09 | City H | 5 | 2 |
Each row is one user, just like the vertices DataFrame
in the original PySpark version. Having birthday,
employer_id, and school_id on hand is what
lets us later ask marketing questions like “which users share an
employer?” or “who has the same birth month?”
# Barabasi-Albert graph: a small number of well-connected "hub" users,
# similar to the hub-and-spoke pattern real social networks show
g_sim <- sample_pa(n_users, power = 1.1, m = 3, directed = FALSE)
edges <- as_data_frame(g_sim, what = "edges") %>%
rename(src = from, dst = to) %>%
as_tibble()
edges %>%
slice_head(n = 5) %>%
kable(caption = "First 5 rows of the simulated friendship (edge) table") %>%
kable_styling(latex_options = "striped")
| src | dst |
|---|---|
| 1 | 2 |
| 1 | 3 |
| 2 | 3 |
| 1 | 4 |
| 2 | 4 |
sample_pa() builds a
preferential-attachment graph — new users are more
likely to befriend already-popular users, which is exactly the “a few
people have tons of friends, most people have a handful” pattern real
social networks show. That’s what will let triangle-counting and
PageRank produce meaningful results later instead of a flat, uniform
graph.
In PySpark, a GraphFrame is built from a vertices
DataFrame and an edges DataFrame. igraph works the same way
— graph_from_data_frame() takes an edge list and an
optional vertex table and stitches them into one graph object.
fb_graph <- graph_from_data_frame(
d = edges,
vertices = vertices,
directed = FALSE
)
fb_graph
## IGRAPH daefdb6 UN-- 150 444 --
## + attr: name (v/c), birthday (v/n), hometown (v/c), employer_id (v/n),
## | school_id (v/n)
## + edges from daefdb6 (vertex names):
## [1] 1 --2 1 --3 2 --3 1 --4 2 --4 3 --4 3 --5 2 --5 4 --5 5 --6
## [11] 2 --6 3 --6 1 --7 2 --7 6 --7 2 --8 1 --8 5 --8 3 --9 5 --9
## [21] 2 --9 4 --10 3 --10 7 --10 6 --11 2 --11 5 --11 3 --12 9 --12 6 --12
## [31] 12--13 2 --13 1 --13 4 --14 13--14 12--14 4 --15 2 --15 8 --15 5 --16
## [41] 2 --16 12--16 2 --17 4 --17 13--17 12--18 1 --18 8 --18 15--19 5 --19
## [51] 18--19 4 --20 12--20 3 --20 5 --21 17--21 2 --21 3 --22 14--22 5 --22
## [61] 15--23 2 --23 16--23 16--24 13--24 8 --24 8 --25 11--25 16--25 4 --26
## + ... omitted several edges
# The equivalent of GraphFrame's `graph.triplets.show()`:
# each edge, alongside the attributes of both endpoints
# (igraph stores vertex names as character, so we cast back to integer
# before joining against the vertices table)
as_data_frame(fb_graph, what = "edges") %>%
mutate(from = as.integer(from), to = as.integer(to)) %>%
left_join(vertices, by = c("from" = "id")) %>%
rename_with(~ paste0("a_", .), .cols = -c(from, to)) %>%
left_join(vertices, by = c("to" = "id")) %>%
rename_with(~ paste0("b_", .), .cols = c(birthday, hometown, employer_id, school_id)) %>%
slice_head(n = 3) %>%
kable(caption = "Sample 'triplets': an edge plus both users' attributes") %>%
kable_styling(latex_options = "striped", font_size = 9)
| from | to | a_birthday | a_hometown | a_employer_id | a_school_id | b_birthday | b_hometown | b_employer_id | b_school_id |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 1989-08-29 | City B | 4 | 5 | 1991-01-04 | City B | 12 | 5 |
| 1 | 3 | 1989-08-29 | City B | 4 | 5 | 1998-04-19 | City A | 1 | 4 |
| 2 | 3 | 1991-01-04 | City B | 12 | 5 | 1998-04-19 | City A | 1 | 4 |
graph_from_data_frame() combines the two tables, the
same way GraphFrame(vertices, edges) does in PySpark.vertices twice (once per
endpoint) reproduces GraphFrame’s triplets view, letting us
see both users’ attributes side-by-side on every friendship.A shared birthday is a lightweight, low-cost personalization hook — useful for birthday-bundle promotions or “double celebration” event invitations.
same_birthday <- as_data_frame(fb_graph, what = "edges") %>%
mutate(from = as.integer(from), to = as.integer(to)) %>%
left_join(vertices, by = c("from" = "id")) %>%
left_join(vertices, by = c("to" = "id"), suffix = c("_a", "_b")) %>%
filter(birthday_a == birthday_b) %>%
select(a = from, b = to, shared_birthday = birthday_a)
same_birthday %>%
slice_head(n = 5) %>%
kable(caption = "Friend pairs who share a birthday") %>%
kable_styling(latex_options = "striped")
| a | b | shared_birthday |
|---|---|---|
| NA | NA | NA |
| –: | –: | :————— |
graph.find("(a)-[]->(b)") plays in GraphFrames.filter() step is a direct stand-in for the
.filter("a.birthday = b.birthday") line in the
original.Marketing takeaway: a company running a loyalty program could automatically flag friend pairs who share a birthday and offer a “celebrate together” discount code redeemable by both users — a cheap way to turn an existing friendship into two redemptions instead of one.
A triangle is three users who are all mutually connected. A user who sits inside many triangles belongs to a tight-knit friend group — exactly the kind of person whose recommendation carries weight with the people around them.
triangle_counts <- tibble(
id = as.integer(V(fb_graph)$name),
triangle_count = count_triangles(fb_graph)
)
triangle_counts %>%
arrange(desc(triangle_count)) %>%
slice_head(n = 5) %>%
kable(caption = "Users embedded in the most friendship triangles") %>%
kable_styling(latex_options = "striped")
| id | triangle_count |
|---|---|
| 2 | 36 |
| 1 | 23 |
| 3 | 23 |
| 5 | 19 |
| 4 | 16 |
count_triangles() is igraph‘s direct
equivalent of GraphFrames’ triangleCount() — one number per
user, counting how many closed friend-triangles they belong to.Marketing takeaway: tight-knit groups are good candidates for community-building campaigns — think “bring your whole friend group” offers — because the group is already primed to act together.
PageRank scores each user by how well-connected their friends are, not just how many friends they have — someone with 20 friends who are themselves highly connected outranks someone with 50 friends who are all isolated.
pr <- page_rank(fb_graph, damping = 0.85)$vector
influencers <- tibble(
id = as.integer(names(pr)),
pagerank = pr
) %>%
arrange(desc(pagerank))
influencers %>%
slice_head(n = 5) %>%
kable(caption = "Top 5 most influential users by PageRank", digits = 4) %>%
kable_styling(latex_options = "striped")
| id | pagerank |
|---|---|
| 1 | 0.0304 |
| 12 | 0.0281 |
| 2 | 0.0264 |
| 4 | 0.0250 |
| 5 | 0.0217 |
page_rank()‘s damping argument plays the
same role as GraphFrames’ resetProbability — it’s the
“restart” probability that keeps the algorithm from getting stuck
endlessly circling one dense cluster.
(resetProbability = 0.15 in the original is the same idea
as damping = 0.85, just expressed as the complement.)Marketing takeaway: PageRank is a principled way to shortlist influencer-marketing candidates — people whose endorsement reaches other well-connected users, not just a large but disengaged audience.
Before Google’s founders ever thought about friend graphs, they used this exact algorithm to rank web pages. Think of every hyperlink as a “vote”: a page that gets linked to by many other well-linked pages ranks higher than a page with the same number of links from obscure sites. It’s the identical math we just used to rank influential Facebook users — only here the “friendship” is a hyperlink, and it only goes one direction.
That makes this a directed graph instead of an undirected one, which is a useful contrast for a marketing student: friendships are mutual, but a backlink isn’t — Site A can link to Site B without Site B linking back.
To make this concrete, here’s a real link graph, hand-captured from three actual pages on csub.edu (fetched directly on July 23, 2026): the CSUB homepage, the Office of Admissions page, and the College of Business and Public Administration (BPA) page. Every edge below is a hyperlink that genuinely appears on one of those three pages — nothing here is simulated.
# A real (partial) crawl of csub.edu: every edge is an actual hyperlink
# found on one of three fetched pages (Home, Admissions, BPA), captured
# 2026-07-23. Destination pages weren't themselves crawled, so their
# outbound links aren't represented -- this is a snapshot, not a full
# site crawl.
csub_links <- tribble(
~from, ~to, ~url_to,
"Home", "About CSUB", "csub.edu/about/index.shtml",
"Home", "Academics/Degrees", "csub.edu/academics/degrees.shtml",
"Home", "Admissions", "csub.edu/admissions/index.shtml",
"Home", "Financial Aid", "csub.edu/financial-aid/index.shtml",
"Home", "Registrar", "csub.edu/registrar/index.shtml",
"Home", "Library", "library.csub.edu",
"Home", "News", "news.csub.edu",
"Home", "Future Students", "csub.edu/future-students.shtml",
"Home", "BPA", "csub.edu/bpa/index.shtml",
"Admissions", "Home", "csub.edu/",
"Admissions", "About CSUB", "csub.edu/about/index.shtml",
"Admissions", "Academics/Degrees", "csub.edu/academics/degrees.shtml",
"Admissions", "Financial Aid", "csub.edu/financial-aid/index.shtml",
"Admissions", "Registrar", "csub.edu/registrar/index.shtml",
"Admissions", "Library", "library.csub.edu",
"Admissions", "News", "news.csub.edu",
"Admissions", "Future Students", "csub.edu/future-students.shtml",
"Admissions", "BPA", "csub.edu/bpa/index.shtml",
"BPA", "Home", "csub.edu/",
"BPA", "About CSUB", "csub.edu/about/index.shtml",
"BPA", "Academics/Degrees", "csub.edu/academics/degrees.shtml",
"BPA", "Admissions", "csub.edu/admissions/index.shtml",
"BPA", "Financial Aid", "csub.edu/financial-aid/index.shtml",
"BPA", "Registrar", "csub.edu/registrar/index.shtml",
"BPA", "Library", "library.csub.edu",
"BPA", "News", "news.csub.edu",
"BPA", "Future Students", "csub.edu/future-students.shtml",
"BPA", "BPA Academic Programs","csub.edu/bpa/academic-programs.shtml",
"BPA", "MBA Program", "csub.edu/bpa/master-business-administration-mba/index.shtml"
)
csub_graph <- graph_from_data_frame(
csub_links %>% select(from, to), directed = TRUE
)
csub_pr <- page_rank(csub_graph, damping = 0.85)$vector
csub_rank <- tibble(
page = names(csub_pr),
pagerank = csub_pr
) %>%
arrange(desc(pagerank))
csub_rank %>%
kable(caption = "csub.edu pages ranked by PageRank (real crawl, 3 source pages)",
digits = 4) %>%
kable_styling(latex_options = "striped")
| page | pagerank |
|---|---|
| About CSUB | 0.0876 |
| Academics/Degrees | 0.0876 |
| Financial Aid | 0.0876 |
| Registrar | 0.0876 |
| Library | 0.0876 |
| News | 0.0876 |
| Future Students | 0.0876 |
| BPA | 0.0814 |
| Home | 0.0801 |
| Admissions | 0.0801 |
| BPA Academic Programs | 0.0725 |
| MBA Program | 0.0725 |
The table tells you the ranking, but the graph makes the reason for the ranking visible: which pages sit in the middle of the traffic pattern versus off to the side.
# Size and shade each node by its PageRank score so the plot visually
# reinforces the table above -- bigger, darker circles are pages that
# rank higher.
V(csub_graph)$pagerank <- csub_pr[V(csub_graph)$name]
plot(
csub_graph,
layout = layout_with_fr(csub_graph),
vertex.size = 15 + scales::rescale(V(csub_graph)$pagerank, to = c(0, 35)),
vertex.color = scales::col_numeric("Blues", domain = NULL)(V(csub_graph)$pagerank),
vertex.label.color = "black",
vertex.label.cex = 0.75,
vertex.frame.color = "white",
edge.arrow.size = 0.4,
edge.color = "grey60",
main = "csub.edu link graph (node size/shade = PageRank)"
)
Home, Admissions, and BPA sit near the center of the layout with arrows pointing both in and out of them — that mutual linking is what pulls them together visually and pushes their PageRank up. Library, Academics/Degrees, Financial Aid, Registrar, News, and Future Students sit further out with arrows only pointing in: they’re still shaded fairly dark, since three separate pages all vote for them, but they never send a vote back out. BPA Academic Programs and MBA Program sit at the outer edge, each with a single incoming arrow from BPA and nothing else — visually, the periphery of the network is exactly where the lowest PageRank scores end up.
directed = TRUE matters here more than ever — the CSUB
homepage links out to Admissions, but that doesn’t obligate Admissions
to link back (it does, in this case, purely because CSUB’s site template
repeats the same global navigation menu on every page).Marketing takeaway: this is the algorithmic backbone of SEO and digital PR — and the CSUB example makes the internal-linking version of that lesson concrete. A university’s homepage and its shared global navigation act like a bank of backlinks that every page automatically receives; a specific program page (like the MBA page) only accumulates that same authority when something outside the template — a college page, a news story, another department — chooses to link to it directly. For a real marketing or digital-strategy team, that’s the practical version of “earn better backlinks”: don’t just rely on the template nav, get other high-authority pages to link to the page you’re trying to promote.
Everything so far has been a static image — good for a printed report, but not for exploring. D3.js (“Data-Driven Documents”) is the JavaScript library behind most of the interactive network graphics you’ve probably seen in The New York Times or The Pulitzer Center’s investigative pieces: nodes you can drag around, hover over, and zoom into, all running live in a web browser.
You don’t need to write JavaScript to get this. The
networkD3 package wraps D3.js as an R
htmlwidget — you hand it a nodes table and an edges
table, and it renders a fully interactive D3.js force-directed graph
directly inside your knitted HTML report.
library(networkD3)
# networkD3 wants nodes indexed from 0, so we build a lookup table first
nodes_d3 <- tibble(
name = V(csub_graph)$name,
pagerank = V(csub_graph)$pagerank,
group = 1
)
links_d3 <- as_data_frame(csub_graph, what = "edges") %>%
mutate(
source = match(from, nodes_d3$name) - 1,
target = match(to, nodes_d3$name) - 1,
value = 1
)
forceNetwork(
Links = as.data.frame(links_d3),
Nodes = as.data.frame(nodes_d3),
Source = "source",
Target = "target",
Value = "value",
NodeID = "name",
Group = "group",
Nodesize = "pagerank",
radiusCalculation = JS("Math.sqrt(d.nodesize) * 40 + 6"),
linkDistance = 130,
opacity = 0.9,
zoom = TRUE,
arrows = TRUE,
fontSize = 14,
bounded = TRUE
)
nodes_d3 and links_d3 are just the same
csub_graph data, reshaped into the two flat tables
forceNetwork() expects — a nodes table and a 0-indexed edge
list.Nodesize = "pagerank" reuses the exact PageRank scores
from the table and static plot above, so the same “bigger circle =
higher rank” logic carries over — except now a student can drag
Home away from Admissions and watch
the layout physically resist it, which makes the “mutually reinforcing
cluster” idea from the static plot much more tangible.arrows = TRUE keeps the directionality visible, just
like the static igraph plot.Marketing takeaway: static charts are fine for a slide deck, but an interactive network like this is what you’d actually hand to a stakeholder exploring a customer graph, a site map, or a partner network — letting them drag, zoom, and hover into the specific nodes they care about instead of squinting at a fixed image.
1. Targeted marketing and advertising
2. Event planning and networking
3. Influencer marketing
4. Community detection and social dynamics
The math didn’t change when we moved from PySpark to R — triangles
are still triangles, and PageRank still rewards being connected to
well-connected people. What changed is the barrier to entry: with
igraph, a marketing analytics student can build, query, and
visualize a friendship network on a laptop, using
dplyr-style joins they already know, without standing up a
Spark cluster first.
The techniques here — shared-attribute matching, triangle counting, friend-of-friend expansion, and PageRank — map onto four concrete marketing decisions: who to personalize for, who to run group campaigns against, who to introduce to whom, and who to sign as an influencer. That’s the real payoff of social network analysis for a marketing team: turning a graph of relationships into a shortlist of people worth acting on.
networkD3: D3 JavaScript Network Graphs from R. R
package.igraph walkthrough covering the fundamentals
this tutorial builds on.