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.

Data scientist focusing on simulation, optimization and modeling in R, SQL, VBA and Python

## Leave a Reply