diff --git a/examples/virus_antibody/README.md b/examples/virus_antibody/README.md new file mode 100644 index 00000000..ca918b0c --- /dev/null +++ b/examples/virus_antibody/README.md @@ -0,0 +1,61 @@ +# Virus-Antibody Model + +This model is a simulation of immune reaction declined as a confrontation between antibody agents and virus agents. The global idea is to model how the immune system can struggle against new virus but is able to adapt over time and beat a same virus if it comes back. The results are quite interesting as the simulation can go both ways (virus win or antibodies win) with a little tweak in the base parameters. + + +**It showcases :** +- **Usage of memory in agents** : divided into a short term memory using a deque to easily add and remove memories in case of a new virus encounter, and a long term memory (here a simple list) +- **Agent knowledge sharing** : the antibodies are able to share short term memory) +- **Usage of weak referencing** to avoid coding errors (antibodies can store viruses in a `self.target` attribute) +- Emergence of completely **different outcomes** with only small changes in parameters + + +For example, with a given set of fixed parameters : +| Virus mutation rate = 0.15 (antibodies win) | Virus mutation rate = 0.2 (viruses win) | +|--------------------------------------------------|--------------------------------------------------| +| ![](images/antibodies_win.png) | ![](images/viruses_win.png) | + + + + +## How It Works + +1. **Initialization**: The model initializes a population of viruses and antibodies in a continuous 2D space. +2. **Agent Behavior**: + - Antibodies move randomly until they detect a virus within their sight range (becomes purple), than pursue the virus. + - Antibodies pass on all the virus DNA in their short term memory to the nearest antibodies (cf. example) + - Viruses move randomly and can duplicate or mutate. +3. **Engagement (antibody vs virus)**: When an antibody encounters a virus: + - If the antibody has the virus's DNA in its memory, it destroys the virus. + - Otherwise, the virus may defeat the antibody, causing it to lose health or become inactive temporarily. +4. **Duplication**: Antibodies and viruses can duplicate according to their duplication rate. + + +> Example for memory transmission : Let's look at two antibodies A1 and A2 +> `A1.st_memory() = [ABC]` and `A1.lt_memory() = [ABC]` +> `A2.st_memory() = [DEF]` and `A2.lt() = [DEF]` +> +> After A1 encounters A2, +> `A1.st_memory() = [DEF]` and `A1.lt() = [ABC, DEF]` +> `A2.st_memory() = [ABC]` and `A1.lt() = [DEF, ABC]` +> +> A1 and A2 'switched' short term memory but both have the two viruses DNA in their long term memory + +For further details, here is the full architecture of this model : + +
+ +
+ +## Usage + +After cloning the repo and installing mesa on pip, run the application with : +```bash + solara run app.py +``` + +## A couple more of interesting cases + +| An interesting tendency inversion | high duplication + high mutation = both grow (more viruses) | high duplication + low mutation = both grow (more antibodies) | +|---|---|---| +| | | | diff --git a/examples/virus_antibody/agents.py b/examples/virus_antibody/agents.py new file mode 100644 index 00000000..42273a2a --- /dev/null +++ b/examples/virus_antibody/agents.py @@ -0,0 +1,218 @@ +""" +Mesa implementation of Virus/Antibody model: Agents module. +""" + +import copy +import os +import sys +import weakref +from collections import deque + +import numpy as np + +sys.path.insert(0, os.path.abspath("../../../mesa")) +from mesa.experimental.continuous_space import ContinuousSpaceAgent + + +class CellularAgent(ContinuousSpaceAgent): + def _random_move(self, speed=1): + """Random walk in a 2D space.""" + perturb = np.array( + [ + self.random.uniform(-0.5, 0.5), + self.random.uniform(-0.5, 0.5), + ] + ) + self.direction = self.direction + perturb + norm = np.linalg.norm(self.direction) + if norm > 0: + self.direction /= norm + self.position += self.direction * speed + + +class AntibodyAgent(CellularAgent): + """An Antibody agent. They move randomly until they see a virus, go fight it. + If they lose, stay KO for a bit, lose health and back to random moving. + """ + + speed = 1.5 + sight_range = 10 + ko_timeout = 15 + memory_capacity = 3 + health = 2 + + def __init__( + self, + model, + space, + duplication_rate, + initial_position=(0, 0), + direction=(1, 1), + ): + super().__init__(model=model, space=space) + + # Movement & characteristics + self.position = initial_position + self.direction = np.array(direction, dtype=float) + self.duplication_rate = duplication_rate + + # Memory + self.st_memory: deque = deque(maxlen=self.memory_capacity) + self.lt_memory: list = [] + + # Target & KO state + self.target = None # will hold a weakref.ref or None + self.ko_steps_left = 0 + + def step(self): + nearby_agents, _ = self.space.get_agents_in_radius( + self.position, self.sight_range + ) + nearby_viruses = [a for a in nearby_agents if isinstance(a, VirusAgent)] + nearby_antibodies = [ + a + for a in nearby_agents + if isinstance(a, AntibodyAgent) and a.unique_id != self.unique_id + ] + + # Acquire a virus target if we don't already have one + if self.target is None and nearby_viruses: + closest = nearby_viruses[0] + self.target = weakref.ref(closest) + + # Communicate and maybe duplicate + self.communicate(nearby_antibodies) + if self.random.random() < self.duplication_rate: + self.duplicate() + + # Then move + self.move() + + def communicate(self, nearby_antibodies) -> bool: + for other in nearby_antibodies: + to_share = [ + dna for dna in self.st_memory if dna and dna not in other.lt_memory + ] + if to_share: + other.st_memory.extend(to_share) + other.lt_memory.extend(to_share) + return True + + def duplicate(self): + clone = AntibodyAgent( + self.model, + self.space, + duplication_rate=self.duplication_rate, + initial_position=self.position, + direction=self.direction, + ) + # Copy over memory + clone.st_memory = deque(maxlen=self.memory_capacity) + clone.st_memory.extend([item for item in self.st_memory if item]) + clone.lt_memory = [item for item in self.lt_memory if item] + clone.target = None + clone.ko_steps_left = 0 + + def move(self): + # Dereference weakref if needed + target = ( + self.target() + if isinstance(self.target, weakref.ReferenceType) + else self.target + ) + + new_pos = None + + # KO state: target refers back to self + if target is self: + self.ko_steps_left -= 1 + if self.ko_steps_left <= 0: + self.target = None + + # Random walk if no target + elif target is None: + self._random_move() + + # Chase a valid virus target + else: + if getattr(target, "space", None) is not None: + vec = np.array(target.position) - np.array(self.position) + dist = np.linalg.norm(vec) + if dist > self.speed: + self.direction = vec / dist + new_pos = self.position + self.direction * self.speed + else: + self.engage_virus(target) + else: + self.target = None + + if new_pos is not None: + self.position = new_pos + + def engage_virus(self, virus) -> str: + dna = copy.deepcopy(virus.dna) + if dna in self.st_memory or dna in self.lt_memory: + virus.remove() + self.target = None + + else: + # KO (or death) + self.health -= 1 + if self.health <= 0: + self.remove() + + self.st_memory.append(dna) + self.lt_memory.append(dna) + self.ko_steps_left = self.ko_timeout + # mark KO state by weak-ref back to self + self.target = weakref.ref(self) + return "ko" + + +class VirusAgent(CellularAgent): + """A virus agent: random movement, mutation, duplication, passive to antibodies.""" + + speed = 1 + + def __init__( + self, + model, + space, + mutation_rate, + duplication_rate, + position=(0, 0), + dna=None, + ): + super().__init__(model=model, space=space) + + self.position = position + self.mutation_rate = mutation_rate + self.duplication_rate = duplication_rate + self.direction = np.array((1, 1), dtype=float) + self.dna = dna if dna is not None else self.generate_dna() + + def step(self): + if self.random.random() < self.duplication_rate: + self.duplicate() + self._random_move() + + def duplicate(self): + VirusAgent( + self.model, + self.space, + mutation_rate=self.mutation_rate, + duplication_rate=self.duplication_rate, + position=self.position, + dna=self.generate_dna(self.dna), + ) + + def generate_dna(self, dna=None): + if dna is None: + return [self.random.randint(0, 9) for _ in range(3)] + idx = self.random.randint(0, 2) + chance = self.random.random() + if chance < self.mutation_rate / 2: + dna[idx] = (dna[idx] + 1) % 10 + elif chance < self.mutation_rate: + dna[idx] = (dna[idx] - 1) % 10 + return dna diff --git a/examples/virus_antibody/app.py b/examples/virus_antibody/app.py new file mode 100644 index 00000000..28ce536f --- /dev/null +++ b/examples/virus_antibody/app.py @@ -0,0 +1,137 @@ +import os +import sys +import weakref + +from agents import AntibodyAgent, VirusAgent +from matplotlib.markers import MarkerStyle +from model import VirusAntibodyModel + +sys.path.insert(0, os.path.abspath("../../../mesa")) + +from mesa.experimental.devs import ABMSimulator +from mesa.visualization import ( + Slider, + SolaraViz, + make_plot_component, + make_space_component, +) + +# Style and portrayals +MARKER_CACHE = {} +MARKER_CACHE["antibody"] = MarkerStyle("o") +MARKER_CACHE["virus"] = MarkerStyle("*") + + +def agent_portrayal(agent): + """Portray an agent for visualization.""" + portrayal = {} + + if isinstance(agent, AntibodyAgent): + portrayal["marker"] = MARKER_CACHE["antibody"] + portrayal["size"] = 30 + + if isinstance(agent.target, weakref.ReferenceType): + target_obj = agent.target() # dereference the weakref + else: + target_obj = agent.target + + if target_obj == agent: + # gray if ko + portrayal["color"] = "gray" + portrayal["layer"] = 2 + + elif target_obj is None: + # Blue if moving + portrayal["color"] = "blue" + portrayal["layer"] = 1 + + else: + # Purple if aiming for virus + portrayal["color"] = "purple" + portrayal["layer"] = 1 + + elif isinstance(agent, VirusAgent): + portrayal["marker"] = MARKER_CACHE["virus"] + portrayal["size"] = 50 + portrayal["color"] = "red" + portrayal["filled"] = True + portrayal["layer"] = 0 + + return portrayal + + +# Setup model parameters for the visualization interface +simulator = ABMSimulator() +model = VirusAntibodyModel() + +model_params = { + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "initial_antibody": Slider( + label="Number of antibodies", + value=20, + min=1, + max=50, + step=1, + ), + "antibody_duplication_rate": Slider( + label="Rate of duplication for antibodies", + value=0.01, + min=0, + max=0.05, + step=0.001, + ), + "initial_viruses": Slider( + label="Number of viruses", + value=20, + min=1, + max=50, + step=1, + ), + "virus_duplication_rate": Slider( + label="Rate of duplication for viruses", + value=0.01, + min=0, + max=0.05, + step=0.001, + ), + "virus_mutation_rate": Slider( + label="Rate of mutation for viruses", + value=0.05, + min=0, + max=0.3, + step=0.01, + ), +} + + +# Visualization and plots + + +def post_process_lines(ax): + ax.legend(loc="center left", bbox_to_anchor=(1, 0.9)) + + +agents_lineplot_component = make_plot_component( + {"Antibodies": "tab:blue", "Viruses": "tab:red"}, + post_process=post_process_lines, +) + +agent_portrayal_component = make_space_component( + agent_portrayal=agent_portrayal, backend="matplotlib" +) + +page = SolaraViz( + model, + components=[ + agent_portrayal_component, + agents_lineplot_component, + ], + model_params=model_params, + name="Virus/Antibody", +) + +page # noqa diff --git a/examples/virus_antibody/images/antibodies_win.png b/examples/virus_antibody/images/antibodies_win.png new file mode 100644 index 00000000..089fadd7 Binary files /dev/null and b/examples/virus_antibody/images/antibodies_win.png differ diff --git a/examples/virus_antibody/images/grow_antibody_wins.png b/examples/virus_antibody/images/grow_antibody_wins.png new file mode 100644 index 00000000..a0fc9081 Binary files /dev/null and b/examples/virus_antibody/images/grow_antibody_wins.png differ diff --git a/examples/virus_antibody/images/grow_virus_wins.png b/examples/virus_antibody/images/grow_virus_wins.png new file mode 100644 index 00000000..277f4bf4 Binary files /dev/null and b/examples/virus_antibody/images/grow_virus_wins.png differ diff --git a/examples/virus_antibody/images/pattern.png b/examples/virus_antibody/images/pattern.png new file mode 100644 index 00000000..0854f003 Binary files /dev/null and b/examples/virus_antibody/images/pattern.png differ diff --git a/examples/virus_antibody/images/virus_antibody_architecture.png b/examples/virus_antibody/images/virus_antibody_architecture.png new file mode 100644 index 00000000..674634ae Binary files /dev/null and b/examples/virus_antibody/images/virus_antibody_architecture.png differ diff --git a/examples/virus_antibody/images/viruses_win.png b/examples/virus_antibody/images/viruses_win.png new file mode 100644 index 00000000..0a789ee1 Binary files /dev/null and b/examples/virus_antibody/images/viruses_win.png differ diff --git a/examples/virus_antibody/model.py b/examples/virus_antibody/model.py new file mode 100644 index 00000000..82bf9810 --- /dev/null +++ b/examples/virus_antibody/model.py @@ -0,0 +1,143 @@ +""" +Virus/Antibody Model +=================== +A mesa implementation of the Virus/Antibody model, where antibodies and viruses interact in a continuous space. +""" + +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../mesa")) + +import numpy as np +from agents import AntibodyAgent, VirusAgent +from mesa import Model +from mesa.datacollection import DataCollector +from mesa.experimental.continuous_space import ContinuousSpace + + +class VirusAntibodyModel(Model): + """ + Virus/Antibody model. + + Handles agent creation, placement and scheduling. + """ + + def __init__( + # General parameters + self, + seed=None, + initial_antibody=20, + initial_viruses=20, + width=100, + height=100, + # Antibody parameters + antibody_duplication_rate=0.01, + # Virus parameters + virus_duplication_rate=0.01, + virus_mutation_rate=0.01, + ): + """Create a new Virus/Antibody model. + + Args: + seed: Random seed for reproducibility + initial_antibody: Number of Antibodies in the simulation + initial_viruses: Number of viruses in the simulation + width: Width of the space + height: Height of the space + antibody_duplication_rate: Probability of duplication for antibodies + virus_duplication_rate: Probability of duplication for viruses + virus_mutation_rate: Probability of mutation for viruses + + Indirect Args (not chosen in the graphic interface for clarity reasons): + antibody_memory_capacity: Number of virus DNA an antibody can remember + antibody_ko_timeout : Number of step after which an antibody can move after a KO + + """ + + super().__init__(seed=seed) + + # Model parameters + self.initial_antibody = initial_antibody + self.initial_viruses = initial_viruses + self.width = width + self.height = height + + # antibody parameters + self.antibody_duplication_rate = antibody_duplication_rate + + # virus parameters + self.virus_duplication_rate = virus_duplication_rate + self.virus_mutation_rate = virus_mutation_rate + + # Statistics + self.antibodies_killed = 0 + self.virus_killed = 0 + self.running = True + + # Set up data collection + model_reporters = { + "Antibodies": lambda m: len(m.agents_by_type[AntibodyAgent]), + "Viruses": lambda m: len(m.agents_by_type[VirusAgent]), + } + + self.datacollector = DataCollector(model_reporters=model_reporters) + + # Set up the space + self.space = ContinuousSpace( + [[0, width], [0, height]], + torus=True, # change to false and make checks (currently fails) + random=self.random, + # n_agents=initial_antibody + initial_viruses, + ) + + # Create and place the Antibody agents + antibodies_positions = self.rng.random( + size=(self.initial_antibody, 2) + ) * np.array(self.space.size) + directions = self.rng.uniform(-1, 1, size=(self.initial_antibody, 2)) + AntibodyAgent.create_agents( + self, + self.initial_antibody, + self.space, + initial_position=antibodies_positions, + direction=directions, + duplication_rate=self.antibody_duplication_rate, + ) + + # Create and place the Virus agents + dna = [self.random.randint(0, 9) for _ in range(3)] + viruses_positions = self.rng.random(size=(self.initial_viruses, 2)) * np.array( + self.space.size + ) + directions = self.rng.uniform(-1, 1, size=(self.initial_viruses, 2)) + + VirusAgent.create_agents( + self, + self.initial_viruses, + self.space, + position=viruses_positions, + duplication_rate=self.virus_duplication_rate, + mutation_rate=self.virus_mutation_rate, + dna=dna, + ) + + self.datacollector.collect(self) + + def step(self): + """Run one step of the model.""" + self.agents.shuffle_do("step") + self.datacollector.collect(self) + + if ( + len(self.agents_by_type[AntibodyAgent]) > 200 + or len(self.agents_by_type[VirusAgent]) > 200 + ): + print("Too many agents, stopping the simulation") + self.running = False + elif len(self.agents_by_type[AntibodyAgent]) == 0: + self.running = False + print("All antibodies are dead") + elif len(self.agents_by_type[VirusAgent]) == 0: + self.running = False + print("All viruses are dead") diff --git a/examples/virus_antibody/requirements.txt b/examples/virus_antibody/requirements.txt new file mode 100644 index 00000000..ef30d9d3 --- /dev/null +++ b/examples/virus_antibody/requirements.txt @@ -0,0 +1,4 @@ +mesa>=2.1.1 +numpy>=1.24.0 +matplotlib>=3.7.0 +solara>=1.20.0 \ No newline at end of file