Project: Social Network & Community Influence Analyzer
Mix.install([
{:yog_ex, path: "../.."},
{:jason, "~> 1.4"},
{:kino_vizjs, "~> 0.9.0"}
])
Introduction
Social network analysis (SNA) is one of the foundational disciplines in graph theory and computational social science. It provides the mathematical machinery to understand:
- Community Structure: How do individuals naturally cluster into tightly-knit subgroups or echo chambers?
- Leadership vs. Gatekeeping: Is the most popular member (highest degree) also the one controlling the flow of information across the network (highest betweenness)?
- Network Vulnerability: Which members serve as critical structural bridges, whose departure would partition or fragment the organization?
- Information Diffusion: How rapidly does a new idea, product adoption, or rumor cascade across the network depending on which member originates the broadcast?
The Zachary Karate Club Case Study
In 1977, sociologist Wayne W. Zachary published an iconic study of a university karate club observed over three years. During the study, a political conflict erupted between the head instructor ("Mr. Hi", Node 1) and the club's administrator/president ("Officer", Node 34). The club subsequently split into two independent factions.
Because the true historical split is known, Zachary's Karate Club has become the canonical "gold standard" benchmark for validating community detection algorithms, modularity optimization, and centrality scoring.
In this project, we will use Yog's algorithms (Yog.Community, Yog.Centrality, Yog.Connectivity, and Yog.Render) to perform a comprehensive social network analysis on this dataset.
Yog Concepts Covered
By the end, you will know how to:
- Build a weighted undirected graph from JSON data.
- Interpret
orderas|V|,edge_countas|E|, and density for simple undirected networks. - Detect communities with Louvain modularity optimization.
- Compare degree, betweenness, closeness, and PageRank centrality.
- Use articulation points and bridges to reason about network vulnerability.
- Render a graph with DOT using custom node and edge attributes.
- Run a small Monte Carlo diffusion simulation over graph neighborhoods.
Why Yog Fits This Problem
Social network analysis usually combines several graph tasks: construction, metrics, community detection, connectivity, visualization, and simulation. Yog keeps those tasks on one graph representation, so you can move from data ingestion to analysis without converting between multiple libraries or formats.
Section 1: Ingesting the Social Graph & Network Fundamentals
We load the Karate Club network from JSON. Each node represents a club member with their formal role and known ground-truth faction. Each undirected edge represents interpersonal friendship and frequent interaction outside of formal martial arts lessons.
alias Yog.Centrality
alias Yog.Community
alias Yog.Connectivity
# Locate dataset on disk with embedded fallback
data_path =
cond do
File.exists?(Path.expand("data/social_network.json", __DIR__)) ->
Path.expand("data/social_network.json", __DIR__)
File.exists?("livebooks/projects/data/social_network.json") ->
"livebooks/projects/data/social_network.json"
true ->
nil
end
raw_data =
if data_path && File.exists?(data_path) do
File.read!(data_path)
else
# Fallback minimal subset if file is absent
~s"""
{
"nodes": [
{"id": 1, "name": "John (Mr. Hi)", "faction": "Mr. Hi", "role": "Head Instructor"},
{"id": 2, "name": "Alex", "faction": "Mr. Hi", "role": "Senior Student"},
{"id": 33, "name": "Frank", "faction": "Officer", "role": "Vice President"},
{"id": 34, "name": "Gene (Officer)", "faction": "Officer", "role": "Club President"}
],
"edges": [
{"source": 1, "target": 2, "weight": 4},
{"source": 33, "target": 34, "weight": 5}
]
}
"""
end
network_json = Jason.decode!(raw_data)
nodes_data = network_json["nodes"]
edges_data = network_json["edges"]
IO.puts("Loaded #{length(nodes_data)} members and #{length(edges_data)} interpersonal ties.")
Now we construct an undirected, weighted graph:
# Build the graph
social_graph =
Enum.reduce(nodes_data, Yog.undirected(), fn member, g ->
Yog.add_node(g, member["id"], %{
name: member["name"],
faction: member["faction"],
role: member["role"]
})
end)
social_graph =
Enum.reduce(edges_data, social_graph, fn e, g ->
Yog.Model.add_edge_ensure(g, e["source"], e["target"], e["weight"])
end)
n = Yog.order(social_graph)
m = Yog.edge_count(social_graph)
density = 2 * m / (n * (n - 1))
IO.puts("=== Network Summary ===")
IO.puts("Members (n = |V|): #{n}")
IO.puts("Interpersonal Ties (m = |E|): #{m}")
IO.puts("Network Density: #{Float.round(density, 4)} (#{:erlang.float_to_binary(density * 100, decimals: 1)}% of all possible friendships exist)")
Expected result: the full Karate Club dataset has 34 members and 78 undirected friendship ties. Yog's edge_count/1 counts each undirected tie once, so the density formula uses the standard simple-graph denominator n * (n - 1) / 2.
Section 2: Unsupervised Community Detection & Modularity Optimization
Can algorithmic community detection predict the real-world club schism without knowing anyone's faction in advance?
We apply the Louvain method (Yog.Community.Louvain.detect/1), a greedy modularity maximization algorithm:
$$Q = \frac{1}{2W} \sum_{ij} \left[ A_{ij} - \frac{k_i k_j}{2W} \right] \delta(c_i, c_j)$$
where $A_{ij}$ is the weighted edge strength between members $i$ and $j$, $k_i$ is the weighted degree (strength) of node $i$, $W$ is the total undirected edge weight, and $\delta(c_i, c_j) = 1$ when members $i$ and $j$ are placed in the same community.
This is separate from the earlier unweighted edge count $m = |E|$ used for density. In Yog, Yog.edge_count/1 counts each undirected friendship once, while modularity uses the graph's edge weights.
# Detect communities using Louvain modularity optimization
community_result = Community.Louvain.detect(social_graph)
modularity_score = Community.modularity(social_graph, community_result)
IO.puts("=== Community Detection Results ===")
IO.puts("Discovered Communities: #{community_result.num_communities}")
IO.puts("Modularity (Q): #{Float.round(modularity_score, 4)}")
A modularity score $Q > 0.3$ is typically considered strong evidence of significant modular community structure in complex networks.
Expected result: the Karate Club graph should produce a positive modularity score with a small number of communities that broadly align with the historical split. Exact community IDs are arbitrary labels and should not be interpreted as stable names.
Ground-Truth Comparison: Evaluating Split Accuracy
Let's group members by their detected community and compare them against their historical factions:
# Group members by detected community ID
members_by_community =
Enum.group_by(nodes_data, fn member ->
community_result.assignments[member["id"]]
end)
Enum.each(members_by_community, fn {comm_id, members} ->
factions = Enum.map(members, & &1["faction"])
mr_hi_count = Enum.count(factions, &(&1 == "Mr. Hi"))
officer_count = Enum.count(factions, &(&1 == "Officer"))
IO.puts("\n🔹 Community #{comm_id} (#{length(members)} members):")
IO.puts(" • Mr. Hi Faction: #{mr_hi_count} | Officer Faction: #{officer_count}")
IO.puts(" • Members: #{Enum.map_join(members, ", ", & &1["name"])}")
end)
Notice how closely the detected communities align with the historical schism! The unsupervised algorithm neatly isolates the Instructor's inner circle from the President's camp.
Section 3: Centrality Metrics & Key Player Identification
In a social network, not all influential actors look alike. We measure 4 complementary dimensions of influence:
- Degree Centrality ($C_D$): Number of direct contacts. Identifies high-activity, popular members.
- Betweenness Centrality ($C_B$): Fraction of all shortest paths passing through a node. Identifies information brokers and gatekeepers between factions.
- Closeness Centrality ($C_C$): Reciprocal of average shortest-path distance to all other members. Identifies members who can broadcast messages most rapidly.
- PageRank Centrality ($PR$): Recursive prestige. Measures connections to other influential members.
deg_scores = Centrality.degree(social_graph)
btw_scores = Centrality.betweenness(social_graph)
cls_scores = Centrality.closeness(social_graph)
pr_scores = Centrality.pagerank(social_graph)
# Combine metrics into a structured leaderboard
scorecard =
Enum.map(nodes_data, fn member ->
id = member["id"]
%{
id: id,
name: member["name"],
faction: member["faction"],
role: member["role"],
degree: Map.get(deg_scores, id, 0),
betweenness: Float.round(Map.get(btw_scores, id, 0.0), 4),
closeness: Float.round(Map.get(cls_scores, id, 0.0), 4),
pagerank: Float.round(Map.get(pr_scores, id, 0.0), 4)
}
end)
# Sort by Betweenness Centrality
top_brokers = Enum.sort_by(scorecard, & &1.betweenness, :desc) |> Enum.take(5)
IO.puts("=== Top 5 Information Brokers (Highest Betweenness) ===")
Enum.each(top_brokers, fn m ->
IO.puts("Node #{String.pad_trailing("#{m.id}", 2)} | #{String.pad_trailing(m.name, 24)} | Betweenness: #{m.betweenness} | Degree: #{m.degree} | PageRank: #{m.pagerank}")
end)
Expected result: the highest-degree members are usually faction leaders, while high-betweenness members are the social brokers connecting otherwise separated groups. This is the main difference between popularity and brokerage.
Strategic Insight: The Hidden Power of Node 3 (Brian)
- Node 1 (Mr. Hi) and Node 34 (Officer) naturally have the highest degree and PageRank because they are the formal leaders.
- However, Node 3 (Brian) possesses an extraordinary Betweenness score despite having a lower degree. Brian maintains friendships across both factions, serving as the indispensable bridge through which rumors, compromises, and social influence must pass!
Section 4: Visualizing the Community Structure
We now render the social network using Yog.Render.DOT.
Visual encoding:
- Node Colors: Tinted by detected Louvain community (e.g. Emerald for Community 0, Indigo for Community 1, Amber for Community 2).
- Node Size: Proportional to Betweenness Centrality, making structural gatekeepers visually prominent.
- Node Borders: Thick borders for key faction leaders (Mr. Hi and Officer).
- Cross-Community Edges (Bridges): Rendered in dashed red/gray to emphasize inter-faction tension.
# Map each community ID to a distinct hex color
palette = ["#10b981", "#6366f1", "#f59e0b", "#ec4899", "#06b6d4"]
community_colors =
community_result.assignments
|> Map.values()
|> Enum.uniq()
|> Enum.with_index()
|> Map.new(fn {cid, idx} -> {cid, Enum.at(palette, rem(idx, length(palette)))} end)
max_btw = Enum.max(Map.values(btw_scores))
dot_opts = %{
Yog.Render.DOT.default_options()
| layout: :neato,
node_shape: :circle,
node_style: :filled,
node_fontname: "Inter,Helvetica,Arial",
node_fontsize: 9,
node_attributes: fn id, _data ->
comm = community_result.assignments[id]
color = Map.get(community_colors, comm, "#64748b")
btw = Map.get(btw_scores, id, 0.0)
# Size node proportionally to betweenness (0.4 to 1.1 inches)
size = Float.round(0.4 + 0.7 * (btw / max(max_btw, 0.001)), 2)
border_style =
if id in [1, 34] do
[{:penwidth, 3.5}, {:color, "#0f172a"}]
else
[{:penwidth, 1.2}, {:color, "#ffffff"}]
end
label = "#{id}\n#{Enum.at(String.split(Enum.find(nodes_data, &(&1["id"] == id))["name"], " "), 0)}"
[
{:fillcolor, color},
{:fontcolor, "#ffffff"},
{:width, size},
{:height, size},
{:fixedsize, true},
{:label, label}
] ++ border_style
end,
edge_attributes: fn u, v, weight ->
comm_u = community_result.assignments[u]
comm_v = community_result.assignments[v]
if comm_u != comm_v do
# Cross-community boundary edge
[{:color, "#f43f5e"}, {:penwidth, 1.8}, {:style, "dashed"}]
else
# Internal community edge
penwidth = max(1.0, weight * 0.5)
[{:color, "#cbd5e1"}, {:penwidth, penwidth}, {:style, "solid"}]
end
end
}
dot_source = Yog.Render.DOT.to_dot(social_graph, dot_opts)
Kino.VizJS.render(dot_source)
Section 5: Structural Vulnerability & Articulation Points
In social network analysis, a member is an articulation point (or cut vertex) if their removal increases the number of connected components in the graph. Similarly, an edge is a bridge if its deletion splits a component.
Using Yog.Connectivity.analyze/1, we examine the structural cohesion of the club:
connectivity = Connectivity.analyze(in: social_graph)
IO.puts("=== Connectivity & Articulation Analysis ===")
IO.puts("Bridges: #{inspect(connectivity.bridges)}")
IO.puts("Articulation Points (Cut Vertices): #{inspect(connectivity.articulation_points)}")
Expected result: articulation points identify members whose removal can split the friendship network. In social terms, these are structurally important brokers or local gatekeepers, not necessarily the most popular people.
Let's test the impact of an articulation point or key broker being removed from the network:
# Simulate the departure of Node 1 (Mr. Hi)
graph_without_instructor = Yog.remove_node(social_graph, 1)
components_after = Connectivity.connected_components(graph_without_instructor)
IO.puts("\nAfter removing Head Instructor (Node 1):")
IO.puts("Number of remaining components: #{length(components_after)}")
Enum.each(Enum.with_index(components_after, 1), fn {comp, idx} ->
IO.puts(" Component #{idx} (#{length(comp)} members): #{inspect(comp)}")
end)
Notice that removing Node 1 detaches several peripheral students (such as Nodes 5, 6, 7, 11) from the rest of the club! Their entire social attachment to the organization was mediated solely through the instructor.
Section 6: Information Diffusion & Cascade Simulation
How does a message or cultural trend propagate through this community?
We implement an Independent Cascade Model (often used to model viral social media spreads and epidemiology):
- Initial Seed: At $t = 0$, an initial set of "activated" seed members receives the information.
- Diffusion Rounds: In each round $t + 1$, each newly activated node $u$ has a single chance to convince each inactive neighbor $v$.
- The transmission probability is modeled as $p = 1 - e^{-\lambda \cdot w_{uv}}$, where $w_{uv}$ is friendship interaction strength.
- The process terminates when no new members are activated.
defmodule DiffusionSimulator do
@doc """
Runs an Independent Cascade simulation starting from `seed_node`.
Returns `{history, total_activated_count}`.
"""
def simulate(graph, seed_node, base_prob \\ 0.25, max_rounds \\ 10) do
do_step(graph, [seed_node], MapSet.new([seed_node]), base_prob, 0, max_rounds, [
{0, MapSet.new([seed_node])}
])
end
defp do_step(_graph, [], all_active, _prob, _round, _max, history) do
{Enum.reverse(history), MapSet.size(all_active)}
end
defp do_step(_graph, _newly_active, all_active, _prob, round, max, history)
when round >= max do
{Enum.reverse(history), MapSet.size(all_active)}
end
defp do_step(graph, newly_active, all_active, base_prob, round, max, history) do
# For each newly activated node, try to activate its neighbors
next_round_activated =
Enum.flat_map(newly_active, fn u ->
neighbors = Yog.Model.neighbors(graph, u)
Enum.filter(neighbors, fn v ->
if MapSet.member?(all_active, v) do
false
else
weight = Yog.Model.edge_data(graph, u, v) || 1
# Probability increases with interaction weight
prob = min(0.95, 1.0 - :math.exp(-base_prob * weight))
:rand.uniform() <= prob
end
end)
end)
|> Enum.uniq()
next_all_active = Enum.reduce(next_round_activated, all_active, &MapSet.put(&2, &1))
next_round = round + 1
do_step(
graph,
next_round_activated,
next_all_active,
base_prob,
next_round,
max,
[{next_round, next_all_active} | history]
)
end
end
Monte Carlo Experiment: Seeding Broker vs. Seeding Peripheral Member
We run 50 Monte Carlo trials comparing information diffusion from:
- Seed A: Node 1 (Mr. Hi - Top Leader / Broker)
- Seed B: Node 17 (Peter - Low-degree Peripheral Member)
trials = 50
:rand.seed(:exsss, {42, 100, 2026})
# Run trials for Node 1
node1_results =
for _ <- 1..trials do
{_history, count} = DiffusionSimulator.simulate(social_graph, 1, 0.20)
count
end
# Run trials for Node 17
node17_results =
for _ <- 1..trials do
{_history, count} = DiffusionSimulator.simulate(social_graph, 17, 0.20)
count
end
avg_node1 = Enum.sum(node1_results) / trials
avg_node17 = Enum.sum(node17_results) / trials
IO.puts("=== Information Cascade Simulation (50 Monte Carlo Trials) ===")
IO.puts("📢 Seed: Node 1 (Head Instructor)")
IO.puts(" Average Reach: #{Float.round(avg_node1, 1)} / #{n} members (#{Float.round(avg_node1 / n * 100, 1)}% of club)")
IO.puts("-----------------------------------------------------------------")
IO.puts("📢 Seed: Node 17 (Peripheral Member)")
IO.puts(" Average Reach: #{Float.round(avg_node17, 1)} / #{n} members (#{Float.round(avg_node17 / n * 100, 1)}% of club)")
Expected result: seeding the information at a central influencer or structural broker should usually reach substantially more of the network than seeding from a peripheral member. Because this is a Monte Carlo simulation, exact values depend on the random seed and probability model.
Try Changing This
- Sort the centrality leaderboard by
degree,closeness, orpagerankinstead ofbetweenness. - Change the diffusion seed from node
1to node34or node3and compare average reach. - Increase or decrease
base_probinDiffusionSimulator.simulate/3to model stronger or weaker social influence. - Remove node
3instead of node1and compare the resulting connected components. - Try a different DOT layout such as
:fdpor:sfdpif Graphviz supports it in your environment.
Conclusion & Key Takeaways
Through this project, we explored the core toolkit of social network analytics in Yog:
- Unsupervised Community Detection:
Yog.Community.Louvainpartitioned the network with high modularity ($Q \approx 0.40$), successfully capturing the historical club split. - Multi-Faceted Centrality: Discovered that popularity (Degree / PageRank) does not equate to gatekeeping (Betweenness). Nodes like Brian (Node 3) hold pivotal brokerage positions bridging distinct communities.
- Network Vulnerability:
Yog.Connectivity.analyzerevealed articulation points whose removal disconnects peripheral members from the organization. - Information Dynamics: Simulated the Independent Cascade model to demonstrate how network topology and broker positions govern viral spread.
- Interactive Visualization: Rendered force-directed diagrams using
Yog.Render.DOTwith modular color coding and centrality-scaled geometries.