Skip to content

Commit 321a851

Browse files
committed
virus antibody model
1 parent 7cac1a8 commit 321a851

File tree

11 files changed

+617
-0
lines changed

11 files changed

+617
-0
lines changed

examples/virus_antibody/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Virus-Antibody Model
2+
3+
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 imune 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.
4+
5+
6+
**It showcases :**
7+
- **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)
8+
- **Agent knowledge sharing** : the antibodies are able to share short term memory)
9+
- **Usage of weak referencing** to avoid coding errors (antibodies can store viruses in a `self.target` attribute)
10+
- Emergence of completely **different outcomes** with only small changes in parameters
11+
12+
13+
For example, with a given set of fixed parameters :
14+
| Virus mutation rate = 0.15 (antibodies win) | Virus mutation rate = 0.2 (viruses win) |
15+
|--------------------------------------------------|--------------------------------------------------|
16+
| ![](images/antibodies_win.png) | ![](images/viruses_win.png) |
17+
18+
19+
20+
21+
## How It Works
22+
23+
1. **Initialization**: The model initializes a population of viruses and antibodies in a continuous 2D space.
24+
2. **Agent Behavior**:
25+
- Antibodies move randomly until they detect a virus within their sight range (becomes purple), than pursue the virus.
26+
- Antibodies pass on all the virus DNA in their short term memory to the nearest antibodies (cf. example)
27+
- Viruses move randomly and can duplicate or mutate.
28+
3. **Engagement (antibody vs virus)**: When an antibody encounters a virus:
29+
- If the antibody has the virus's DNA in its memory, it destroys the virus.
30+
- Otherwise, the virus may defeat the antibody, causing it to lose health or become inactive temporarily.
31+
4. **Duplication**: Antibodies and viruses can duplicate according to their duplication rate.
32+
33+
34+
> Example for memory transmission : Let's look at two antibodies A1 and A2
35+
> `A1.st_memory() = [ABC]` and `A1.lt_memory() = [ABC]`
36+
> `A2.st_memory() = [DEF]` and `A2.lt() = [DEF]`
37+
>
38+
> After A1 encounters A2,
39+
> `A1.st_memory() = [DEF]` and `A1.lt() = [ABC, DEF]`
40+
> `A2.st_memory() = [ABC]` and `A1.lt() = [DEF, ABC]`
41+
>
42+
> A1 and A2 'switched' short term memory but both have the two viruses DNA in their long term memory
43+
44+
For further details, here is the full architecture of this model :
45+
46+
<div align="center">
47+
<img src="images/virus_antibody_architecture.png" width="550"/>
48+
</div>
49+
50+
## Usage
51+
52+
After cloning the repo and installing mesa on pip, run the application with :
53+
```bash
54+
solara run app.py
55+
```
56+
57+
## A couple more of interesting cases
58+
59+
| An interesting tendency inversion | high duplication + high mutation = both grow (more viruses) | high duplication + low mutation = both grow (more antibodies) |
60+
|---|---|---|
61+
| <img src="images/pattern.png" width="550"/> | <img src="images/grow_virus_wins.png" width="450"/> | <img src="images/grow_antibody_wins.png" width="450"/> |

examples/virus_antibody/agents.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""
2+
Mesa implementation of Virus/Antibody model: Agents module.
3+
"""
4+
5+
import copy
6+
import os
7+
import sys
8+
import weakref
9+
from collections import deque
10+
11+
import numpy as np
12+
13+
sys.path.insert(0, os.path.abspath("../../mesa"))
14+
from mesa.experimental.continuous_space import ContinuousSpaceAgent
15+
16+
17+
class AntibodyAgent(ContinuousSpaceAgent):
18+
"""An Antibody agent. They move randomly until they see a virus, go fight it.
19+
If they lose, stay KO for a bit, lose health and back to random moving.
20+
"""
21+
22+
def __init__(
23+
self,
24+
model,
25+
space,
26+
sight_range,
27+
duplication_rate,
28+
ko_timeout,
29+
memory_capacity,
30+
initial_position=(0, 0),
31+
direction=(1, 1),
32+
):
33+
super().__init__(model=model, space=space)
34+
35+
# Movement & state
36+
self.position = initial_position
37+
self.speed = 1.5
38+
self.direction = np.array(direction, dtype=float)
39+
40+
# Characteristics
41+
self.sight_range = sight_range
42+
self.health = 2
43+
self.duplication_rate = duplication_rate
44+
45+
# Memory
46+
self.st_memory: deque = deque()
47+
self.lt_memory: list = []
48+
self.memory_capacity = memory_capacity
49+
50+
# Target & KO state
51+
self.target = None # will hold a weakref.ref or None
52+
self.ko_timeout = ko_timeout
53+
self.ko_steps_left = 0
54+
55+
def step(self):
56+
if self is None:
57+
return
58+
59+
# Acquire a virus target if we don't already have one
60+
if self.target is None:
61+
closest = self.find_closest_virus()
62+
if closest:
63+
self.target = weakref.ref(closest)
64+
65+
# Communicate and maybe duplicate
66+
self.communicate()
67+
if self.random.random() < self.duplication_rate:
68+
self.duplicate()
69+
70+
# Then move
71+
self.move()
72+
73+
def find_closest_virus(self):
74+
agents, _ = self.space.get_agents_in_radius(self.position, self.sight_range)
75+
viruses = [a for a in agents if isinstance(a, VirusAgent)]
76+
return viruses[0] if viruses else None
77+
78+
def communicate(self) -> bool:
79+
agents, _ = self.space.get_agents_in_radius(self.position, self.sight_range)
80+
peers = [
81+
a
82+
for a in agents
83+
if isinstance(a, AntibodyAgent) and a.unique_id != self.unique_id
84+
]
85+
if not peers:
86+
return False
87+
88+
for other in peers:
89+
to_share = [
90+
dna for dna in self.st_memory if dna and dna not in other.lt_memory
91+
]
92+
if to_share:
93+
other.st_memory.extend(to_share)
94+
other.lt_memory.extend(to_share)
95+
while len(other.st_memory) > self.memory_capacity:
96+
other.st_memory.popleft()
97+
return True
98+
99+
def duplicate(self):
100+
clone = AntibodyAgent(
101+
self.model,
102+
self.space,
103+
sight_range=self.sight_range,
104+
duplication_rate=self.duplication_rate,
105+
ko_timeout=self.ko_timeout,
106+
memory_capacity=self.memory_capacity,
107+
initial_position=self.position,
108+
direction=self.direction,
109+
)
110+
# Copy over memory
111+
clone.st_memory = deque(item for item in self.st_memory if item)
112+
clone.lt_memory = [item for item in self.lt_memory if item]
113+
clone.target = None
114+
clone.ko_steps_left = 0
115+
116+
self.model.antibodies_set.add(clone)
117+
118+
def move(self):
119+
# If we've been removed from the space, bail out
120+
if getattr(self, "space", None) is None:
121+
return
122+
123+
# Dereference weakref if needed
124+
target = (
125+
self.target()
126+
if isinstance(self.target, weakref.ReferenceType)
127+
else self.target
128+
)
129+
130+
new_pos = None
131+
132+
# KO state: target refers back to self
133+
if target is self:
134+
self.ko_steps_left -= 1
135+
if self.ko_steps_left <= 0:
136+
self.target = None
137+
138+
# Random walk if no target
139+
elif target is None:
140+
perturb = np.array(
141+
[
142+
self.random.uniform(-0.5, 0.5),
143+
self.random.uniform(-0.5, 0.5),
144+
]
145+
)
146+
self.direction = self.direction + perturb
147+
norm = np.linalg.norm(self.direction)
148+
if norm > 0:
149+
self.direction /= norm
150+
new_pos = self.position + self.direction * self.speed
151+
152+
# Chase a valid virus target
153+
else:
154+
if getattr(target, "space", None) is not None:
155+
vec = np.array(target.position) - np.array(self.position)
156+
dist = np.linalg.norm(vec)
157+
if dist > self.speed:
158+
self.direction = vec / dist
159+
new_pos = self.position + self.direction * self.speed
160+
else:
161+
self.engage_virus(target)
162+
else:
163+
self.target = None
164+
165+
if new_pos is not None:
166+
self.position = new_pos
167+
168+
def engage_virus(self, virus) -> str:
169+
# If it's already gone
170+
if virus not in self.model.agents:
171+
self.target = None
172+
return "no_target"
173+
174+
dna = copy.deepcopy(virus.dna)
175+
if dna in self.st_memory or dna in self.lt_memory:
176+
virus.remove()
177+
self.target = None
178+
return "win"
179+
else:
180+
# KO (or death)
181+
self.health -= 1
182+
if self.health <= 0:
183+
self.remove()
184+
return "dead"
185+
186+
self.st_memory.append(dna)
187+
self.lt_memory.append(dna)
188+
self.ko_steps_left = self.ko_timeout
189+
# mark KO state by weak-ref back to self
190+
self.target = weakref.ref(self)
191+
return "ko"
192+
193+
194+
class VirusAgent(ContinuousSpaceAgent):
195+
"""A virus agent: random movement, mutation, duplication, passive to antibodies."""
196+
197+
def __init__(
198+
self,
199+
model,
200+
space,
201+
mutation_rate,
202+
duplication_rate,
203+
position=(0, 0),
204+
dna=None,
205+
):
206+
super().__init__(model=model, space=space)
207+
208+
self.position = position
209+
self.mutation_rate = mutation_rate
210+
self.duplication_rate = duplication_rate
211+
self.speed = 1
212+
self.direction = np.array((1, 1), dtype=float)
213+
self.dna = dna if dna is not None else self.generate_dna()
214+
215+
def step(self):
216+
# If already removed from the space, don't do anything
217+
if getattr(self, "space", None) is None:
218+
return
219+
if self.random.random() < self.duplication_rate:
220+
self.duplicate()
221+
self.move()
222+
223+
def duplicate(self):
224+
clone = VirusAgent(
225+
self.model,
226+
self.space,
227+
mutation_rate=self.mutation_rate,
228+
duplication_rate=self.duplication_rate,
229+
position=self.position,
230+
dna=self.generate_dna(self.dna),
231+
)
232+
self.model.viruses_set.add(clone)
233+
234+
def generate_dna(self, dna=None):
235+
if dna is None:
236+
return [self.random.randint(0, 9) for _ in range(3)]
237+
idx = self.random.randint(0, 2)
238+
chance = self.random.random()
239+
if chance < self.mutation_rate / 2:
240+
dna[idx] = (dna[idx] + 1) % 10
241+
elif chance < self.mutation_rate:
242+
dna[idx] = (dna[idx] - 1) % 10
243+
return dna
244+
245+
def move(self):
246+
if getattr(self, "space", None) is None:
247+
return
248+
249+
# Random walk
250+
perturb = np.array(
251+
[
252+
self.random.uniform(-0.5, 0.5),
253+
self.random.uniform(-0.5, 0.5),
254+
]
255+
)
256+
self.direction = self.direction + perturb
257+
norm = np.linalg.norm(self.direction)
258+
if norm > 0:
259+
self.direction /= norm
260+
261+
# Step
262+
self.position = self.position + self.direction * self.speed

0 commit comments

Comments
 (0)