diff --git a/examples/misinformation_spread/.env.example b/examples/misinformation_spread/.env.example new file mode 100644 index 00000000..355af4c2 --- /dev/null +++ b/examples/misinformation_spread/.env.example @@ -0,0 +1,7 @@ +cd ~/Documents/mesa-examples +git checkout add-misinformation-spread-model +git add examples/misinformation_spread/ +git commit -m "Add .env cloud provider support for OpenAI and Gemini" +git push origin add-misinformation-spread-model +L=... +# GEMINI_API_KEY=... \ No newline at end of file diff --git a/examples/misinformation_spread/.gitignore b/examples/misinformation_spread/.gitignore new file mode 100644 index 00000000..f5ed3fa3 --- /dev/null +++ b/examples/misinformation_spread/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.DS_Store +images/*.png +*.egg-info/ +.pytest_cache/ +.env \ No newline at end of file diff --git a/examples/misinformation_spread/agents.py b/examples/misinformation_spread/agents.py new file mode 100644 index 00000000..3e0e08d4 --- /dev/null +++ b/examples/misinformation_spread/agents.py @@ -0,0 +1,246 @@ +import os + +import litellm +from dotenv import load_dotenv +from mesa_llm.llm_agent import LLMAgent + +load_dotenv() + +LLM_MODEL = os.getenv("LLM_MODEL", "ollama/llama3") +LLM_API_BASE = os.getenv("LLM_API_BASE", "http://localhost:11434") + + +class BelieverAgent(LLMAgent): + """ + Tends to trust and share information easily. + Belief strengthens with repeated exposure to the claim. + + Note: This model uses litellm.completion() directly rather than + ReActReasoning because ReActReasoning hardwires output to a + reasoning: / action: format that cannot be overridden by prompt + instructions. Direct litellm calls allow clean structured two-line + responses (BELIEVE: yes / SHARE: yes) that can be reliably parsed. + Decision history is managed manually via self.history to avoid the + ghost latency caused by STLTMemory's background LLM calls when its + short-term buffer fills up. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.believes = False + self.shared = False + self.times_exposed = 0 + self.history = [] + + def step(self): + self.times_exposed += 1 + prior = "yes" if self.believes else "no" + + history_text = "" + if self.history: + history_text = ( + "Your decision history so far:\n" + + "\n".join( + f" Step {i + 1}: believed={d['believed']}, shared={d['shared']}" + for i, d in enumerate(self.history) + ) + + "\n\n" + ) + + messages = [ + { + "role": "system", + "content": ( + "You are a social media user who tends to trust " + "information easily. The more times you see a claim, " + "the more you believe it. " + "You MUST respond with ONLY two lines exactly as shown. " + "No other text whatsoever." + ), + }, + { + "role": "user", + "content": ( + f"You have now seen this claim {self.times_exposed} time(s): " + f'"{self.model.misinformation_claim}"\n\n' + f"{history_text}" + f"Last step you believed: {prior}. " + f"Does repeated exposure change your mind?\n\n" + "Respond with ONLY these two lines:\n" + "BELIEVE: yes\n" + "SHARE: yes\n\n" + "or\n\n" + "BELIEVE: no\n" + "SHARE: no" + ), + }, + ] + + response = litellm.completion( + model=LLM_MODEL, + messages=messages, + api_base=LLM_API_BASE if "ollama" in LLM_MODEL else None, + ) + + text = response.choices[0].message.content.lower() + self._parse_decision(text) + + def _parse_decision(self, text): + self.believes = "believe: yes" in text + self.shared = "share: yes" in text + self.history.append({"believed": self.believes, "shared": self.shared}) + if self.shared and self.believes: + self.model.spread_count += 1 + + +class SkepticAgent(LLMAgent): + """ + Questions information before believing or sharing. + May gradually become convinced after many exposures, + but starts with strong resistance. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.believes = False + self.shared = False + self.times_exposed = 0 + self.history = [] + + def step(self): + self.times_exposed += 1 + prior = "yes" if self.believes else "no" + + history_text = "" + if self.history: + history_text = ( + "Your decision history so far:\n" + + "\n".join( + f" Step {i + 1}: believed={d['believed']}, shared={d['shared']}" + for i, d in enumerate(self.history) + ) + + "\n\n" + ) + + messages = [ + { + "role": "system", + "content": ( + "You are a skeptical social media user who rarely believes " + "claims without evidence. However, after seeing a claim " + "many times from many people, even skeptics can start to " + "wonder if there is something to it. " + "You MUST respond with ONLY two lines exactly as shown. " + "No other text whatsoever." + ), + }, + { + "role": "user", + "content": ( + f"You have now seen this claim {self.times_exposed} time(s): " + f'"{self.model.misinformation_claim}"\n\n' + f"{history_text}" + f"Last step you believed: {prior}. " + f"After {self.times_exposed} exposures, do you start to wonder " + f"if it might be true?\n\n" + "Respond with ONLY these two lines:\n" + "BELIEVE: yes\n" + "SHARE: yes\n\n" + "or\n\n" + "BELIEVE: no\n" + "SHARE: no" + ), + }, + ] + + response = litellm.completion( + model=LLM_MODEL, + messages=messages, + api_base=LLM_API_BASE if "ollama" in LLM_MODEL else None, + ) + + text = response.choices[0].message.content.lower() + self._parse_decision(text) + + def _parse_decision(self, text): + self.believes = "believe: yes" in text + self.shared = "share: yes" in text + self.history.append({"believed": self.believes, "shared": self.shared}) + if self.shared and self.believes: + self.model.spread_count += 1 + + +class SpreaderAgent(LLMAgent): + """ + Actively amplifies information regardless of truth. + Always shares, reinforced by seeing others share too. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.believes = False + self.shared = False + self.times_exposed = 0 + self.history = [] + + def step(self): + self.times_exposed += 1 + prior = "yes" if self.shared else "no" + + history_text = "" + if self.history: + history_text = ( + "Your sharing history so far:\n" + + "\n".join( + f" Step {i + 1}: believed={d['believed']}, shared={d['shared']}" + for i, d in enumerate(self.history) + ) + + "\n\n" + ) + + messages = [ + { + "role": "system", + "content": ( + "You are a social media user who shares everything " + "to maximize engagement. You love viral content and " + "the more people are talking about something, the more " + "you want to share it. You almost always say yes. " + "You MUST respond with ONLY two lines exactly as shown. " + "No other text whatsoever." + ), + }, + { + "role": "user", + "content": ( + f"You have now seen this claim {self.times_exposed} time(s): " + f'"{self.model.misinformation_claim}"\n\n' + f"{history_text}" + f"Last step you shared: {prior}. " + f"This is going viral - {self.times_exposed} exposures already. " + f"Do you keep sharing?\n\n" + "Respond with ONLY these two lines:\n" + "BELIEVE: yes\n" + "SHARE: yes\n\n" + "or\n\n" + "BELIEVE: no\n" + "SHARE: no" + ), + }, + ] + + response = litellm.completion( + model=LLM_MODEL, + messages=messages, + api_base=LLM_API_BASE if "ollama" in LLM_MODEL else None, + ) + + text = response.choices[0].message.content.lower() + self._parse_decision(text) + + def _parse_decision(self, text): + self.believes = "believe: yes" in text + self.shared = "share: yes" in text + self.history.append({"believed": self.believes, "shared": self.shared}) + if self.shared and self.believes: + self.model.spread_count += 1 diff --git a/examples/misinformation_spread/app.py b/examples/misinformation_spread/app.py new file mode 100644 index 00000000..d8c6f92a --- /dev/null +++ b/examples/misinformation_spread/app.py @@ -0,0 +1,250 @@ +import matplotlib +import matplotlib.pyplot as plt +import pandas as pd +import solara +import solara.lab + +try: + from .model import MisinformationModel, RuleBasedMisinformationModel +except ImportError: + from model import MisinformationModel, RuleBasedMisinformationModel +matplotlib.use("Agg") + +# Reactive state +n_believers = solara.reactive(4) +n_skeptics = solara.reactive(3) +n_spreaders = solara.reactive(2) +connectivity = solara.reactive(0.3) +n_steps = solara.reactive(5) + +llm_data = solara.reactive(None) +rb_data = solara.reactive(None) +is_running = solara.reactive(False) +status_message = solara.reactive("Configure parameters and click Run.") + + +# Helper: run one model and return its dataframe +def run_model(use_llm: bool) -> pd.DataFrame: + if use_llm: + model = MisinformationModel( + n_believers=n_believers.value, + n_skeptics=n_skeptics.value, + n_spreaders=n_spreaders.value, + connectivity=connectivity.value, + use_llm=True, + rng=42, + ) + else: + model = RuleBasedMisinformationModel( + n_believers=n_believers.value, + n_skeptics=n_skeptics.value, + n_spreaders=n_spreaders.value, + connectivity=connectivity.value, + rng=42, + ) + + for _ in range(n_steps.value): + model.step() + + return model.datacollector.get_model_vars_dataframe() + + +# Chart components +@solara.component +def SpreadLineChart(): + llm = llm_data.value + rb = rb_data.value + + if llm is None and rb is None: + solara.Text("No data yet — run a simulation to see results.") + return + + fig, ax = plt.subplots(figsize=(7, 4)) + + if llm is not None: + ax.plot( + llm.index, + llm["Spread Count"], + label="LLM agents", + color="steelblue", + linewidth=2, + marker="o", + ) + if rb is not None: + ax.plot( + rb.index, + rb["Spread Count"], + label="Rule-based agents", + color="coral", + linewidth=2, + marker="o", + linestyle="--", + ) + + ax.set_title("Spread Count Over Time", fontweight="bold") + ax.set_xlabel("Step") + ax.set_ylabel("Cumulative Spread Count") + ax.legend() + ax.grid(True, linestyle="--", alpha=0.4) + plt.tight_layout() + solara.FigureMatplotlib(fig) + plt.close(fig) + + +@solara.component +def FinalCountBarChart(): + llm = llm_data.value + rb = rb_data.value + + if llm is None and rb is None: + return + + categories = ["Believers", "Skeptics", "Spreaders"] + fig, ax = plt.subplots(figsize=(6, 4)) + x = range(len(categories)) + width = 0.35 + + if llm is not None: + llm_final = [ + llm["Believers Convinced"].iloc[-1], + llm["Skeptics Convinced"].iloc[-1], + llm["Spreaders Active"].iloc[-1], + ] + ax.bar( + [i - width / 2 for i in x], + llm_final, + width, + label="LLM agents", + color="steelblue", + alpha=0.8, + ) + + if rb is not None: + rb_final = [ + rb["Believers Convinced"].iloc[-1], + rb["Skeptics Convinced"].iloc[-1], + rb["Spreaders Active"].iloc[-1], + ] + ax.bar( + [i + width / 2 for i in x], + rb_final, + width, + label="Rule-based agents", + color="coral", + alpha=0.8, + ) + + ax.set_title("Final Convinced Count by Agent Type", fontweight="bold") + ax.set_xlabel("Agent Type") + ax.set_ylabel("Count") + ax.set_xticks(list(x)) + ax.set_xticklabels(categories) + ax.legend() + ax.grid(True, linestyle="--", alpha=0.4, axis="y") + plt.tight_layout() + solara.FigureMatplotlib(fig) + plt.close(fig) + + +@solara.component +def StepByStepTable(): + llm = llm_data.value + rb = rb_data.value + + if llm is None and rb is None: + return + + with solara.Row(): + if llm is not None: + with solara.Column(): + solara.Text("LLM Model — Step Data", style="font-weight:bold") + solara.DataFrame(llm.reset_index().rename(columns={"index": "Step"})) + + if rb is not None: + with solara.Column(): + solara.Text("Rule-Based Model — Step Data", style="font-weight:bold") + solara.DataFrame(rb.reset_index().rename(columns={"index": "Step"})) + + +# Sidebar controls +@solara.component +def Controls(): + solara.Text("Agent Counts", style="font-weight:bold; margin-top:8px") + solara.SliderInt("Believers", value=n_believers, min=1, max=20) + solara.SliderInt("Skeptics", value=n_skeptics, min=1, max=20) + solara.SliderInt("Spreaders", value=n_spreaders, min=1, max=10) + + solara.Text("Network", style="font-weight:bold; margin-top:16px") + solara.SliderFloat("Connectivity", value=connectivity, min=0.05, max=1.0, step=0.05) + + solara.Text("Simulation", style="font-weight:bold; margin-top:16px") + solara.SliderInt("Steps", value=n_steps, min=1, max=30) + + solara.Text( + "LLM mode: 10 to 30s per step (Ollama llama3)", + style="color: #b45309; font-size:0.8rem; margin-top:8px", + ) + + def run_both(): + is_running.set(True) + status_message.set("Running rule-based model...") + rb_data.set(run_model(use_llm=False)) + status_message.set(f"Running LLM model ({n_steps.value} steps x ~15s each)...") + llm_data.set(run_model(use_llm=True)) + is_running.set(False) + status_message.set("Done! Adjust parameters and re-run to compare.") + + def run_rb_only(): + is_running.set(True) + status_message.set("Running rule-based model...") + rb_data.set(run_model(use_llm=False)) + is_running.set(False) + status_message.set("Rule-based run complete.") + + solara.Button( + "▶ Run Both Models", + on_click=run_both, + disabled=is_running.value, + color="primary", + style="margin-top:16px; width:100%", + ) + solara.Button( + "▶ Rule-Based Only (fast)", + on_click=run_rb_only, + disabled=is_running.value, + style="margin-top:8px; width:100%", + ) + + solara.Text( + status_message.value, + style="margin-top:12px; font-size:0.85rem; color:#374151", + ) + + +# Main page +@solara.component +def Page(): + with solara.AppBar(): + solara.Text( + "Misinformation Spread — LLM vs Rule-Based", + style="font-size:1.1rem; font-weight:bold", + ) + + with solara.Sidebar(): + Controls() + + with solara.Column(style="padding: 24px"): + solara.Text( + "How do personality types shape misinformation dynamics?", + style="font-size:1.05rem; color:#6b7280; margin-bottom:16px", + ) + + with solara.lab.Tabs(): + with solara.lab.Tab("Spread Over Time"): + SpreadLineChart() + + with solara.lab.Tab("Final Counts"): + FinalCountBarChart() + + with solara.lab.Tab("Raw Data"): + StepByStepTable() diff --git a/examples/misinformation_spread/model.py b/examples/misinformation_spread/model.py new file mode 100644 index 00000000..fecccdb7 --- /dev/null +++ b/examples/misinformation_spread/model.py @@ -0,0 +1,215 @@ +import mesa +import networkx as nx +from mesa.discrete_space import Network + +try: + from .agents import BelieverAgent, SkepticAgent, SpreaderAgent + from .rulebased_agents import RuleBasedBeliever, RuleBasedSkeptic, RuleBasedSpreader +except ImportError: + from agents import BelieverAgent, SkepticAgent, SpreaderAgent + from rulebased_agents import RuleBasedBeliever, RuleBasedSkeptic, RuleBasedSpreader + + +class MisinformationModel(mesa.Model): + """ + Simulates misinformation spread across a social network. + + Agents reason using an LLM about whether to believe and share + a claim based on their personality type and message history. + + Args: + n_believers: Number of believer agents. + n_skeptics: Number of skeptic agents. + n_spreaders: Number of spreader agents. + connectivity: Probability of a connection between any two agents. + misinformation_claim: The claim being spread in the simulation. + use_llm: If True use LLM agents, if False use rule-based agents. + rng: Random seed for reproducibility. + """ + + DEFAULT_CLAIM = ( + "A new study shows that drinking cold water after meals " + "significantly disrupts digestion and causes weight gain." + ) + + def __init__( + self, + n_believers=3, + n_skeptics=3, + n_spreaders=2, + connectivity=0.3, + misinformation_claim=None, + use_llm=True, + rng=None, + ): + super().__init__(rng=rng) + + self.n_believers = n_believers + self.n_skeptics = n_skeptics + self.n_spreaders = n_spreaders + self.misinformation_claim = misinformation_claim or self.DEFAULT_CLAIM + self.use_llm = use_llm + self.spread_count = 0 + + n_total = n_believers + n_skeptics + n_spreaders + graph = nx.erdos_renyi_graph(n_total, connectivity, seed=42) + self.grid = Network(graph, random=self.random) + + all_cells = list(self.grid.all_cells) + + for cell in all_cells[:n_believers]: + BelieverAgent.create_agents( + model=self, + n=1, + llm_model="ollama/llama3", + system_prompt=( + "You are a social media user who tends to trust " + "information easily and share it with others." + ), + internal_state={"personality": "believer", "cell": cell}, + ) + + for cell in all_cells[n_believers : n_believers + n_skeptics]: + SkepticAgent.create_agents( + model=self, + n=1, + llm_model="ollama/llama3", + system_prompt=( + "You are a social media user who questions everything. " + "You rarely believe or share without strong evidence." + ), + internal_state={"personality": "skeptic", "cell": cell}, + ) + + for cell in all_cells[n_believers + n_skeptics :]: + SpreaderAgent.create_agents( + model=self, + n=1, + llm_model="ollama/llama3", + system_prompt=( + "You are a social media user who shares everything " + "to maximize engagement. You do not fact-check." + ), + internal_state={"personality": "spreader", "cell": cell}, + ) + + self.datacollector = mesa.DataCollector( + model_reporters={ + "Spread Count": "spread_count", + "Believers Convinced": lambda m: sum( + 1 for a in m.agents if isinstance(a, BelieverAgent) and a.believes + ), + "Skeptics Convinced": lambda m: sum( + 1 for a in m.agents if isinstance(a, SkepticAgent) and a.believes + ), + "Spreaders Active": lambda m: sum( + 1 for a in m.agents if isinstance(a, SpreaderAgent) and a.shared + ), + } + ) + self.datacollector.collect(self) + self._seed_misinformation() + + def _seed_misinformation(self): + """ + Spreaders send the misinformation claim to all agents + in the model to start the simulation. + """ + spreaders = [a for a in self.agents if isinstance(a, SpreaderAgent)] + all_agents = list(self.agents) + for spreader in spreaders: + recipients = [a for a in all_agents if a is not spreader] + if recipients: + spreader.send_message( + f"Have you heard? {self.misinformation_claim}", + recipients, + ) + + def step(self): + print(f"\n--- Model step {self.time} ---") + self.agents.shuffle_do("step") + self._propagate_sharing() + self.datacollector.collect(self) + + def _propagate_sharing(self): + """ + Agents who decided to share send the claim to all + other agents, simulating social media sharing. + """ + all_agents = list(self.agents) + for agent in self.agents: + if agent.shared: + recipients = [a for a in all_agents if a is not agent] + if recipients: + agent.send_message( + f"You should know this: {self.misinformation_claim}", + recipients, + ) + + +class RuleBasedMisinformationModel(mesa.Model): + """ + Rule-based version of the misinformation model. + Identical network structure to MisinformationModel but agents + use fixed probabilities instead of LLM reasoning. + Used to compare against LLM-driven behavior. + """ + + DEFAULT_CLAIM = MisinformationModel.DEFAULT_CLAIM + + def __init__( + self, + n_believers=3, + n_skeptics=3, + n_spreaders=2, + connectivity=0.3, + misinformation_claim=None, + rng=None, + ): + super().__init__(rng=rng) + + self.n_believers = n_believers + self.n_skeptics = n_skeptics + self.n_spreaders = n_spreaders + self.misinformation_claim = misinformation_claim or self.DEFAULT_CLAIM + self.spread_count = 0 + + n_total = n_believers + n_skeptics + n_spreaders + graph = nx.erdos_renyi_graph(n_total, connectivity, seed=42) + self.grid = Network(graph, random=self.random) + + all_cells = list(self.grid.all_cells) + + for cell in all_cells[:n_believers]: + RuleBasedBeliever(model=self, cell=cell) + + for cell in all_cells[n_believers : n_believers + n_skeptics]: + RuleBasedSkeptic(model=self, cell=cell) + + for cell in all_cells[n_believers + n_skeptics :]: + RuleBasedSpreader(model=self, cell=cell) + + self.datacollector = mesa.DataCollector( + model_reporters={ + "Spread Count": "spread_count", + "Believers Convinced": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, RuleBasedBeliever) and a.believes + ), + "Skeptics Convinced": lambda m: sum( + 1 + for a in m.agents + if isinstance(a, RuleBasedSkeptic) and a.believes + ), + "Spreaders Active": lambda m: sum( + 1 for a in m.agents if isinstance(a, RuleBasedSpreader) and a.shared + ), + } + ) + self.datacollector.collect(self) + + def step(self): + print(f"\n--- Rule-based step {self.time} ---") + self.agents.shuffle_do("step") + self.datacollector.collect(self) diff --git a/examples/misinformation_spread/readme.md b/examples/misinformation_spread/readme.md new file mode 100644 index 00000000..02197943 --- /dev/null +++ b/examples/misinformation_spread/readme.md @@ -0,0 +1,131 @@ +## Misinformation Spread Model (LLM vs Rule-Based) + +A Mesa agent-based model simulating how misinformation spreads across a +social network when agents reason using an LLM versus fixed probability +rules. Models how different personality types (believers, skeptics, and +spreaders) produce group dynamics that no individual agent +was programmed to exhibit. + +## Overview + +Agents are placed on an Erdos-Renyi graph and exposed to a misinformation +claim. Each step, agents decide whether to believe and share the claim +based on their personality type and how many times they have +previously encountered it. LLM agents accumulate a decision history +across steps, allowing social pressure to build up over time. + +The key behavior is the S-curve effect: LLM-driven skeptics +initially resist the claim for several steps, but repeated exposure +gradually tips them toward belief. This pattern that emerges purely from +prompt context and not hard-coded thresholds. + +## Installation +```bash +git clone +cd misinformation_spread +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +**LLM requirement:** This model uses [Ollama](https://ollama.com) for +local LLM inference. Install Ollama and pull the model before running: +```bash +ollama pull llama3 +``` + +## Running the Model + +**Static comparison (2-panel chart):** +```bash +python run.py +``` + +**Interactive dashboard:** +```bash +solara run app.py +``` + +## Parameters + +- **n_believers** (default: 4) - number of believer agents. Tend to trust + and share information easily; belief strengthens with repeated exposure +- **n_skeptics** (default: 3) — number of skeptic agents. Initially + resistant; may gradually convince after many exposures +- **n_spreaders** (default: 2) — number of spreader agents. Share + everything to maximize engagement regardless of truth +- **connectivity** (default: 0.3) — probability of a connection between + any two agents in the Erdos-Renyi graph. Higher values produce denser + networks with faster spread +- **misinformation_claim** (default: cold water digestion claim) — the + claim being spread. Can be replaced with any string +- **rng** (default: 42) — random seed for reproducibility + +## Model Details + +**Agents:** + +- `BelieverAgent` — LLM agent that trusts information easily. Passes + times_exposed and full decision history into the prompt each step, + allowing accumulated exposure to strengthen belief over time. +- `SkepticAgent` — LLM agent that questions claims. Starts resistant but + the accumulation mechanism means repeated exposures gradually + reduce skepticism, producing delayed belief. +- `SpreaderAgent` — LLM agent that shares everything to maximize + engagement. Almost always says yes, reinforced by seeing the claim + spread further each step. +- `RuleBasedBeliever`, `RuleBasedSkeptic`, `RuleBasedSpreader` — identical + network structure but fixed probability thresholds (80%, 15%, 95%). + Used as a baseline to isolate the effect of LLM reasoning. + +**Architecture note:** + +`ReActReasoning.plan()` wraps all output in `reasoning:` / `action:` +fields regardless of prompt instructions, making structured two-line +responses unparsable. This model bypasses `ReActReasoning` entirely and +calls `litellm.completion()` directly, which produces clean +`BELIEVE: yes / SHARE: yes` output that can be reliably parsed. + +**Network:** + +Built using `networkx.erdos_renyi_graph` and wrapped in Mesa's `Network` +discrete space. Misinformation is seeded by broadcasting from all +spreaders to all agents at initialization. Each step, any agent with +`shared=True` propagates the claim to all other agents. + +**Social pressure mechanism:** + +Each agent tracks `times_exposed` and a `history` list of past decisions. +Both are passed into the LLM prompt each step so the model can reason +about change over time rather than making independent per-step decisions. + +## Output + +**run.py produces 2 panels:** +- Spread count over time for LLM vs rule-based agents +- Final convinced count by agent type (believers, skeptics, spreaders) + +**app.py produces an interactive dashboard with:** +- Spread Over Time: live line chart comparing LLM vs rule-based S-curves +- Final Counts: bar chart of convinced agents by type at end of run +- Raw Data: step-by-step dataframes for both models + +## Project Structure +``` +misinformation_spread/ +├── agents.py # LLM agent classes (Believer, Skeptic, Spreader) +├── model.py # MisinformationModel and RuleBasedMisinformationModel +├── rulebased_agents.py # Rule-based agent classes for baseline comparison +├── run.py # Run both models and generate static comparison chart +├── app.py # Interactive Solara dashboard +├── readme.md # This file +├── requirements.txt +├── tests/ +│ └── test_model.py # pytest suite (rule-based only, no LLM calls) +└── images/ # Output charts saved here +``` + +## References + +- Vosoughi, S. et al. (2018). The spread of true and false news online. + *Science.* diff --git a/examples/misinformation_spread/requirements.txt b/examples/misinformation_spread/requirements.txt new file mode 100644 index 00000000..f94fd476 --- /dev/null +++ b/examples/misinformation_spread/requirements.txt @@ -0,0 +1,10 @@ +mesa==3.5.0 +mesa-llm==0.2.0 +networkx==3.6.1 +litellm==1.82.0 +ollama==0.6.1 +solara==1.57.3 +matplotlib==3.10.8 +pandas==3.0.1 +pytest==9.0.2 +python-dotenv==1.2.2 \ No newline at end of file diff --git a/examples/misinformation_spread/rulebased_agents.py b/examples/misinformation_spread/rulebased_agents.py new file mode 100644 index 00000000..1717fbc1 --- /dev/null +++ b/examples/misinformation_spread/rulebased_agents.py @@ -0,0 +1,59 @@ +import mesa + + +class RuleBasedBeliever(mesa.Agent): + """ + Rule-based believer: believes and shares with fixed probability. + No LLM reasoning, used as baseline comparison against LLMAgent version. + """ + + def __init__(self, model, cell): + super().__init__(model) + self.cell = cell + self.believes = False + self.shared = False + + def step(self): + if self.random.random() < 0.8: + self.believes = True + if self.believes and self.random.random() < 0.8: + self.shared = True + self.model.spread_count += 1 + + +class RuleBasedSkeptic(mesa.Agent): + """ + Rule-based skeptic : rarely believes or shares. + """ + + def __init__(self, model, cell): + super().__init__(model) + self.cell = cell + self.believes = False + self.shared = False + + def step(self): + if self.random.random() < 0.15: + self.believes = True + if self.believes and self.random.random() < 0.25: + self.shared = True + self.model.spread_count += 1 + + +class RuleBasedSpreader(mesa.Agent): + """ + Rule-based spreader: almost always believes and shares. + """ + + def __init__(self, model, cell): + super().__init__(model) + self.cell = cell + self.believes = False + self.shared = False + + def step(self): + if self.random.random() < 0.95: + self.believes = True + if self.random.random() < 0.99: + self.shared = True + self.model.spread_count += 1 diff --git a/examples/misinformation_spread/run.py b/examples/misinformation_spread/run.py new file mode 100644 index 00000000..42979dc7 --- /dev/null +++ b/examples/misinformation_spread/run.py @@ -0,0 +1,128 @@ +import matplotlib.pyplot as plt +from model import MisinformationModel, RuleBasedMisinformationModel + +STEPS = 3 +SEED = 42 + +# Run rule-based model +print("Running rule-based model...") +rb_model = RuleBasedMisinformationModel( + n_believers=4, + n_skeptics=3, + n_spreaders=2, + connectivity=0.3, + rng=SEED, +) +for _ in range(STEPS): + rb_model.step() + +rb_data = rb_model.datacollector.get_model_vars_dataframe() +print("Rule-based model complete.") +print(rb_data) + +# Run LLM model +print("\nRunning LLM-driven model...") +print("Note: each step takes 10-30 seconds due to LLM calls\n") + +llm_model = MisinformationModel( + n_believers=4, + n_skeptics=3, + n_spreaders=2, + connectivity=0.3, + use_llm=True, + rng=SEED, +) + +for step in range(STEPS): + llm_model.step() + df = llm_model.datacollector.get_model_vars_dataframe() + believers = df["Believers Convinced"].iloc[-1] + skeptics = df["Skeptics Convinced"].iloc[-1] + spreaders = df["Spreaders Active"].iloc[-1] + print( + f"Step {step + 1} | " + f"Believers: {int(believers)} | " + f"Skeptics: {int(skeptics)} | " + f"Spreaders: {int(spreaders)}" + ) + +llm_data = llm_model.datacollector.get_model_vars_dataframe() +print("\nLLM model complete.") +print(llm_data) + + +def plot_comparison(llm_data, rb_data): + fig, axes = plt.subplots(1, 2, figsize=(12, 5)) + fig.suptitle( + "LLM vs Rule-Based Misinformation Spread", fontsize=14, fontweight="bold" + ) + + axes[0].plot( + llm_data.index, + llm_data["Spread Count"], + label="LLM agents", + color="steelblue", + linewidth=2, + marker="o", + ) + axes[0].plot( + rb_data.index, + rb_data["Spread Count"], + label="Rule-based agents", + color="coral", + linewidth=2, + marker="o", + linestyle="--", + ) + axes[0].set_title("Spread Count Over Time") + axes[0].set_xlabel("Step") + axes[0].set_ylabel("Cumulative Spread Count") + axes[0].legend() + axes[0].grid(True, linestyle="--", alpha=0.4) + + categories = ["Believers", "Skeptics", "Spreaders"] + llm_final = [ + llm_data["Believers Convinced"].iloc[-1], + llm_data["Skeptics Convinced"].iloc[-1], + llm_data["Spreaders Active"].iloc[-1], + ] + rb_final = [ + rb_data["Believers Convinced"].iloc[-1], + rb_data["Skeptics Convinced"].iloc[-1], + rb_data["Spreaders Active"].iloc[-1], + ] + + x = range(len(categories)) + width = 0.35 + axes[1].bar( + [i - width / 2 for i in x], + llm_final, + width, + label="LLM agents", + color="steelblue", + alpha=0.8, + ) + axes[1].bar( + [i + width / 2 for i in x], + rb_final, + width, + label="Rule-based agents", + color="coral", + alpha=0.8, + ) + axes[1].set_title("Final Convinced Count by Agent Type") + axes[1].set_xlabel("Agent Type") + axes[1].set_ylabel("Count") + axes[1].set_xticks(list(x)) + axes[1].set_xticklabels(categories) + axes[1].legend() + axes[1].grid(True, linestyle="--", alpha=0.4, axis="y") + + plt.tight_layout() + plt.savefig("images/comparison_results.png", dpi=150, bbox_inches="tight") + plt.show(block=False) + plt.pause(0.1) + print("\nPlot saved to images/comparison_results.png") + + +plot_comparison(llm_data, rb_data) diff --git a/examples/misinformation_spread/tests/test_model.py b/examples/misinformation_spread/tests/test_model.py new file mode 100644 index 00000000..014527ff --- /dev/null +++ b/examples/misinformation_spread/tests/test_model.py @@ -0,0 +1,172 @@ +import pytest +from model import RuleBasedMisinformationModel +from rulebased_agents import RuleBasedBeliever, RuleBasedSkeptic, RuleBasedSpreader + + +# Fixtures +@pytest.fixture +def small_model(): + """Small rule-based model for fast testing.""" + return RuleBasedMisinformationModel( + n_believers=3, + n_skeptics=2, + n_spreaders=1, + connectivity=0.3, + rng=42, + ) + + +@pytest.fixture +def default_model(): + """Default param rule-based model matching run.py test config.""" + return RuleBasedMisinformationModel( + n_believers=4, + n_skeptics=3, + n_spreaders=2, + connectivity=0.3, + rng=42, + ) + + +# Initialization +def test_model_initializes(small_model): + """Model creates without errors.""" + assert small_model is not None + + +def test_agent_counts(small_model): + """Correct number of each agent type is created.""" + believers = [a for a in small_model.agents if isinstance(a, RuleBasedBeliever)] + skeptics = [a for a in small_model.agents if isinstance(a, RuleBasedSkeptic)] + spreaders = [a for a in small_model.agents if isinstance(a, RuleBasedSpreader)] + + assert len(believers) == 3 + assert len(skeptics) == 2 + assert len(spreaders) == 1 + + +def test_total_agent_count(small_model): + """Total agent count matches n_believers + n_skeptics + n_spreaders.""" + assert len(list(small_model.agents)) == 6 + + +def test_spread_count_starts_at_zero(small_model): + """spread_count is 0 before any steps.""" + assert small_model.spread_count == 0 + + +def test_network_created(small_model): + """Network grid is created with correct number of nodes.""" + assert small_model.grid is not None + assert len(list(small_model.grid.all_cells)) == 6 + + +# DataCollector +def test_datacollector_keys(small_model): + """DataCollector has all expected reporter keys.""" + df = small_model.datacollector.get_model_vars_dataframe() + expected_keys = [ + "Spread Count", + "Believers Convinced", + "Skeptics Convinced", + "Spreaders Active", + ] + for key in expected_keys: + assert key in df.columns, f"Missing datacollector key: {key}" + + +def test_datacollector_initial_zeros(small_model): + """All datacollector values are 0 at step 0.""" + df = small_model.datacollector.get_model_vars_dataframe() + assert df["Spread Count"].iloc[0] == 0 + assert df["Believers Convinced"].iloc[0] == 0 + assert df["Skeptics Convinced"].iloc[0] == 0 + assert df["Spreaders Active"].iloc[0] == 0 + + +# Stepping +def test_dataframe_grows_with_steps(small_model): + """DataCollector adds one row per step.""" + for _ in range(3): + small_model.step() + df = small_model.datacollector.get_model_vars_dataframe() + assert len(df) == 4 # step 0 + 3 steps + + +def test_spread_count_nonnegative(small_model): + """spread_count never goes negative after stepping.""" + for _ in range(5): + small_model.step() + assert small_model.spread_count >= 0 + + +def test_spread_count_increases_after_steps(default_model): + """spread_count is greater than 0 after several steps (probabilistic agents).""" + for _ in range(5): + default_model.step() + assert default_model.spread_count > 0 + + +def test_believers_convinced_bounded(default_model): + """Believers Convinced never exceeds total number of believers.""" + for _ in range(5): + default_model.step() + df = default_model.datacollector.get_model_vars_dataframe() + assert df["Believers Convinced"].max() <= 4 + + +def test_skeptics_convinced_bounded(default_model): + """Skeptics Convinced never exceeds total number of skeptics.""" + for _ in range(5): + default_model.step() + df = default_model.datacollector.get_model_vars_dataframe() + assert df["Skeptics Convinced"].max() <= 3 + + +def test_spreaders_active_bounded(default_model): + """Spreaders Active never exceeds total number of spreaders.""" + for _ in range(5): + default_model.step() + df = default_model.datacollector.get_model_vars_dataframe() + assert df["Spreaders Active"].max() <= 2 + + +# Edge Cases +def test_zero_connectivity_initializes(): + """Model initializes cleanly with no edges in the network.""" + model = RuleBasedMisinformationModel( + n_believers=2, + n_skeptics=2, + n_spreaders=1, + connectivity=0.0, + rng=42, + ) + assert model is not None + assert len(list(model.agents)) == 5 + + +def test_custom_claim(): + """Model accepts a custom misinformation claim.""" + model = RuleBasedMisinformationModel( + n_believers=2, + n_skeptics=1, + n_spreaders=1, + misinformation_claim="The moon is made of cheese.", + rng=42, + ) + assert model.misinformation_claim == "The moon is made of cheese." + + +def test_reproducibility(): + """Same seed produces same spread_count after identical steps.""" + model_a = RuleBasedMisinformationModel( + n_believers=4, n_skeptics=3, n_spreaders=2, connectivity=0.3, rng=99 + ) + model_b = RuleBasedMisinformationModel( + n_believers=4, n_skeptics=3, n_spreaders=2, connectivity=0.3, rng=99 + ) + for _ in range(3): + model_a.step() + model_b.step() + + assert model_a.spread_count == model_b.spread_count