Skip to main content
Autonomous AgentsPrediction715 lines

Social Dynamics Modeling

Quick Summary14 lines
Social dynamics modeling simulates how opinions, behaviors, and information spread through populations. By modeling individuals as agents embedded in social networks, we can predict cascades, polarization, consensus formation, and tipping points. This is essential for forecasting elections, market sentiment, technology adoption, viral content spread, and social movements.

## Key Points

1. Even mild individual preferences (Schelling's 30% threshold) can produce extreme macro-level segregation
2. Bounded confidence models explain polarization: people stop listening to those who disagree, forming isolated clusters
3. Information cascades occur when individuals rationally follow the crowd, potentially leading to collectively wrong outcomes
4. Network topology dramatically affects dynamics: scale-free networks are vulnerable to targeted influence, while small-world networks spread information quickly
5. Complex contagion (requiring social reinforcement) spreads differently than simple contagion and explains why some behaviors are harder to spread than diseases
6. Echo chamber detection requires both structural (network clustering) and opinion (belief homogeneity) analysis
7. Influence maximization can identify the most impactful seed nodes for marketing, public health campaigns, or information operations
8. Social dynamics models bridge the gap between individual behavior and population-level outcomes for forecasting
skilldb get prediction-skills/social-dynamics-modelingFull skill: 715 lines
Paste into your CLAUDE.md or agent config

Social Dynamics Modeling

Overview

Social dynamics modeling simulates how opinions, behaviors, and information spread through populations. By modeling individuals as agents embedded in social networks, we can predict cascades, polarization, consensus formation, and tipping points. This is essential for forecasting elections, market sentiment, technology adoption, viral content spread, and social movements.

Opinion Dynamics Models

The DeGroot Model (Consensus)

The simplest opinion dynamics model: agents repeatedly average their opinion with those of their neighbors.

import numpy as np
import networkx as nx

class DeGrootModel:
    """
    Agents converge to consensus by averaging neighbor opinions.
    Models: committee deliberation, peer influence, social learning.
    """

    def __init__(self, n_agents: int, adjacency_matrix: np.ndarray):
        self.n = n_agents
        # Row-stochastic weight matrix (rows sum to 1)
        row_sums = adjacency_matrix.sum(axis=1, keepdims=True)
        self.W = adjacency_matrix / np.where(row_sums > 0, row_sums, 1)
        self.opinions = np.random.uniform(0, 1, n_agents)
        self.history = [self.opinions.copy()]

    def step(self):
        """One round of opinion averaging."""
        self.opinions = self.W @ self.opinions
        self.history.append(self.opinions.copy())

    def run(self, steps: int = 100):
        """Run until convergence or max steps."""
        for _ in range(steps):
            old = self.opinions.copy()
            self.step()
            if np.max(np.abs(self.opinions - old)) < 1e-8:
                break
        return self.opinions

    def consensus_value(self) -> float:
        """The final consensus value depends on eigenvector centrality."""
        return np.mean(self.opinions)

    def convergence_speed(self) -> float:
        """Second-largest eigenvalue determines convergence speed."""
        eigenvalues = np.sort(np.abs(np.linalg.eigvals(self.W)))[::-1]
        if len(eigenvalues) > 1:
            return eigenvalues[1]  # Closer to 0 = faster convergence
        return 0

Bounded Confidence Models

Agents only listen to neighbors whose opinions are "close enough" to their own, modeling ideological filtering:

class DeffuantModel:
    """
    Bounded confidence model: agents only interact if their
    opinion difference is below a threshold.
    Models: echo chambers, filter bubbles, selective exposure.
    """

    def __init__(self, n_agents: int, confidence_threshold: float = 0.3,
                 convergence_rate: float = 0.5):
        self.n = n_agents
        self.threshold = confidence_threshold
        self.mu = convergence_rate
        self.opinions = np.random.uniform(0, 1, n_agents)
        self.history = [self.opinions.copy()]

    def step(self):
        """Randomly pair agents and update if within confidence bound."""
        i, j = np.random.choice(self.n, 2, replace=False)

        diff = abs(self.opinions[i] - self.opinions[j])
        if diff < self.threshold:
            # Move opinions closer together
            self.opinions[i] += self.mu * (self.opinions[j] - self.opinions[i])
            self.opinions[j] += self.mu * (self.opinions[i] - self.opinions[j])

        self.history.append(self.opinions.copy())

    def run(self, steps: int = 10000):
        for _ in range(steps):
            self.step()
        return self.opinions

    def count_clusters(self, cluster_threshold: float = 0.05) -> int:
        """Count the number of distinct opinion clusters."""
        sorted_opinions = np.sort(self.opinions)
        clusters = 1
        for i in range(1, len(sorted_opinions)):
            if sorted_opinions[i] - sorted_opinions[i-1] > cluster_threshold:
                clusters += 1
        return clusters

    def phase_diagram(self) -> str:
        """
        Predict outcome based on confidence threshold:
        - High threshold (>0.5): Consensus (one cluster)
        - Medium (0.25-0.5): Moderate pluralism (2-3 clusters)
        - Low (<0.25): Extreme pluralism (many clusters)
        """
        n_clusters = self.count_clusters()
        if n_clusters == 1:
            return 'consensus'
        elif n_clusters <= 3:
            return 'moderate_pluralism'
        else:
            return 'fragmentation'

The Voter Model

class VoterModel:
    """
    Each agent randomly copies the opinion of a random neighbor.
    Models: social pressure, conformity, imitation.
    Simple but produces interesting dynamics on networks.
    """

    def __init__(self, graph: nx.Graph, initial_opinions: dict = None):
        self.graph = graph
        self.nodes = list(graph.nodes())

        if initial_opinions:
            self.opinions = initial_opinions
        else:
            self.opinions = {node: np.random.choice([0, 1]) for node in self.nodes}

        self.history = [self.opinions.copy()]

    def step(self):
        """One agent copies a random neighbor."""
        agent = np.random.choice(self.nodes)
        neighbors = list(self.graph.neighbors(agent))
        if neighbors:
            neighbor = np.random.choice(neighbors)
            self.opinions[agent] = self.opinions[neighbor]
        self.history.append(self.opinions.copy())

    def run(self, steps: int = 10000) -> dict:
        for _ in range(steps):
            self.step()
            # Check for consensus
            values = list(self.opinions.values())
            if len(set(values)) == 1:
                return {'outcome': 'consensus', 'value': values[0], 'steps': _ + 1}

        return {
            'outcome': 'incomplete',
            'distribution': {
                0: sum(1 for v in self.opinions.values() if v == 0),
                1: sum(1 for v in self.opinions.values() if v == 1)
            }
        }

    def consensus_probability(self, opinion: int) -> float:
        """Probability that the system reaches consensus on given opinion.
        On a complete graph, it equals the initial fraction holding that opinion."""
        total = len(self.opinions)
        count = sum(1 for v in self.opinions.values() if v == opinion)
        return count / total

Social Contagion

SIR-Style Information Spread

class InformationSIR:
    """
    SIR model adapted for information/rumor spreading.
    S = Susceptible (unaware)
    I = Infected (spreading)
    R = Recovered (aware but no longer spreading)
    """

    def __init__(self, graph: nx.Graph, seed_nodes: list,
                 spread_prob: float = 0.1, recovery_prob: float = 0.05):
        self.graph = graph
        self.spread_prob = spread_prob
        self.recovery_prob = recovery_prob

        self.state = {node: 'S' for node in graph.nodes()}
        for seed in seed_nodes:
            self.state[seed] = 'I'

        self.history = [self._snapshot()]

    def _snapshot(self) -> dict:
        counts = {'S': 0, 'I': 0, 'R': 0}
        for state in self.state.values():
            counts[state] += 1
        return counts

    def step(self):
        """One time step of SIR dynamics."""
        new_state = self.state.copy()

        for node in self.graph.nodes():
            if self.state[node] == 'I':
                # Try to infect neighbors
                for neighbor in self.graph.neighbors(node):
                    if self.state[neighbor] == 'S':
                        if np.random.random() < self.spread_prob:
                            new_state[neighbor] = 'I'
                # Try to recover
                if np.random.random() < self.recovery_prob:
                    new_state[node] = 'R'

        self.state = new_state
        self.history.append(self._snapshot())

    def run(self, max_steps: int = 500) -> dict:
        for step in range(max_steps):
            self.step()
            if self.history[-1]['I'] == 0:
                break

        return {
            'total_reached': self.history[-1]['R'],
            'peak_infected': max(h['I'] for h in self.history),
            'duration': len(self.history),
            'reach_fraction': self.history[-1]['R'] / len(self.graph.nodes()),
            'timeline': self.history
        }

    def estimate_R0(self) -> float:
        """Estimate basic reproduction number."""
        avg_degree = sum(dict(self.graph.degree()).values()) / len(self.graph)
        return self.spread_prob * avg_degree / self.recovery_prob

Complex Contagion

class ComplexContagion:
    """
    Unlike simple contagion (one contact sufficient),
    complex contagion requires social reinforcement:
    multiple neighbors must adopt before you do.
    Models: social movements, risky behaviors, costly adoption.
    """

    def __init__(self, graph: nx.Graph, threshold_type: str = 'fractional'):
        self.graph = graph
        self.threshold_type = threshold_type
        self.adopted = set()

        # Each agent has a personal adoption threshold
        self.thresholds = {}
        for node in graph.nodes():
            if threshold_type == 'fractional':
                self.thresholds[node] = np.random.beta(2, 5)  # Most need ~20-30% of neighbors
            else:
                self.thresholds[node] = np.random.geometric(0.3)  # Absolute count

        self.history = []

    def seed(self, initial_adopters: list):
        self.adopted = set(initial_adopters)

    def step(self) -> int:
        """One cascade step. Returns number of new adoptions."""
        new_adoptions = set()

        for node in self.graph.nodes():
            if node in self.adopted:
                continue

            neighbors = list(self.graph.neighbors(node))
            if not neighbors:
                continue

            adopted_neighbors = sum(1 for n in neighbors if n in self.adopted)

            if self.threshold_type == 'fractional':
                fraction = adopted_neighbors / len(neighbors)
                if fraction >= self.thresholds[node]:
                    new_adoptions.add(node)
            else:
                if adopted_neighbors >= self.thresholds[node]:
                    new_adoptions.add(node)

        self.adopted.update(new_adoptions)
        self.history.append(len(self.adopted))
        return len(new_adoptions)

    def run(self, max_steps: int = 100) -> dict:
        for _ in range(max_steps):
            new = self.step()
            if new == 0:
                break

        total = len(self.graph.nodes())
        return {
            'total_adopted': len(self.adopted),
            'adoption_rate': len(self.adopted) / total,
            'cascade_size': self.history,
            'reached_tipping_point': len(self.adopted) / total > 0.5
        }

    def find_critical_mass(self, n_trials: int = 50) -> float:
        """
        Find the minimum seed fraction needed for a cascade
        to reach >50% of the population.
        """
        fractions = np.linspace(0.01, 0.30, 20)
        cascade_rates = []

        for frac in fractions:
            successes = 0
            for _ in range(n_trials):
                self.adopted = set()
                seed_size = int(frac * len(self.graph.nodes()))
                seeds = np.random.choice(list(self.graph.nodes()), seed_size, replace=False)
                self.seed(seeds.tolist())
                result = self.run()
                if result['reached_tipping_point']:
                    successes += 1
            cascade_rates.append(successes / n_trials)

        # Find the fraction where cascade probability exceeds 50%
        for i, rate in enumerate(cascade_rates):
            if rate > 0.5:
                return fractions[i]
        return fractions[-1]

Cascade Effects and Influence Propagation

Information Cascade Model

class InformationCascade:
    """
    Model where rational agents may ignore their private signal
    and follow the crowd, leading to herding behavior.
    Classic model from Bikhchandani, Hirshleifer, and Welch (1992).
    """

    def __init__(self, n_agents: int, signal_quality: float = 0.6):
        self.n_agents = n_agents
        self.signal_quality = signal_quality
        self.true_state = np.random.choice([0, 1])  # True state of the world
        self.decisions = []
        self.cascade_started = False
        self.cascade_start_agent = None

    def run(self) -> dict:
        for i in range(self.n_agents):
            # Private signal (noisy observation of true state)
            if np.random.random() < self.signal_quality:
                private_signal = self.true_state
            else:
                private_signal = 1 - self.true_state

            # Count previous public decisions
            count_0 = sum(1 for d in self.decisions if d == 0)
            count_1 = sum(1 for d in self.decisions if d == 1)

            # Bayesian rational decision
            if count_1 > count_0 + 1:
                # Strong evidence for 1: ignore private signal
                decision = 1
                if not self.cascade_started:
                    self.cascade_started = True
                    self.cascade_start_agent = i
            elif count_0 > count_1 + 1:
                decision = 0
                if not self.cascade_started:
                    self.cascade_started = True
                    self.cascade_start_agent = i
            else:
                # Evidence inconclusive: follow private signal
                decision = private_signal

            self.decisions.append(decision)

        final_majority = 1 if sum(self.decisions) > len(self.decisions) / 2 else 0

        return {
            'true_state': self.true_state,
            'majority_decision': final_majority,
            'correct': final_majority == self.true_state,
            'cascade_started': self.cascade_started,
            'cascade_start_agent': self.cascade_start_agent,
            'decisions': self.decisions,
            'conformity_rate': max(
                sum(1 for d in self.decisions if d == 1),
                sum(1 for d in self.decisions if d == 0)
            ) / len(self.decisions)
        }

Network Influence Maximization

class InfluenceMaximization:
    """
    Find the most influential seed nodes for maximum cascade spread.
    Uses greedy algorithm with Monte Carlo simulation.
    """

    def __init__(self, graph: nx.Graph, spread_prob: float = 0.1):
        self.graph = graph
        self.spread_prob = spread_prob

    def _simulate_spread(self, seeds: set, n_simulations: int = 100) -> float:
        """Estimate average spread from a seed set."""
        total_spread = 0

        for _ in range(n_simulations):
            active = set(seeds)
            newly_active = set(seeds)

            while newly_active:
                next_active = set()
                for node in newly_active:
                    for neighbor in self.graph.neighbors(node):
                        if neighbor not in active:
                            if np.random.random() < self.spread_prob:
                                next_active.add(neighbor)
                active.update(next_active)
                newly_active = next_active

            total_spread += len(active)

        return total_spread / n_simulations

    def greedy_selection(self, k: int, n_simulations: int = 100) -> list:
        """Select k seed nodes to maximize influence spread."""
        selected = set()
        candidates = set(self.graph.nodes())

        for _ in range(k):
            best_node = None
            best_marginal_gain = -1

            for candidate in candidates - selected:
                trial_seeds = selected | {candidate}
                spread = self._simulate_spread(trial_seeds, n_simulations)
                current_spread = self._simulate_spread(selected, n_simulations) if selected else 0
                marginal_gain = spread - current_spread

                if marginal_gain > best_marginal_gain:
                    best_marginal_gain = marginal_gain
                    best_node = candidate

            if best_node is not None:
                selected.add(best_node)

        return list(selected)

Schelling Segregation Model

class SchellingModel:
    """
    Agents on a grid move if too few neighbors share their type.
    Even mild preferences for similarity produce strong segregation.
    Key insight: micro-motives != macro-behavior.
    """

    def __init__(self, grid_size: int = 50, empty_fraction: float = 0.1,
                 similarity_threshold: float = 0.3):
        self.size = grid_size
        self.threshold = similarity_threshold
        self.grid = np.zeros((grid_size, grid_size), dtype=int)

        # Populate grid: 0=empty, 1=type A, 2=type B
        cells = grid_size * grid_size
        n_empty = int(cells * empty_fraction)
        n_each = (cells - n_empty) // 2

        types = [0] * n_empty + [1] * n_each + [2] * (cells - n_empty - n_each)
        np.random.shuffle(types)
        self.grid = np.array(types).reshape((grid_size, grid_size))

    def _get_neighbors(self, r: int, c: int) -> list:
        neighbors = []
        for dr in [-1, 0, 1]:
            for dc in [-1, 0, 1]:
                if dr == 0 and dc == 0:
                    continue
                nr, nc = r + dr, c + dc
                if 0 <= nr < self.size and 0 <= nc < self.size:
                    neighbors.append(self.grid[nr, nc])
        return neighbors

    def _is_satisfied(self, r: int, c: int) -> bool:
        if self.grid[r, c] == 0:
            return True
        neighbors = [n for n in self._get_neighbors(r, c) if n > 0]
        if not neighbors:
            return True
        same = sum(1 for n in neighbors if n == self.grid[r, c])
        return same / len(neighbors) >= self.threshold

    def step(self) -> int:
        """Move unsatisfied agents to random empty cells."""
        unsatisfied = []
        empty = []

        for r in range(self.size):
            for c in range(self.size):
                if self.grid[r, c] == 0:
                    empty.append((r, c))
                elif not self._is_satisfied(r, c):
                    unsatisfied.append((r, c))

        np.random.shuffle(unsatisfied)
        np.random.shuffle(empty)

        moves = min(len(unsatisfied), len(empty))
        for i in range(moves):
            ur, uc = unsatisfied[i]
            er, ec = empty[i]
            self.grid[er, ec] = self.grid[ur, uc]
            self.grid[ur, uc] = 0

        return moves

    def run(self, max_steps: int = 100) -> dict:
        segregation_history = []
        for step in range(max_steps):
            moves = self.step()
            seg = self.segregation_index()
            segregation_history.append(seg)
            if moves == 0:
                break

        return {
            'final_segregation': self.segregation_index(),
            'steps_to_equilibrium': step + 1,
            'segregation_history': segregation_history
        }

    def segregation_index(self) -> float:
        """Compute average fraction of same-type neighbors."""
        total_same_fraction = 0
        count = 0

        for r in range(self.size):
            for c in range(self.size):
                if self.grid[r, c] == 0:
                    continue
                neighbors = [n for n in self._get_neighbors(r, c) if n > 0]
                if neighbors:
                    same = sum(1 for n in neighbors if n == self.grid[r, c])
                    total_same_fraction += same / len(neighbors)
                    count += 1

        return total_same_fraction / count if count > 0 else 0

Polarization Modeling

Echo Chamber Detection

class EchoChamberDetector:
    """Detect echo chambers in social networks based on opinion and connection patterns."""

    def __init__(self, graph: nx.Graph, opinions: dict):
        self.graph = graph
        self.opinions = opinions

    def opinion_homophily(self) -> float:
        """Measure how much more similar connected agents are vs random pairs."""
        connected_diffs = []
        for u, v in self.graph.edges():
            if u in self.opinions and v in self.opinions:
                connected_diffs.append(abs(self.opinions[u] - self.opinions[v]))

        random_diffs = []
        nodes = list(self.opinions.keys())
        for _ in range(len(connected_diffs)):
            i, j = np.random.choice(len(nodes), 2, replace=False)
            random_diffs.append(abs(self.opinions[nodes[i]] - self.opinions[nodes[j]]))

        connected_avg = np.mean(connected_diffs) if connected_diffs else 0
        random_avg = np.mean(random_diffs) if random_diffs else 0

        # Homophily ratio: 0 = no homophily, 1 = complete homophily
        return 1 - (connected_avg / random_avg) if random_avg > 0 else 0

    def find_echo_chambers(self, min_size: int = 5,
                           opinion_threshold: float = 0.15) -> list:
        """Identify tightly-connected groups with similar opinions."""
        # Find communities
        communities = list(nx.community.greedy_modularity_communities(self.graph))

        echo_chambers = []
        for community in communities:
            if len(community) < min_size:
                continue

            opinions_in_community = [
                self.opinions[n] for n in community if n in self.opinions
            ]
            if not opinions_in_community:
                continue

            opinion_std = np.std(opinions_in_community)
            opinion_mean = np.mean(opinions_in_community)

            # Echo chamber: tight community with homogeneous opinions
            if opinion_std < opinion_threshold:
                # Measure insularity (how many edges go outside)
                internal_edges = sum(
                    1 for u, v in self.graph.edges()
                    if u in community and v in community
                )
                total_edges = sum(
                    self.graph.degree(n) for n in community
                ) / 2
                insularity = internal_edges / max(total_edges, 1)

                echo_chambers.append({
                    'size': len(community),
                    'mean_opinion': opinion_mean,
                    'opinion_std': opinion_std,
                    'insularity': insularity,
                    'is_echo_chamber': opinion_std < opinion_threshold and insularity > 0.7
                })

        return echo_chambers

    def polarization_index(self) -> float:
        """Measure overall network polarization (bimodality of opinions)."""
        values = list(self.opinions.values())
        # Distance from center
        deviations = [abs(v - 0.5) for v in values]
        return np.mean(deviations) * 2  # 0 = no polarization, 1 = complete

Prediction Applications

Using Social Dynamics Models for Forecasting

class SocialForecastEngine:
    """Use social dynamics to predict real-world outcomes."""

    def __init__(self, network_data: dict, opinion_data: dict):
        self.network = self._build_network(network_data)
        self.opinions = opinion_data

    def predict_election(self, candidate_support: dict,
                         n_simulations: int = 100) -> dict:
        """Predict election outcome using opinion dynamics simulation."""
        results = []

        for _ in range(n_simulations):
            # Initialize with polling data + noise
            initial = {}
            for node in self.network.nodes():
                base = candidate_support.get('A', 0.5)
                noise = np.random.normal(0, 0.1)
                initial[node] = np.clip(base + noise, 0, 1)

            # Run bounded confidence dynamics
            model = DeffuantModel(len(self.network.nodes()), confidence_threshold=0.3)
            model.opinions = np.array([initial.get(n, 0.5) for n in self.network.nodes()])
            model.run(steps=5000)

            # Election result: majority wins
            vote_A = np.mean(model.opinions > 0.5)
            results.append(vote_A)

        return {
            'candidate_A_win_probability': np.mean(np.array(results) > 0.5),
            'expected_vote_share': np.mean(results),
            'uncertainty': np.std(results),
            'scenarios': {
                'landslide_A': np.mean(np.array(results) > 0.6),
                'close_race': np.mean((np.array(results) > 0.45) & (np.array(results) < 0.55)),
                'landslide_B': np.mean(np.array(results) < 0.4)
            }
        }

    def _build_network(self, data: dict) -> nx.Graph:
        G = nx.Graph()
        G.add_edges_from(data.get('edges', []))
        return G

Key Takeaways

  1. Even mild individual preferences (Schelling's 30% threshold) can produce extreme macro-level segregation
  2. Bounded confidence models explain polarization: people stop listening to those who disagree, forming isolated clusters
  3. Information cascades occur when individuals rationally follow the crowd, potentially leading to collectively wrong outcomes
  4. Network topology dramatically affects dynamics: scale-free networks are vulnerable to targeted influence, while small-world networks spread information quickly
  5. Complex contagion (requiring social reinforcement) spreads differently than simple contagion and explains why some behaviors are harder to spread than diseases
  6. Echo chamber detection requires both structural (network clustering) and opinion (belief homogeneity) analysis
  7. Influence maximization can identify the most impactful seed nodes for marketing, public health campaigns, or information operations
  8. Social dynamics models bridge the gap between individual behavior and population-level outcomes for forecasting

Install this skill directly: skilldb add prediction-skills

Get CLI access →