Developing an agend-based framework for simulation models in Python

In a previous post I constructed a simple agent-based simulation model, containing groups of agents that can be located on a battlefield grid. The model was coded in Python, using matplotlib for visualization. I went on to conduct a simple simulation run, showing one battle scenario and its outcome.

Below I remodel the problem, trying to clean up the code and modularizing functionality into re-usable functions. The result will be a modular framework and general appraoch description that can be used for building very advanced agent-based simulation models.

The agents are modelled as a class, as shown below:

# class, defining agents as abstract data types
class agent:
    # init-method, the constructor method for agents
    def __init__(self,x,y,group):
        self.life = 100 # agent's life score
        self.x = x
        self.y = y
        self.group = group

The battlefield itself is modelled as a two-dimensional grid array, as shown below:

# creating empty 100 x 100 list using list comprehension in python
battlefield = [[None for i in range(0,100)] for i in range(0,100)]

Agent groups can be populated onto the battlefield grid using the agentCreator-function:

# define a function for creating agents and assigning them to grid
def agentCreator(size,group,groupList,field,n,m):
    # loop through entire group, i.e. in this case 1000 units
    for j in range(0,size):
        # select random available location 
        while True:
            # random x coordinate
            x = random.choice(range(0,n))
            # random y coordinate
            y = random.choice(range(0,m))
            # check if spot is available; if not then re-iterate 
            if field[x][y] == None:
                field[x][y] = agent(x=x,y=y,group=group)
                # append agent object reference to group list
                groupList.append(field[x][y])
                # exit while loop; spot on field is taken
                break

For plotting agents with a positive life score as visual dots on the battlefield grid I create a plotting function, which can be called at any time throughout the simulation to get a snapshot of current battle status:

# import pyplot and colors from matplotlib
from matplotlib import pyplot, colors
# define function for plotting battlefield (all agents that are still alive)
def plotBattlefield(populationArr, plotTitle):
    # using colors from matplotlib, define a color map
    colormap = colors.ListedColormap(["lightgrey","green","blue"])
    # define figure size using pyplot
    pyplot.figure(figsize = (12,12))
    # using pyplot add a title
    pyplot.title(plotTitle, fontsize = 24)
    # using pyplot add x and y labels
    pyplot.xlabel("x coordinates", fontsize = 20)
    pyplot.ylabel("y coordinates", fontsize = 20)
    # adjust x and y axis ticks, using pyplot
    pyplot.xticks(fontsize = 16)
    pyplot.yticks(fontsize = 16)
    # use .imshow() method from pyplot to visualize agent locations
    pyplot.imshow(X = populationArr, cmap = colormap)

Another function I will need is a function that can map a battlefield status to a numeric two-dimensional array containing values of 1.0 if an agent of type A is still located and alive within a given cell of the two-dimensional battlefield grid, or the value 2.0 if there is an agent of type B. I use this mapping process to feed my plotting function a numeric grid instead of a grid with objects of an abstract i.e. customized class:

# this function maps a battlefield grid to a numeric grid with 1 for agents of type A, 2 for type B and 0 for no agent
def mapBattlefield(battlefieldArr):
    #.imshow() needs a matrix with float elements;
    populationArr = [[0.0 for i in range(0,100)] for i in range(0,100)]
    # if agent is of type A, put a 1.0, if of type B, pyt a 2.0
    for i in range(1,100):
        for j in range(1,100):
            if battlefieldArr[i][j] == None: # empty
                pass # leave 0.0 in population cell
            elif battlefieldArr[i][j].group == "A": # group A agents
                populationArr[i][j] = 1.0 # 1.0 means "A"
            else: # group B agents
                populationArr[i][j] = 2.0 # 2.0 means "B"
    # return mapped values
    return(populationArr)

Using these model components I created an initial battlefield population and plotted agent locations using matplotlib. This is done in the code below and similar to my previous posts, only that in this case I use modularized functionality. The functionality of setting up an initial battlefield grid is transferred to a separate function below. That function is then executed.

# function for creating an initial battlefield grid
def initBattlefield(populationSizeA,populationSizeB,battlefieldArr):
    # initializing new empty battlefield grid, using list comprehension in Python
    battlefieldArr = [[None for i in range(0,100)] for i in range(0,100)]
    # create empty list for containing agent references in future, type A & B
    agents_A = []
    agents_B = []
    # assigning random spots to agents of group A and B; 
    import random
    agentCreator(size = populationSizeA,
                    group = "A",
                    groupList = agents_A,
                    field = battlefieldArr,
                    n = 100,
                    m = 100)
    agentCreator(size = populationSizeB,
                    group = "B",
                    groupList = agents_B,
                    field = battlefieldArr,
                    n = 100,
                    m = 100)
    # return populated battlefield grid
    return(battlefieldArr)

# executing above function for a population size of 1000 for both groups
battlefield = initBattlefield(populationSizeA=1000,populationSizeB=1000,battlefieldArr = battlefield)

# plot battlefield status
plotBattlefield(populationArr = mapBattlefield(battlefield), 
                    plotTitle = "battlefield before simulation run (green = A, blue = B)")

In my previous post I then ran a simulation run based on the following rules:

Group A has the strategy of always hitting the same agent in each round
Group B has a random and independent strategy for attacking enemies. This means that each agent of type B will attack a randomly selected agent within that agents reach.

The simulation was conducted under the following conditions:

1) Each round is one iteration
2) In each round, each agent can attack one agent from within its reach
3) The reach of an agent is defined at the start of the simulation and defaults to 10
4) If an agent dies he will no longer be located on the battle field
5) An agent dies when his life score equals or goes below zero
6) Each agent has a randomly distributed attack damage, ranging from 10 to 60
7) In each round all agents get to launch an attack

Like in one of my previous posts I will now iterate through 50 rounds of battle. After each round, agents with a non-positive life score will be removed from the battlefield grid. As a deviation from my previous post I will however now modularize functionality.

First, I define a function for removing agents from the battlefield grid:

# function for removing agents from battlefield grid when life score is not strictly positive
def removeDeadAgents(battlefieldArr):
    # identifying agents with life score of score or below - and removing them from the grid
    for i in range(0,len(battlefieldArr)):
        for j in range(0,len(battlefieldArr)):
            if battlefieldArr[i][j]:
                if battlefieldArr[i][j].life <= 0:
                    # remove this agent since life score is not strictly positive
                    battlefieldArr[i][j] = None

Next, I define a function implementing the fighting strategy for agents of type A:

# function implementing one round of fighting, for an agent of type A
def oneRoundAgentA(i,j,attackRange):
    found_i = None
    found_j = None
    # look in neigbouring cells in same order for each iteration
    for k in range(i-attackRange,i+attackRange+1):
        for l in range(j-attackRange,j+attackRange+1):
            # check for negative index values; if so - break!
            if k < 0 or l < 0:
                break
                # check for index values above 99, if so break!
            if k > 99 or l > 99:
                break
            if battlefield[k][l]:
                if battlefield[k][l].group == "B": # then this is an enemy
                    if found_i == None:
                        found_i = k
                        found_j = l
                    
    # deal damage to identified enemies
    if found_i:
        battlefield[found_i][found_j].life = battlefield[found_i][found_j].life - random.randint(10,60)

Then I do the same for agents of type B:

# function implementing one round of fighting, for an agent of type B
def oneRoundAgentB(i,j,attackRange):
    # first check if there even is an enemy in one of the surrounding cells
    enemy_found = False
    for k in range(i-attackRange,i+attackRange+1):
        for l in range(j-attackRange,j+attackRange+1):
            # check for negative index, if so break to next iteration
            if k < 0 or l < 0:
                break
                # check for index values above 99, if so break
            if k > 99 or l > 99:
                break
            if battlefield[k][l] != None:
                if battlefield[k][l].group == "A":
                    enemy_found = True
    # select a random row and a random column
    found_i = None
    found_j = None
    while enemy_found and found_i == None:
        k = random.randint(i-attackRange,i+attackRange)
        l = random.randint(j-attackRange,j+attackRange)
        # check for negative index, if so continue to next iteration
        if k < 0 or l < 0:
            continue
        # check for index value > 99, if so continue
        if k > 99 or l > 99:
            continue
        if k != i:
            if battlefield[k][l]: 
                if battlefield[k][l].group == "A":
                    found_i = k
                    found_j = l
        else:
            if l != j:
                if battlefield[k][l]:
                    if battlefield[k][l].group == "A":
                        found_i = k
                        found_j = l
    # deal damage to identified enemy
    if found_i:
        battlefield[found_i][found_j].life = battlefield[found_i][found_j].life - random.randint(10,60)

I proceed to simulating the battle, using the functions already implemented:

for counter in range(0,50): # in this case I am conducting 50 iterations 
    # iterating through all cells on the battlefield
    for x in range(0,len(battlefield)):
        for y in range(0,len(battlefield)):
            # print("top tier iteration, i: "+str(i)+", j: "+str(j))
            # check if there is an agent within the respective cell
            if battlefield[x][y] != None:
                # depending on the type: execute respective attack strategy
                if battlefield[x][y].group == "A":
                    # one round of battle for this agent of type A
                    oneRoundAgentA(i = x, j = y,attackRange=10)
                else: 
                    # one round of battle for this agent of type B
                    oneRoundAgentB(i = x, j = y,attackRange=10)
    # identifying agents with life score of score or below - and removing them from the grid
    removeDeadAgents(battlefieldArr = battlefield)
# plot battlefield status
plotBattlefield(populationArr = mapBattlefield(battlefield), 
                plotTitle = "battlefield after 50 iterations (green = A, blue = B)")

So far, this has content-wise been identical to the simple simulation study presented in a previous post.

I now want to add a graph to analyze battle outcome and the progression of the battle itself. I thus define an additional plotting function which plots the number of agents by type, being still alive.

I also implement a function for updating the time series of agents still being alive.

# function for updating the time series of agents being alive
def calcAgentsAliveA(resultsA,battlefieldArr):
    # these variables will be used for counting the number of agents alive
    countA = 0
    countB = 0
    # iterate through grid, find agents and update count if relevant
    for i in range(0,len(battlefieldArr)):
        for j in range(0,len(battlefieldArr)):
            if battlefieldArr[i][j]:
                if battlefieldArr[i][j].group == "A":
                    countA = countA + 1
                else:
                    countB = countB + 1
    # update results list and return it
    resultsA.append(countA)
    return(resultsA)

# function for updating the time series of agents being alive
def calcAgentsAliveB(resultsB,battlefieldArr):
    # these variables will be used for counting the number of agents alive
    countA = 0
    countB = 0
    # iterate through grid, find agents and update count if relevant
    for i in range(0,len(battlefieldArr)):
        for j in range(0,len(battlefieldArr)):
            if battlefieldArr[i][j]:
                if battlefieldArr[i][j].group == "A":
                    countA = countA + 1
                else:
                    countB = countB + 1
    # update results list and return it
    resultsB.append(countB)
    return(resultsB)

# function for plotting the number of agents still alive
def plotNumberOfAgentsAlive(plotTitle,iterations,resultsA,resultsB):
    from matplotlib import pyplot
    pyplot.figure(figsize = (12,12))
    pyplot.title(plotTitle, fontsize = 24)
    pyplot.xlabel("iteration", fontsize = 20)
    pyplot.ylabel("agents still alive", fontsize = 20)
    pyplot.xticks(fontsize = 16)
    pyplot.yticks(fontsize = 16)
    ax = pyplot.subplot()
    ax.plot(iterations,resultsA, label = "type a agents")
    ax.plot(iterations,resultsB, label = "type b agents")
    ax.legend(fontsize=16)

Now, I can conduct another simulation run using, displaying the graph. Since I will be running various simulation scenarios I will write the simulation run to a function as well:

# defining function for conducting a simulation run
def simulationRun(iterationLimit,attackRange,showPlot):
    iterations = []
    resultsA = []
    resultsB = []
    for counter in range(0,iterationLimit): # in this case I am conducting 50 iterations 
        # update iterations
        # update results
        iterations.append(counter+1)
        resultsA = calcAgentsAliveA(resultsA,battlefield)
        resultsB = calcAgentsAliveB(resultsB,battlefield)
        # iterating through all cells on the battlefield
        for x in range(0,len(battlefield)):
            for y in range(0,len(battlefield)):
                # print("top tier iteration, i: "+str(i)+", j: "+str(j))
                # check if there is an agent within the respective cell
                if battlefield[x][y]:
                    # depending on the type: execute respective attack strategy
                    if battlefield[x][y].group == "A":
                        # one round of battle for this agent of type A
                        oneRoundAgentA(i = x, j = y, attackRange = attackRange)
                    else: 
                        # one round of battle for this agent of type B
                        oneRoundAgentB(i = x, j = y, attackRange = attackRange)
        # identifying agents with life score of score or below - and removing them from the grid
        removeDeadAgents(battlefieldArr = battlefield)
    # plot battle progression, but only if plot should be displayed
    if showPlot:
        plotNumberOfAgentsAlive("battle progression",iterations,resultsA,resultsB)
    # return results
    return([resultsA,resultsB])

I can now conduct a simulation run using one line of code only:

battlefield = initBattlefield(populationSizeA = 1000,populationSizeB = 1000,battlefieldArr = battlefield)
results = simulationRun(iterationLimit = 50, attackRange = 10, showPlot = True)

It seems agents of type B have a somewhat effective attack strategy. But what if their number count is slightly lower when battle begins? Below I plot battle progression in a fight with 1000 initial A and 950 initial B agents.

battlefield = initBattlefield(populationSizeA = 1000,populationSizeB = 950,battlefieldArr = battlefield)
results = simulationRun(iterationLimit = 50, attackRange = 10, showPlot = True)

What happens if attack range is reduced to 5?

battlefield = initBattlefield(populationSizeA = 1000,populationSizeB = 1000,battlefieldArr = battlefield)
results = simulationRun(iterationLimit = 50, attackRange = 5, showPlot = True)

Initial battlefield location of agents is random. Final battle outcome might thus be randomly distributed as well. I want to investigate to what extent results are randomized. For this I implement a sensitivity test function that conduct repeats the simulation over and over again. This function will return results that can be visualized in a histogram, representing the total amount of agents alive at simulation end. In this case I repeat the simulation 50 times.

Below I implement the function conducting the sensitivity test:

# this function is used for conducting a sensitivity test with regards to battle outcome
def sensitivityTest(iterationLimit,attackRange,runs):
    # indicate that battlefield is a global variable
    global battlefield
    # empty lists which will contain the final number of agents of respective type, at the end of battle
    outcomeA = []
    outcomeB = []
    # repeating the simulation with defined attack range and iteration limit, for "runs" number of times
    for i in range(0,runs):
        # before each simulation run battlefield must be initialized
        battlefield = initBattlefield(populationSizeA = 1000,populationSizeB = 950,battlefieldArr = battlefield)
        # conduct simulation run
        results = simulationRun(iterationLimit=iterationLimit,attackRange = attackRange,showPlot = False)
        # append result to relevant outcome list
        outcomeA.append(results[0][iterationLimit-1])
        outcomeB.append(results[1][iterationLimit-1])
    # returning the result in a list with two-sublist
    return([outcomeA,outcomeB])

Below I implement the histogram plotting function:

# function for plotting a histogram
def plotHistogram(plotTitle,resultsA,resultsB):
    from matplotlib import pyplot
    pyplot.figure(figsize = (12,12))
    pyplot.title(plotTitle, fontsize = 24)
    pyplot.xlabel("number of agents still alive", fontsize = 20)
    pyplot.ylabel("absolute frequency", fontsize = 20)
    pyplot.xticks(fontsize = 16)
    pyplot.yticks(fontsize = 16)
    ax = pyplot.subplot()
    ax.hist(resultsA, bins=20, histtype="bar",color="red",label="agent A",alpha=0.25)
    ax.hist(resultsB, bins=20, histtype="bar",color="blue",label="agent B",alpha=0.25)
    ax.legend(fontsize=16)

Finally, the code below executes the seensitivity test and plots the result.

# executing the sensitivityTest
results = sensitivityTest(iterationLimit = 50,attackRange = 5,runs = 50)
plotHistogram(plotTitle = "distribution of agents",resultsA = results[0], resultsB = results[1])

This result is for an attack range of 5.

Using this approach, a very advanced agent-based simulation model can be developed.

Leave a Reply

Leave a Reply

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

Close

Meta