Making a Balanced Simulation

I don’t know what I am doing, I have never made a balanced simulation in my life. This is purely an exploration of the idea.

Travis

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:

  1. When any one variable climbs way beyond any other.
  2. When all variables are ‘dead.’
  3. 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…

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()

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.