All right, lets get down to it, I am genuinely curious how many variables I can add to a simulation… Coded myself… Using Python, Sqlite, Matplotlib, and have it continue for a lengthy period of time… How much? you might ask. The answer is, of course, AS MUCH AS WE CAN GET! ONWARD!
To start with, I think I will use the tutorial here to get my feet wet on animated graphs with Matplotlib, because who wants to watch the statistics of your simulation in snapshots? (Not this guy)
We also need to think about data storage, I think (getting the objects that will be my actors/variables to behave in a semi ‘normal’ way). In the short term, we can stick with just local variables like lists and dictionaries. However, reaching any further than even just a couple of variables we, I think, should implement a Sqlite back end to store the information. This will make retrieval and multi-threaded things easier I think.
Let’s also define when the simulation is ‘broken’ and should be ‘stopped’… Let’s say:
- When any one variable climbs way beyond any other.
- When all variables are ‘dead.’
- When a ‘steady state’ is achieved.
- Meaning we lose interest in the animated graph because it no longer seems interesting… Though a steady state is kind of fun to think about.
- Like, how? You know? How is everything 1 to 1 like that? Idk…
- Meaning we lose interest in the animated graph because it no longer seems interesting… Though a steady state is kind of fun to think about.
Time to code!
# -*- coding: utf-8 -*-
"""
Created on Tue Jan 11 18:13:50 2022
@author: Travis Adsitt
"""
from dataclasses import dataclass
from enum import Enum
import matplotlib.pyplot as plt
import random
GESTATION_TIME = 9
LIFE_TIME = 876
class Gender(Enum):
Male = "Male"
Female = "Female"
@dataclass(init=True)
class WorldTraits:
population: list
time: int
class World:
def __init__(self, population):
assert isinstance(population, int), "population must be of type 'int'"
self.traits = WorldTraits(
population=[],
time=0
)
self.traits.population = [Person(self, 0.0) for p in range(0, population)]
def birth(self):
"""
Helper function for the Person object to inform the World that they have
had a child.
Returns
-------
None.
"""
# Create a new person
new_peep = Person(self, 0.0)
# Add them to the population
self.traits.population.append(new_peep)
def attempt_mate(self, person_one, person_two):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
# Check if they are female
one_kids = person_one.traits.gender == Gender.Female
two_kids = person_two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
person_one.attempt_to_impregnate(self.traits.time)
else:
person_two.attempt_to_impregnate(self.traits.time)
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
def tick_time(self):
"""
Helper function to push the world forward one month at a time.
Returns
-------
None.
"""
# Tick time on all people
for person in self.traits.population:
person.tick_time(self.traits.time)
# Try and mate
self.reproduction_cycle()
# Tick our time forward
self.traits.time += 1
@dataclass(init=True)
class PersonTraits:
alive: bool
ispregnant: bool
pregnancystart: int
age: int
gender: Gender
money: float
class Person:
def __init__(self, world, money):
assert isinstance(world, World), "world, must be of type 'World'"
assert isinstance(money, float), "money must be of type 'float'"
# Set our world object
self.world = world
# Set our initial 'last_time'
self.last_time = self.world.traits.time
# Set our initial traits
self.traits = PersonTraits(
alive=True,
ispregnant=False,
pregnancystart=None,
age=0,
gender=Gender[random.choice([g.name for g in Gender])],
money=money
)
def spend_money(self, amount):
"""
Used to check if this person can spend money, and if so, does spend
the amount passed in.
Parameters
----------
amount : float
How much money should I spend?
Returns
-------
bool
If I spent the money
"""
spent = False
if(self.money >= amount):
self.money -= amount
spent = True
return spent
def advance_automatic_tickers(self, time):
"""
Helper function to advance the automatic tickers for a person, such as
age or checking for a pregnancy... Those sorts of things.
Parameters
----------
time : int
The current time in the world.
Returns
-------
None.
"""
# We get older
self.traits.age += time - self.last_time
# We sometimes die
if(self.traits.age > 876):
self.traits.alive = False
# Sometimes people give birth
if self.traits.ispregnant and ((time - self.traits.pregnancystart) > GESTATION_TIME):
self.world.birth()
self.traits.ispregnant = False
self.traits.pregnancystart = None
def wants_childeren(self):
"""
For the world to call when determining if a pregnancy should happen.
Returns
-------
bool
If I want childeren
"""
# ~16 to ~35 years old
wants_childeren = self.traits.age > 192 and self.traits.age < 420
# Can't have childeren if we already have them
wants_childeren = not self.traits.ispregnant and wants_childeren
return wants_childeren
def attempt_to_impregnate(self, time):
"""
Impregnate this person(female)
Returns
-------
None.
"""
if self.traits.gender == Gender.Female and self.wants_childeren() and self.traits.alive:
self.traits.ispregnant = True
self.traits.pregnancystart = time
def tick_time(self, time):
"""
A 'behavior' function to encapsulate a decision point for this person.
Parameters
----------
time : int
The current time tick of the world.
Returns
-------
None.
"""
self.advance_automatic_tickers(time)
self.last_time = time
def get_plot_vars(world):
men = 0
women = 0
alive = 0
dead = 0
for person in world.traits.population:
if person.traits.alive:
alive += 1
if person.traits.gender == Gender.Female:
women += 1
else:
men += 1
else:
dead += 1
return (women, men, alive, dead)
if __name__ == "__main__":
new_world = World(1000)
women_list = []
men_list = []
alive_list = []
dead_list = []
time_list = []
for time in range(0,100000):
new_world.tick_time()
women, men, alive, dead = get_plot_vars(new_world)
women_list.append(women)
men_list.append(men)
alive_list.append(alive)
dead_list.append(dead)
time_list.append(time)
plt.plot(time_list, women_list, label="Women")
plt.plot(time_list, men_list, label="Men")
plt.plot(time_list, alive_list, label="Alive")
plt.plot(time_list, dead_list, label="Dead")
plt.draw()
plt.pause(0.05)
plt.show()
Ok, so this code represents the base system for a world and people to ‘exist.’ In this world people can reproduce and die. After a few minutes of thinking, maybe even less, you’ll likely ask me — but Travis, won’t this just be an exponential increase in population? Didn’t we agree that that was a ‘broken’ simulation?
Yes. This is just the base, and unfortunately in order to balance this out, we will need to introduce something that kills people… That we will have to find out tomorrow, because I need sleep now. To keep you company while I am away, here is an animated image of our exponential growth!
Label your graph, Travis! the teacher screams. Yeah, yeah, I will next time. The x-axis represents time in months, the blue/orange lines are one of the two genders, the green line is the total alive, and the red line is dead (which might be broken).
END DAY 1
Thoughts
Ok, I have been gone a day and had a chance to think about this a bit, so to start I am simply going to clean up any magic numbers I have lingering and place them at the top as constants.
Along with this I am adding a ‘DIVIDER’ variable to cut down things to a reasonable scale for short-term experiments. The new variables can be seen below:
DIVIDER = 50
REPRODUCTIVE_AGE_START = int(192 / DIVIDER)
REPRODUCTIVE_AGE_END = int(420 / DIVIDER)
GESTATION_TIME_MONTHS = int(9 / DIVIDER)
LIFE_TIME_MONTHS = int(876 / DIVIDER)
With these variables installed throughout the code, it becomes much easier to adjust things and see how they change the graph. For instance, using the settings above we get the following graph:
Which is quite exciting, considering we are trying to “balance” our simulation. I am thinking it would be a good idea to change the way we count deaths, that is, instead of counting total deaths, we count deaths in the last cycle. To do this, we simply need to keep a running value of total deaths and subtract it each time from the previous month.
So it seems that starting conditions are a huge factor in this. The first one we ran (above) ended in 3000 months. Below is an example of one that ran until I had to shut it down because I didn’t put a stop condition in and I needed to go to bed.
In the next couple days I want to port this to SQLite so we can have some memory savings and maybe data access speedup (doubt it though). I find it overwhelmingly fascinating that even at these seemingly small variable counts, we can have such large differences between runs. I look forward to the data this will generate 🙂 Goodnight!
END DAY 2
Another day later, I realize I didn’t talk much about one of the things we noticed about yesterday’s graph: There is almost always a spike in population when the blue line (I think this is the women population) crosses to be above the orange line (the men population). This is interesting for a number of reasons, and our speculation is that when there are more women in the world, clearly there is a higher probability to have children. Also, when those women get too old to have children, if they didn’t have girls when they were having children, then we are likely to see a downturn in population as there aren’t enough women to bear children.
These findings are hardly revolutionary, but it is still cool to uncover, and feel like we are discovering something. Probably shouldn’t make it a habit though 🙂
Anyway, today I want to improve the efficiency of my simulation, and maybe even get started with the move to a Model View Controller(MVC) architecture.
To start, let’s take some timings. For this, I am thinking of 4 places:
- Render
- Stats Collection
- World Resolver
- Person Resolver
In order to do it, I will install time-collection points at the beginning and end of each of these, subtracting the last from the first. I’ll let it run for a while, storing all these measurements and averaging them at the end.
On the left you can see different timing measurements (in seconds) over the current world time. On the right is the graph that represents the world run. From the timing measurements, we can see our World Resolver is going to quickly become unmanageable — I would imagine the memory usage is horrendous on this as well.
For the World Resolver, I think we can make it a shallower linear increase by simply removing the dead Persons and placing them in their own list each time we resolve. This will reduce the number of Persons we need to resolve way down the line (dead people don’t change much). We could also have a win by multi-threading this to parallelize the Person resolutions. This is almost trivial to do, but we have the pesky births that might cause race conditions — so let’s mutex the population list somehow and we should be clear to multi-thread.
Let’s see how those two changes affect our timings:
Those changes were right on point, you can clearly see we have cut down any latency that we might have had to almost nothing! Now we can run some truly long simulations without as much worry over memory and execution time. Speculating on this, I think that even the transfer of dead to their own list has memory implications, namely, we no longer need to keep them in cache or nearby because of their less frequent use. Let’s take off the gloves and run 500 months without any scaling, that is, no division of 50…
Oooo, look at that! We are starting to see the signs of stress as our population comes to 20,000. Wonder how far we can take this, let’s run for 1.5x a lifetime 🙂
All right, that caps the day 🙂 See you tomorrow on POST DAY!
# -*- coding: utf-8 -*-
"""
Created on Tue Jan 11 18:13:50 2022
@author: Travis Adsitt
"""
from dataclasses import dataclass
from enum import Enum
from threading import Thread, Lock
import matplotlib.pyplot as plt
import random
import time as real_time
# Timing variables for benchmarking different portions of code
timing_vars = {
"Render": [],
"StatCollection": [],
"PersonResolver": [],
"WorldResolver": [],
"Population": []
}
DIVIDER = 1
THREADS = 16
START_POP = 1000
RUN_IN_MONTHS = 1000
REPRODUCTIVE_AGE_START = int(192 / DIVIDER)
REPRODUCTIVE_AGE_END = int(420 / DIVIDER)
GESTATION_TIME_MONTHS = int(9 / DIVIDER)
LIFE_TIME_MONTHS = int(876 / DIVIDER)
class Gender(Enum):
Male = "Male"
Female = "Female"
@dataclass(init=True)
class WorldTraits:
population: list
pop_mutex: Lock
time: int
dead: list
dead_mutex: Lock
def tick_people(people, time):
for person in people:
person.tick_time(time)
class World:
def __init__(self, population):
assert isinstance(population, int), "population must be of type 'int'"
self.traits = WorldTraits(
population=[],
pop_mutex=Lock(),
time=0,
dead=[],
dead_mutex=Lock()
)
self.traits.population = [Person(self, 0.0) for p in range(0, population)]
def birth(self):
"""
Helper function for the Person object to inform the World that they have
had a child.
Returns
-------
None.
"""
# Create a new person
new_peep = Person(self, 0.0)
#Get our mutex
self.traits.pop_mutex.acquire()
# Add them to the population
self.traits.population.append(new_peep)
self.traits.pop_mutex.release()
def death(self, new_dead):
if new_dead not in self.traits.population:
return
self.traits.dead_mutex.acquire()
self.traits.dead.append(new_dead)
self.traits.dead_mutex.release()
self.traits.pop_mutex.acquire()
self.traits.population.remove(new_dead)
self.traits.pop_mutex.release()
def attempt_mate(self, person_one, person_two):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
# Check if they are female
one_kids = person_one.traits.gender == Gender.Female
two_kids = person_two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
person_one.attempt_to_impregnate(self.traits.time)
else:
person_two.attempt_to_impregnate(self.traits.time)
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
def tick_time(self):
"""
Helper function to push the world forward one month at a time.
Returns
-------
None.
"""
world_resolution_time = 0
person_resolution_time = 0
threads = []
person_time = real_time.time()
people = self.traits.population
num_per_thread = int(len(people) / THREADS) + 1
thread_data = [people[i:i + num_per_thread] for i in range(0, len(people), num_per_thread)]
for t in range(0,THREADS):
threads.append(Thread(target=tick_people, args=(thread_data[t],self.traits.time, )))
threads[-1].start()
for t in threads:
t.join()
person_time = (real_time.time() - person_time) / len(people)
person_resolution_time = person_time if person_resolution_time == 0 else (person_time + person_resolution_time) / 2
world_resolution_time += person_resolution_time
world_time = real_time.time()
# Try and mate
self.reproduction_cycle()
# Tick our time forward
self.traits.time += 1
world_time = real_time.time() - world_time
timing_vars["WorldResolver"].append(
world_resolution_time + world_time
)
timing_vars["PersonResolver"].append(
person_resolution_time
)
@dataclass(init=True)
class PersonTraits:
alive: bool
ispregnant: bool
pregnancystart: int
age: int
gender: Gender
money: float
class Person:
def __init__(self, world, money):
assert isinstance(world, World), "world, must be of type 'World'"
assert isinstance(money, float), "money must be of type 'float'"
# Set our world object
self.world = world
# Set our initial 'last_time'
self.last_time = self.world.traits.time
# Set our initial traits
self.traits = PersonTraits(
alive=True,
ispregnant=False,
pregnancystart=None,
age=0,
gender=Gender[random.choice([g.name for g in Gender])],
money=money
)
def spend_money(self, amount):
"""
Used to check if this person can spend money, and if so, does spend
the amount passed in.
Parameters
----------
amount : float
How much money should I spend?
Returns
-------
bool
If I spent the money
"""
spent = False
if(self.money >= amount):
self.money -= amount
spent = True
return spent
def advance_automatic_tickers(self, time):
"""
Helper function to advance the automatic tickers for a person, such as
age or checking for a pregnancy... Those sorts of things.
Parameters
----------
time : int
The current time in the world.
Returns
-------
None.
"""
# We get older
self.traits.age += time - self.last_time
# We sometimes die
if(self.traits.age > LIFE_TIME_MONTHS):
self.traits.alive = False
self.world.death(self)
# Sometimes people give birth
if self.traits.ispregnant and ((time - self.traits.pregnancystart) > GESTATION_TIME_MONTHS):
self.world.birth()
self.traits.ispregnant = False
self.traits.pregnancystart = None
def wants_childeren(self):
"""
For the world to call when determining if a pregnancy should happen.
Returns
-------
bool
If I want childeren
"""
# ~16 to ~35 years old
wants_childeren = self.traits.age > REPRODUCTIVE_AGE_START
if(self.traits.gender == Gender.Female):
wants_childeren = wants_childeren and self.traits.age < REPRODUCTIVE_AGE_END
# Can't have childeren if we already have them
wants_childeren = not self.traits.ispregnant and wants_childeren
return wants_childeren
def attempt_to_impregnate(self, time):
"""
Impregnate this person(female)
Returns
-------
None.
"""
if self.traits.gender == Gender.Female and self.wants_childeren() and self.traits.alive:
self.traits.ispregnant = True
self.traits.pregnancystart = time
def tick_time(self, time):
"""
A 'behavior' function to encapsulate a decision point for this person.
Parameters
----------
time : int
The current time tick of the world.
Returns
-------
None.
"""
self.advance_automatic_tickers(time)
self.last_time = time
def get_plot_vars(world):
men = 0
women = 0
alive = 0
dead = len(world.traits.dead)
for person in world.traits.population:
if person.traits.alive:
alive += 1
if person.traits.gender == Gender.Female:
women += 1
else:
men += 1
return (women, men, alive, dead)
if __name__ == "__main__":
new_world = World(START_POP)
women_list = []
men_list = []
alive_list = []
dead_list = []
time_list = []
prev_dead = 0
for time in range(0,RUN_IN_MONTHS):
new_world.tick_time()
stat_time = real_time.time()
women, men, alive, dead = get_plot_vars(new_world)
dead_this_month = dead - prev_dead
prev_dead = dead
women_list.append(women)
men_list.append(men)
alive_list.append(alive)
dead_list.append(dead_this_month)
time_list.append(time)
stat_time = real_time.time() - stat_time
plot_time = real_time.time()
plt.plot(time_list, women_list, label="Women")
plt.plot(time_list, men_list, label="Men")
plt.plot(time_list, alive_list, label="Alive")
plt.plot(time_list, dead_list, label="Dead")
plt.legend()
plt.draw()
plt.pause(0.05)
plot_time = real_time.time() - plot_time
timing_vars["Render"].append(plot_time)
timing_vars["StatCollection"].append(stat_time)
total_stats = [i for i in range(0,len(timing_vars["Render"]))]
plt.plot(total_stats, timing_vars["Render"], label="Render")
plt.plot(total_stats, timing_vars["PersonResolver"], label="PersonResolver")
plt.plot(total_stats, timing_vars["WorldResolver"], label="WorldResolver")
plt.plot(total_stats, timing_vars["StatCollection"], label="StatCollection")
plt.legend()
END DAY 3
Hello again! The final day for this week’s development!
First, I need to specify yesterday’s final couple of graphs. Brielle and I went to dinner and came back to my laptop still trying to crunch the numbers with four threads. So I pushed the code to my local git, pulled it on my desktop (which has a bit more compute) and spun it up on 16 threads, which as you can see towards the end took nearly 25 seconds per person to compute. Also notable is the number of people we managed to get to: ~14 million!
I am thinking the last thing to add this week is just more efficiencies in the World Resolver, in which I think we can reduce a significant amount of time by multi-threading the shuffle and mating cycles. The reason this is what I am targeting is we can infer from the stat collection time that any single-threaded operation will take ~1/4 to ~1/3 of the total time of the world resolution. So, splitting all single-threaded operations will reduce our time(duh).
Currently my reproduction cycle code looks like this:
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
First thing that pops up in my mind is that shuffle operation. I am willing to bet that is very expensive with larger sizes. Our iteration, though single-threaded, is iterating 2 at a time. This will only save us so long and I believe it would be a constant in a Big-O notation break down.
Let’s start by getting a higher resolution timing on each of the parts of the reproduction cycle function. With timers this code looks like:
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
shuffle_time = real_time.time()
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
timing_vars["PeopleShuffle"].append(real_time.time() - shuffle_time)
pop_copy_time = real_time.time()
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
people_iterator = iter(pop_copy)
timing_vars["PeopleCopy"].append(real_time.time() - pop_copy_time)
repro_time = real_time.time()
# attempt mating in twos
for person in people_iterator:
try:
self.attempt_mate(person, next(people_iterator))
except StopIteration:
break
timing_vars["PeopleMate"].append(real_time.time() - repro_time)
Results over 1000 months on 16 threads:
Ok, shuffling people is expensive, but not as expensive as our mating algorithm, so let’s mix the two. We can “shuffle” by simply selecting two random people in the list and chunking at the same time. Below is my rendering of the “chunked” list generator:
def chunked_list_generator(l, chunks=1):
"""
Helper function to chunk a list into 'chunks' pieces, using the Knuth
algorithm already present in the random.shuffle method of python
Parameters
----------
l : list
The list to chunk up.
chunks : int, optional
The number of chunks to split the list into. The default is 1.
Yields
------
y_list : list
Each chunk as they are produced.
"""
# Get our list length and the number of items to place in each
len_list = len(l)
number_per_chunk = int(len_list / chunks)
# First iterator to go chunk by chunk
for curr_start in range(0, len_list, number_per_chunk):
y_list = []
chunk_end = curr_start + number_per_chunk
# Second iterator to go individual by individual
for c in range(curr_start,chunk_end):
# If we have reached the list length break
if c >= len_list: break
# Get a random list index above the current point
j = random.randint(c, len_list - 1)
# Swap the current with the random item
l[c], l[j] = l[j], l[c]
# Append our Yield list
y_list.append(l[c])
# Yield the list
yield y_list
This method should yield each of the split lists from the main population list that we can then start a thread from and use in the global function:
def mate_list(l, time):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
for i in range(0, len(l), 2):
if (i + 1) >= len(l): break
one = l[i]
two = l[i + 1]
# Check if they are female
one_kids = one.traits.gender == Gender.Female
two_kids = two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
one.attempt_to_impregnate(time)
else:
two.attempt_to_impregnate(time)
In case it isn’t totally clear, this is intended to run inside a thread so the list should be handed to it along with the current time step of the world. Let’s see what that does:
Surprisingly, this did not yield the expected speedup I wanted. I am thinking this has to do with list copying more than shuffling, specifically when I yield back the list, so let’s attempt yielding a couple list indexes that we can use to slice and see what happens…
Ok, so interestingly enough, we see speedup when separating the shuffle from the chunking method, quite a bit of speedup actually. I should mention that I implemented a scaling multi-thread here, where as the population grows we add threads. This reduces the overhead for smaller populations. Splitting 1000 people into 16 groups and starting threads for those groups just doesn’t really make all that much sense. So, every 100,000 people we add a thread to compute them until we get to the max thread count, at which point we just accept the time impact.
Conclusion
Ok, so at the start of this post I set out to create a “balanced” simulation — and in the middle of the post we had a pretty small-scale version that was quite balanced, except when there were too few women in the world to birth children. Towards the end here, we tried to get the full-scale problem working. Though we managed to get considerable speedup, we couldn’t quite get a manageable full-scale simulation going.
Brielle and I have some ideas on how to get speed improvements for a large model. I look forward to exploring those and balancing the simulation further 🙂
Final code for this week 🙂
# -*- coding: utf-8 -*-
"""
Created on Tue Jan 11 18:13:50 2022
@author: Travis Adsitt
"""
from dataclasses import dataclass
from enum import Enum
from threading import Thread, Lock
import matplotlib.pyplot as plt
import random
import time as real_time
# Timing variables for benchmarking different portions of code
timing_vars = {
"Render": [],
"StatCollection": [],
"PersonResolver": [],
"WorldResolver": [],
"Population": [],
"PeopleShuffle":[],
"PeopleCopy":[],
"PeopleMate":[]
}
DIVIDER = 1
THREADS = 15
POPULATION_PER_THREAD = 100000
START_POP = 1000
RUN_IN_MONTHS = 1000
REPRODUCTIVE_AGE_START = int(192 / DIVIDER)
REPRODUCTIVE_AGE_END = int(420 / DIVIDER)
GESTATION_TIME_MONTHS = int(9 / DIVIDER)
LIFE_TIME_MONTHS = int(876 / DIVIDER)
class Gender(Enum):
Male = "Male"
Female = "Female"
@dataclass(init=True)
class WorldTraits:
population: list
pop_mutex: Lock
time: int
dead: list
dead_mutex: Lock
def tick_people(people, time):
for person in people:
person.tick_time(time)
def chunked_list_generator(l, chunks=1):
"""
Helper function to chunk a list into 'chunks' pieces, using the Knuth
algorithm already present in the random.shuffle method of python
Parameters
----------
l : list
The list to chunk up.
chunks : int, optional
The number of chunks to split the list into. The default is 1.
Yields
------
y_list : list
Each chunk as they are produced.
"""
# Get our list length and the number of items to place in each
len_list = len(l)
number_per_chunk = int(len_list / chunks)
# First iterator to go chunk by chunk
for curr_start in range(0, len_list, number_per_chunk):
# y_list = []
chunk_end = curr_start + number_per_chunk
yield (curr_start, chunk_end)
"""
# Second iterator to go individual by individual
for c in range(curr_start,chunk_end):
# If we have reached the list length break
if c >= len_list: break
# Get a random list index above the current point
j = random.randint(c, len_list - 1)
# Swap the current with the random item
l[c], l[j] = l[j], l[c]
# Append our Yield list
y_list.append(l[c])
# Yield the list
yield y_list
"""
def mate_list(l, time):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
for i in range(0, len(l), 2):
if (i + 1) >= len(l): break
one = l[i]
two = l[i + 1]
# Check if they are female
one_kids = one.traits.gender == Gender.Female
two_kids = two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
one.attempt_to_impregnate(time)
else:
two.attempt_to_impregnate(time)
class World:
def __init__(self, population):
assert isinstance(population, int), "population must be of type 'int'"
self.traits = WorldTraits(
population=[],
pop_mutex=Lock(),
time=0,
dead=[],
dead_mutex=Lock()
)
self.threads = 1
self.traits.population = [Person(self, 0.0) for p in range(0, population)]
def birth(self):
"""
Helper function for the Person object to inform the World that they have
had a child.
Returns
-------
None.
"""
# Create a new person
new_peep = Person(self, 0.0)
#Get our mutex
self.traits.pop_mutex.acquire()
# Add them to the population
self.traits.population.append(new_peep)
self.traits.pop_mutex.release()
def death(self, new_dead):
if new_dead not in self.traits.population:
return
self.traits.dead_mutex.acquire()
self.traits.dead.append(new_dead)
self.traits.dead_mutex.release()
self.traits.pop_mutex.acquire()
self.traits.population.remove(new_dead)
self.traits.pop_mutex.release()
def attempt_mate(self, person_one, person_two):
"""
Helper function to handle the resolution as to whether to individuals
can mate, and attempt to impregnate them if they can
Parameters
----------
person_one : Person
Any individual person
person_two : Person
Any other individual person
Returns
-------
None.
"""
# Check if they are female
one_kids = person_one.traits.gender == Gender.Female
two_kids = person_two.traits.gender == Gender.Female
# Check if one female and one male
if one_kids and not two_kids or not one_kids and two_kids:
# Attempt impregnation on the correct individual
if one_kids:
person_one.attempt_to_impregnate(self.traits.time)
else:
person_two.attempt_to_impregnate(self.traits.time)
def reproduction_cycle(self):
"""
Helper function to handle the reproduction behavior of the population.
Returns
-------
None.
"""
shuffle_time = real_time.time()
# Shuffle everyone so we can pick peeps at 'random'
random.shuffle(self.traits.population)
timing_vars["PeopleShuffle"].append(real_time.time() - shuffle_time)
pop_copy_time = real_time.time()
# Make a copy of the population to ensure we don't change under the
# iterator
pop_copy = self.traits.population.copy()
timing_vars["PeopleCopy"].append(real_time.time() - pop_copy_time)
repro_time = real_time.time()
num_threads = self.get_num_threads()
threads = []
# attempt mating in twos
for s, e in chunked_list_generator(pop_copy, num_threads):
threads.append(Thread(target=mate_list, args=(pop_copy[s:e],self.traits.time, )))
threads[-1].start()
for t in threads:
t.join()
timing_vars["PeopleMate"].append(real_time.time() - repro_time)
def tick_time(self):
"""
Helper function to push the world forward one month at a time.
Returns
-------
None.
"""
world_resolution_time = 0
person_resolution_time = 0
threads = []
person_time = real_time.time()
people = self.traits.population
num_threads = self.get_num_threads()
num_per_thread = int(len(people) / num_threads) + 1
thread_data = [people[i:i + num_per_thread] for i in range(0, len(people), num_per_thread)]
for t in thread_data:
threads.append(Thread(target=tick_people, args=(t,self.traits.time, )))
threads[-1].start()
for t in threads:
t.join()
person_time = (real_time.time() - person_time) / len(people)
person_resolution_time = person_time if person_resolution_time == 0 else (person_time + person_resolution_time) / 2
world_resolution_time += person_resolution_time
world_time = real_time.time()
# Try and mate
self.reproduction_cycle()
# Tick our time forward
self.traits.time += 1
world_time = real_time.time() - world_time
timing_vars["WorldResolver"].append(
world_resolution_time + world_time
)
timing_vars["PersonResolver"].append(
person_resolution_time
)
def get_num_threads(self):
num_threads = int(len(self.traits.population) / POPULATION_PER_THREAD)
num_threads = num_threads if num_threads < THREADS else THREADS
num_threads = num_threads or 1
if(num_threads != self.threads):
print(f"Threads changing from {self.threads} to {num_threads}", flush=True)
self.threads = num_threads
return num_threads
@dataclass(init=True)
class PersonTraits:
alive: bool
ispregnant: bool
pregnancystart: int
age: int
gender: Gender
money: float
class Person:
def __init__(self, world, money):
assert isinstance(world, World), "world, must be of type 'World'"
assert isinstance(money, float), "money must be of type 'float'"
# Set our world object
self.world = world
# Set our initial 'last_time'
self.last_time = self.world.traits.time
# Set our initial traits
self.traits = PersonTraits(
alive=True,
ispregnant=False,
pregnancystart=None,
age=0,
gender=Gender[random.choice([g.name for g in Gender])],
money=money
)
def spend_money(self, amount):
"""
Used to check if this person can spend money, and if so, does spend
the amount passed in.
Parameters
----------
amount : float
How much money should I spend?
Returns
-------
bool
If I spent the money
"""
spent = False
if(self.money >= amount):
self.money -= amount
spent = True
return spent
def advance_automatic_tickers(self, time):
"""
Helper function to advance the automatic tickers for a person, such as
age or checking for a pregnancy... Those sorts of things.
Parameters
----------
time : int
The current time in the world.
Returns
-------
None.
"""
# We get older
self.traits.age += time - self.last_time
# We sometimes die
if(self.traits.age > LIFE_TIME_MONTHS):
self.traits.alive = False
self.world.death(self)
# Sometimes people give birth
if self.traits.ispregnant and ((time - self.traits.pregnancystart) > GESTATION_TIME_MONTHS):
self.world.birth()
self.traits.ispregnant = False
self.traits.pregnancystart = None
def wants_childeren(self):
"""
For the world to call when determining if a pregnancy should happen.
Returns
-------
bool
If I want childeren
"""
# ~16 to ~35 years old
wants_childeren = self.traits.age > REPRODUCTIVE_AGE_START
if(self.traits.gender == Gender.Female):
wants_childeren = wants_childeren and self.traits.age < REPRODUCTIVE_AGE_END
# Can't have childeren if we already have them
wants_childeren = not self.traits.ispregnant and wants_childeren
return wants_childeren
def attempt_to_impregnate(self, time):
"""
Impregnate this person(female)
Returns
-------
None.
"""
if self.traits.gender == Gender.Female and self.wants_childeren() and self.traits.alive:
self.traits.ispregnant = True
self.traits.pregnancystart = time
def tick_time(self, time):
"""
A 'behavior' function to encapsulate a decision point for this person.
Parameters
----------
time : int
The current time tick of the world.
Returns
-------
None.
"""
self.advance_automatic_tickers(time)
self.last_time = time
def get_plot_vars(world):
men = 0
women = 0
alive = 0
dead = len(world.traits.dead)
for person in world.traits.population:
if person.traits.alive:
alive += 1
if person.traits.gender == Gender.Female:
women += 1
else:
men += 1
return (women, men, alive, dead)
if __name__ == "__main__":
new_world = World(START_POP)
women_list = []
men_list = []
alive_list = []
dead_list = []
time_list = []
prev_dead = 0
for time in range(0,RUN_IN_MONTHS):
new_world.tick_time()
stat_time = real_time.time()
women, men, alive, dead = get_plot_vars(new_world)
dead_this_month = dead - prev_dead
prev_dead = dead
women_list.append(women)
men_list.append(men)
alive_list.append(alive)
dead_list.append(dead_this_month)
time_list.append(time)
stat_time = real_time.time() - stat_time
plot_time = real_time.time()
plt.plot(time_list, women_list, label="Women")
plt.plot(time_list, men_list, label="Men")
plt.plot(time_list, alive_list, label="Alive")
plt.plot(time_list, dead_list, label="Dead")
plt.legend()
plt.draw()
plt.pause(0.05)
plot_time = real_time.time() - plot_time
timing_vars["Render"].append(plot_time)
timing_vars["StatCollection"].append(stat_time)
total_stats = [i for i in range(0,len(timing_vars["Render"]))]
ignore_timings = [
# "Render",
# "StatCollection",
"PersonResolver",
# "WorldResolver",
"Population",
"PeopleShuffle",
"PeopleCopy",
"PeopleMate"
]
for key in timing_vars:
if key in ignore_timings:
continue
plt.plot(total_stats, timing_vars[key], label=key)
plt.legend()