Powered by AppSignal & Oban Pro

Project: Social Network & Community Influence Analyzer

social_network_analyzer.livemd

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 order as |V|, edge_count as |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:

  1. Degree Centrality ($C_D$): Number of direct contacts. Identifies high-activity, popular members.
  2. Betweenness Centrality ($C_B$): Fraction of all shortest paths passing through a node. Identifies information brokers and gatekeepers between factions.
  3. Closeness Centrality ($C_C$): Reciprocal of average shortest-path distance to all other members. Identifies members who can broadcast messages most rapidly.
  4. 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):

  1. Initial Seed: At $t = 0$, an initial set of "activated" seed members receives the information.
  2. Diffusion Rounds: In each round $t + 1$, each newly activated node $u$ has a single chance to convince each inactive neighbor $v$.
  3. The transmission probability is modeled as $p = 1 - e^{-\lambda \cdot w_{uv}}$, where $w_{uv}$ is friendship interaction strength.
  4. 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, or pagerank instead of betweenness.
  • Change the diffusion seed from node 1 to node 34 or node 3 and compare average reach.
  • Increase or decrease base_prob in DiffusionSimulator.simulate/3 to model stronger or weaker social influence.
  • Remove node 3 instead of node 1 and compare the resulting connected components.
  • Try a different DOT layout such as :fdp or :sfdp if Graphviz supports it in your environment.

Conclusion & Key Takeaways

Through this project, we explored the core toolkit of social network analytics in Yog:

  1. Unsupervised Community Detection: Yog.Community.Louvain partitioned the network with high modularity ($Q \approx 0.40$), successfully capturing the historical club split.
  2. 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.
  3. Network Vulnerability: Yog.Connectivity.analyze revealed articulation points whose removal disconnects peripheral members from the organization.
  4. Information Dynamics: Simulated the Independent Cascade model to demonstrate how network topology and broker positions govern viral spread.
  5. Interactive Visualization: Rendered force-directed diagrams using Yog.Render.DOT with modular color coding and centrality-scaled geometries.