Facebook Social Circles: Community Detection & Centrality with Zog ⚡
Mix.install([
# {:zog, "~> 0.6.0"},
{:zog, path: Path.expand("~/repos/elixir/zog")},
{:kino, "~> 0.12"},
{:jason, "~> 1.4"}
])
1. Overview & Data Ingestion
In this notebook, we analyze the ego-Facebook social network dataset from the Stanford Network Analysis Project (SNAP). The network consists of 4,039 individuals (nodes) and 88,234 friendship connections (edges) extracted from survey participants using Facebook social circles.
First, let's download and cache the dataset using Zog.Dataset:
{:ok, txt_path} = Zog.Dataset.fetch_snap(:facebook)
Kino.Markdown.new("""
> **Dataset Ready**: `#{txt_path}` (~850 KB uncompressed edge list).
""")
2. Zero-Copy Ingestion: Native Memory Allocation
We ingest the edge list directly into an ArrayGraph Structure of Arrays (SoA) layout managed in native memory outside the BEAM heap:
alias Zog.IO, as: ZogIO
alias Zog.ResourceGraph
beam_mem_before = :erlang.memory(:total)
{time_load_micro, graph} =
:timer.tc(fn ->
ZogIO.load(txt_path, directed: false, integer_labels: true)
end)
beam_mem_after = :erlang.memory(:total)
beam_mem_delta_mb = (beam_mem_after - beam_mem_before) / (1024 * 1024)
load_ms = time_load_micro / 1000
node_count = ResourceGraph.node_count(graph)
edge_count = ResourceGraph.edge_count(graph)
undirected_edges = div(edge_count, 2)
Kino.Markdown.new("""
### ⚡ Graph Ingestion Summary
- **Nodes (Users)**: `#{node_count}`
- **Undirected Friendships**: `#{undirected_edges}`
- **Directed Half-Edges**: `#{edge_count}`
- **Native Load Time**: **#{Float.round(load_ms, 2)} ms**
- **BEAM Heap Impact**: **#{Float.round(beam_mem_delta_mb, 2)} MB**
""")
3. SNAP Ground Truth Verification
To verify accuracy, we calculate the primary graph metrics using native Zig SIMD / parallel graph kernels and cross-check them directly against the published SNAP benchmark values:
# Compute metrics natively
{time_wcc_micro, wcc_list} =
:timer.tc(fn -> ResourceGraph.weakly_connected_components(graph, raw: true) end)
{time_tri_micro, triangles} = :timer.tc(fn -> ResourceGraph.triangle_count(graph) end)
{time_clust_micro, avg_clustering} =
:timer.tc(fn -> ResourceGraph.average_clustering_coefficient(graph) end)
{time_diam_micro, diameter} = :timer.tc(fn -> ResourceGraph.diameter(graph) end)
# Component calculations
wcc_freq = Enum.frequencies(wcc_list)
largest_wcc = wcc_freq |> Map.values() |> Enum.max()
exact_status = fn actual, expected ->
if actual == expected, do: "✓ Exact Match", else: "⚠ Expected #{expected}"
end
approx_status = fn actual, expected, tolerance ->
if abs(actual - expected) <= tolerance,
do: "✓ Within tolerance",
else: "⚠ Expected ~#{expected}"
end
verification_table = [
%{
"Metric" => "Nodes",
"SNAP Published" => "4,039",
"Zog Computed" => "#{node_count}",
"Status" => exact_status.(node_count, 4_039)
},
%{
"Metric" => "Edges (Undirected)",
"SNAP Published" => "88,234",
"Zog Computed" => "#{undirected_edges}",
"Status" => exact_status.(undirected_edges, 88_234)
},
%{
"Metric" => "Nodes in Largest WCC",
"SNAP Published" => "4,039 (1.000)",
"Zog Computed" => "#{largest_wcc} (#{Float.round(largest_wcc / node_count, 3)})",
"Status" => exact_status.(largest_wcc, 4_039)
},
%{
"Metric" => "Edges in Largest WCC",
"SNAP Published" => "88,234 (1.000)",
"Zog Computed" => "#{undirected_edges} (1.000)",
"Status" => exact_status.(undirected_edges, 88_234)
},
%{
"Metric" => "Number of Triangles",
"SNAP Published" => "1,612,010",
"Zog Computed" => "#{triangles}",
"Status" => exact_status.(triangles, 1_612_010)
},
%{
"Metric" => "Average Clustering Coefficient",
"SNAP Published" => "0.6055",
"Zog Computed" => "#{Float.round(avg_clustering, 4)}",
"Status" => approx_status.(avg_clustering, 0.6055, 0.0001)
},
%{
"Metric" => "Diameter (Longest Shortest Path)",
"SNAP Published" => "8",
"Zog Computed" => "#{trunc(diameter)}",
"Status" => exact_status.(trunc(diameter), 8)
}
]
Kino.Layout.grid(
[
Kino.Markdown.new("""
### 🔬 Ground Truth Cross-Check
Status values are computed from the current Zog outputs against SNAP published metrics.
"""),
Kino.DataTable.new(verification_table, name: "SNAP Benchmark vs Zog Results")
],
columns: 1
)
4. Community Detection Showdown
How do different modularity and propagation algorithms partition Facebook friendship circles? We benchmark four community detection engines implemented natively in Zig:
- Louvain: Multi-level modularity optimization.
- Leiden: Faster, guaranteed connected communities preventing split artifacts.
- Label Propagation (LPA): Sub-linear, near instantaneous label diffusion.
- Fluid Communities: Fluid-dynamics based partition into a fixed $k$ communities.
extract_assignments = fn
%Zog.Community.Result{assignments: asgn} -> asgn
asgn when is_map(asgn) -> asgn
end
community_algorithms = [
{"Louvain", fn -> ResourceGraph.louvain(graph) end},
{"Leiden", fn -> ResourceGraph.leiden(graph) end},
{"Label Propagation", fn -> ResourceGraph.label_propagation(graph) end},
{"Fluid Communities (k=16)",
fn -> ResourceGraph.fluid_communities(graph, target_communities: 16) end}
]
comparison_rows =
for {name, fun} <- community_algorithms do
{time_micro, res} = :timer.tc(fun)
asgn = extract_assignments.(res)
modularity = ResourceGraph.modularity(graph, asgn)
groups = Enum.group_by(Map.keys(asgn), &Map.fetch!(asgn, &1))
num_communities = map_size(groups)
largest_size = groups |> Map.values() |> Enum.map(&length/1) |> Enum.max()
%{
"Algorithm" => name,
"Execution Time" => "#{Float.round(time_micro / 1000, 2)} ms",
"Modularity (Q)" => Float.round(modularity, 4),
"Communities Found" => num_communities,
"Largest Community" => largest_size
}
end
Kino.Layout.grid(
[
Kino.Markdown.new("""
### 🏆 Community Detection Benchmark
Notice how **Louvain**, **Leiden**, and **Label Propagation** complete in **under 30 milliseconds** with high modularity ($Q \\approx 0.81$).
"""),
Kino.DataTable.new(comparison_rows, name: "Community Algorithms Comparison")
],
columns: 1
)
5. Local Community Expansion (Seed Discovery)
Instead of partitioning the whole network globally, what if we want to expand the organic social circle around a single user?
We use ResourceGraph.local_community/3 to perform local modularity expansion starting from an initial user seed:
seed_input = Kino.Input.number("Seed User ID", default: 0)
Kino.Layout.grid([seed_input], columns: 1)
seed_value = Kino.Input.read(seed_input)
whole_number? = fn value -> is_integer(value) or (is_float(value) and value == trunc(value)) end
cond do
not whole_number?.(seed_value) ->
Kino.Markdown.new("⚠️ Please enter a whole-number user ID.")
seed_value < 0 or seed_value >= node_count ->
Kino.Markdown.new("⚠️ Please enter a valid user ID between `0` and `#{node_count - 1}`.")
true ->
seed_id = trunc(seed_value)
{time_loc_micro, local_community_set} =
:timer.tc(fn ->
ResourceGraph.local_community(graph, [seed_id])
end)
comm_members = MapSet.to_list(local_community_set)
comm_size = length(comm_members)
Kino.Markdown.new("""
### 🎯 Local Circle Discovered
- **Seed User**: `#{seed_id}`
- **Discovered Community Size**: `#{comm_size}` users
- **Search Latency**: **#{Float.round(time_loc_micro / 1000, 2)} ms**
- **Sample Members**: `#{inspect(Enum.take(comm_members, 15))}...`
""")
end
6. Social Influence & Centrality Ranking
Who are the most influential individuals in the Facebook network?
- Betweenness Centrality: Identifies social "bridges" linking different circles.
- PageRank: Identifies prestige and structural importance.
- Node Degree: Total direct friendships.
{time_bc_micro, betweenness_scores} =
:timer.tc(fn -> ResourceGraph.betweenness_unweighted(graph) end)
{time_pr_micro, pagerank_scores} = :timer.tc(fn -> ResourceGraph.pagerank(graph) end)
degrees = ResourceGraph.node_degrees(graph)
# Find top 10 bridge nodes by Betweenness
top_influencers =
betweenness_scores
|> Enum.sort_by(&elem(&1, 1), :desc)
|> Enum.take(10)
|> Enum.with_index(1)
|> Enum.map(fn {{user_id, bc}, rank} ->
pr = Map.get(pagerank_scores, user_id, 0.0)
deg = Enum.at(degrees, user_id, 0)
%{
"Rank" => rank,
"User ID" => user_id,
"Betweenness" => Float.round(bc, 2),
"PageRank" => Float.round(pr, 6),
"Friend Count (Degree)" => deg
}
end)
Kino.Layout.grid(
[
Kino.Markdown.new("""
### 👑 Top 10 Bridge Influencers (Betweenness Centrality)
- **Betweenness Computation**: `#{Float.round(time_bc_micro / 1000, 2)} ms`
- **PageRank Computation**: `#{Float.round(time_pr_micro / 1000, 2)} ms`
- **Key Observation**: User `107` has the highest betweenness centrality by far, serving as the primary hub connecting disjoint Facebook social circles.
"""),
Kino.DataTable.new(top_influencers, name: "Top Influencers")
],
columns: 1
)
7. 2D Social Circle Layout & Visualization 🎨
Now that we have identified the community structures and bridge influencers, let's project the entire 4,039-user social graph into 2D space using Multi-Level Coarsening and Pivot-MDS.
We can pack coordinates into a compact binary buffer for an interactive HTML5 Canvas, avoiding bulky JSON payloads for node positions:
# Compute multi-level layout (Louvain macro-clustering + Barnes-Hut local force refinement)
canvas_w = 800
canvas_h = 600
{layout_time_micro, pos_map} =
:timer.tc(fn ->
ResourceGraph.layout_multi_level(graph,
width: canvas_w - 80,
height: canvas_h - 80,
center: {canvas_w / 2, canvas_h / 2},
refine_iterations: 20,
seed: 42
)
end)
louvain_asgn = ResourceGraph.louvain(graph) |> extract_assignments.()
top_bridge_id = top_influencers |> hd() |> Map.get("User ID")
Zog.Kino.render(graph, pos_map,
color_by: louvain_asgn,
highlight: [{top_bridge_id, "Hub User #{top_bridge_id}"}],
edge_file: txt_path,
title: "Facebook Social Circles (#{node_count} Users, #{edge_count} Connections)",
subtitle: "Multi-Level Coarsening Layout • Computed in #{Float.round(layout_time_micro / 1000, 2)} ms",
width: canvas_w,
height: canvas_h
)
8. Clean Native Memory
Always release native allocations when done:
ResourceGraph.destroy(graph)
Kino.Markdown.new("✓ Native memory successfully freed.")