Powered by AppSignal & Oban Pro

Facebook Social Circles: Community Detection & Centrality with Zog ⚡

facebook_community_analysis.livemd

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