# Multi-agent Simulation with 2 Actions per Agent

This is a simulation code that runs a multi-agent system, developed to model the dynamics of how prices are fluctuating as the agents make their actions on them. Each agent can make a decision of 'get' or 'remove' the selected option depending on its value and price. There are many enhancements that I couldn't implement due to the code's incredible slowness. It takes a lot of time for running a small scale simulation (e.g. options = 20, agents = 40, duration = 10).

I read about how map and lambda can optimize nested loops. How could they be implemented in this case? Functions get and remove have several steps of their own.

Run using:

pile, index = init_pile()
run_pile(pile, index)


Initialization function:

def init_pile(agents= 40, options=20):

# initialise pile
colnames = [ 'wallet']
rownames = [ 'counts','value','price']
for i in range(options):
colnames.append('o_' + str(i+1))
for o in range(agents):
rownames.append('agent_' + str(o+1))
pile = pd.DataFrame(0, index=rownames,columns=colnames)

# initialise budgets for agents
mu, sigma = 500, 20
budgets = np.random.normal(mu, sigma, agents)
pile.loc[3:,'wallet'] = budgets

# initialise option counts
values = np.random.randint(10, 50, options)
pile.loc['counts',1:] = values

# initialise option values
mu, sigma = 30, 5
values = np.random.normal(mu, sigma, options)
pile.loc['value',1:] = values

# initialise option prices
mu, sigma = 29, 4
values = np.random.normal(mu, sigma, options)
pile.loc['price',1:] = values

# initializing index
index = []
index.append(1000)

return pile,index


Main pile build-function:

def run_pile(pile,index,duration=20,agents= 40, options=20):
agents = list(range(agents))
for time in range(duration):
for option in range(options):
for agent in agents:
# agent will buy if option perceived value > current value
price = pile.loc['price', str('o_' + str(option + 1))]
value = pile.loc['value', str('o_' + str(option + 1))]

if price < value:
get(pile, agent, option)

if price > value:
remove(pile, agent, option)

#decay(pile,options)

agents = shuffle(agents)
calculate_index(pile, index)

print(pile)


get function:

def get(pile, agent, option):
# if the agent can get the option
budget = pile.loc[str('agent_' + str(agent + 1)), 'wallet']
current_price = pile.loc['price', str('o_' + str(option + 1))]
counts = pile.loc['counts', str('o_' + str(option + 1))]

# if agent has money and there are options remaining
if budget > current_price and counts > 0:
pile.loc[str('agent_' + str(agent + 1)), str('o_' + str(option + 1))] +=1

# subtract from wallet
pile.loc[str('agent_' + str(agent + 1)), 'wallet'] -= current_price

# subtract from option count
pile.loc['counts', str('o_' + str(option + 1))] -= 1

# inflate price of option
pile.loc['price', str('o_' + str(option + 1))] *= 1.1


remove function:

def remove(pile, agent, option):
current_price = pile.loc['price', str('o_' + str(option + 1))]
#if the agent owns the option
if pile.loc[str('agent_' + str(agent + 1)), str('o_' + str(option + 1))] >0:
# agent loses the option
pile.loc[str('agent_' + str(agent + 1)),str('o_' + str(option + 1))] -= 1

# agent gains money placed in wallet
pile.loc[str('agent_' + str(agent + 1)), 'wallet'] += current_price

pile.loc['counts', str('o_' + str(option + 1))] += 1

# deflate the option price
pile.loc['price', str('o_' + str(option + 1))] *= 0.99


calculate_index function:

def calculate_index(pile, index, method = 'average'):
if method == 'average':
ind = ((pile.iloc[2,1:].sum()/20) / index[-1] ) * 1000
index.append(ind)


decay function:

def decay(pile, options):
# if option price is more than perceived value
# deflate the price
for option in range(options):
pile.loc['price', str('o_' + str(option + 1))] *= 0.999999


The decay function contains similar operations to get and remove, but I commented it because it was too slow already and I needed to resolve that.

• Hi Nal. Welcome to CodeReview. We would need more context to run or fully understand this code. In particular you have not given the definition, or even a plausible dummy definition, of pile or most of your other variables. That makes it difficult to know how it could be optimised. Please edit your post to provide enough information for someone else to replicate the problem. Meanwhile, the best advice for optimising code is to start by profiling it to see where the time is being spent. For python, that means run your code with cProfile. stackoverflow.com/a/582337/1688786 – Josiah May 4 '18 at 6:40
• P.S. I do have some suggestions that would probably help, but you can't edit you post after an answer is posted, so I won't put that into an answer until I have enough information to help properly. – Josiah May 4 '18 at 6:43
• @Josiah From the code we can conclude that pile looks like a pandas dataframe with roughly the following structure. But yes, better to have it confirmed by OP. I also would like to know if it is important, in the context of this simulation, if the agents take their actions in turn (it seems, looking at the agents = shuffle(agents) line) or if they can take their decision "simultaneously"? The code for calculate_index would also be a great addition. – 301_Moved_Permanently May 4 '18 at 8:29
• @Josiah I updated the code to be reproducible and complete. I appreciate your guidance. – Nal May 4 '18 at 10:36
• @Mathias Ettinger correct, pandas dataframe. Is there a way to allow for simultaneous actions? Ideally I need the agents to be as random as possible and display no order whatsoever. – Nal May 4 '18 at 10:37

Having all your data in a single structure makes it hard to understand exactly how things interact as you do extra work to build relevant indices. Instead, you should at least decouple your pile into 3 structures:

• wallets with shape (1, number_of_agents);
• options with shape (number_of_options, 3);
• stocks with shape (number_of_options, number_of_agents).

That is, if you intend to use some operations on dataframes that are offered by pandas.

And by the look of things, you don't.

What you seems to be after is to have, for each option, each agent take its own decision; in no particular order, but one after another. So you won't be able to take advantage of the vectorized operations offered by pandas and you might as well drop this dependency.

Instead I would create an Option class to hold attributes of an option (such as a price, a value, an amount, and possibly an id) and an Agent class to hold a wallet and a portfolio of options. The Option class would be responsible to buy, sell, and decay an option. The Agent class would be responsible to get or remove an option into/from their portfolio.

And then you organize all these agents using threads: the threading module provide ways to run threads which will execute in no particular order, to lock sections of code so that a single agent examine an option at a time and to synchronize agents so that each of them will wait that everyone had a chance to examine an option before moving to the next one.

When done, you just need to repeat as much time as desired for your duration:

import random

class InvalidOperation(Exception):
pass

class Option:
def __init__(self, id, price, value, count):
self.id = id
self.price = price
self.value = value
self.count = count

if not self.count:
raise InvalidOperation

self.count -= 1
self.price *= 1.1

def sell(self):
self.count += 1
self.price *= 0.99

def decay(self):
self.price *= 0.999999

def __repr__(self):
return f'{self.__class__.__name__}({self.id}, {self.price}, {self.value}, {self.count})'

class Agent:
def __init__(self, wallet, options):
self.wallet = wallet
self.portfolio = {option.id: 0 for option in options}

def run(self, option):
if option.price < option.value:
self.get(option)

if option.price > option.value:
self.remove(option)

option.decay()

def get(self, option):
if self.wallet > option.price and option.count:
self.wallet -= option.price
self.portfolio[option.id] += 1

def remove(self, option):
if self.portfolio[option.id] > 0:
print(id(self), 'selling', option)
self.wallet += option.price
self.portfolio[option.id] -= 1
option.sell()

def run_agent(agent, options, barrier, mutex):
for option in options:
with mutex:
# Ensure that a single  agent take a decision at a time
agent.run(option)
# Everyone will get a saying at this
# option before going to the next
barrier.wait()

def run_pile(options, agents=40, duration=20):

index = []
agents = [
Agent(random.normalvariate(500, 20), options)
for _ in range(agents)
]

for time in range(duration):
print('time', time, 'starting')
target=run_agent,
args=(agent, options, barrier, mutex),
) for agent in agents
]

print('time', time, 'ending')
calculate_index(options, agents, index)

return index

def build_options(options=20):
return [
Option(
i,
random.normalvariate(29, 4),
random.normalvariate(30, 5),
random.randint(10, 50),
) for i in range(options)
]

def calculate_index(options, agents, index):
# Keeping empty as I don't really get it
pass

if __name__ == '__main__':
options = build_options()
run_pile(options)


I’ve left some print calls so you can see that things stay "ordered" but you should remove them in the final code.

• basilcally the option I was going to post, but I was not going to include the threading. I don't know whether the extra threads compensate the acquiring and releasing of the locks each time. defining __slots__ is something that might help too – Maarten Fabré May 5 '18 at 8:42
• @MaartenFabré Good call on __slots__. Threading was mainly to avoid to focus on selecting and/or shuffling agents. And the lock and barrier help enforce some higher level rule that could be lessened (such as removing the barrier for instance). The approach is not really performances oriented but more of a design consideration. – 301_Moved_Permanently May 5 '18 at 11:18
• This worked incredibly faster than mine, thank you. I had a problem when I tried to import pandas (to represent the end in a graph), it gave me an error of 'too many files open' and pointed at import pandas. But I reran it with your updated code and it worked well! – Nal May 6 '18 at 7:28
• @MathiasEttinger, the portfolio is always 0 for all options at the end. How can I fix that? – Nal May 6 '18 at 10:31
• @Nal I'm no expert but isn't it something to be expected? I mean, once an option is bought, its price may raise above its perceived value, thus making the agent that just purchased it want to sell it as soon as possible. – 301_Moved_Permanently May 6 '18 at 12:07

as mentioned in Mathia Ettingers' excellent answer, using pandas as a 2d-dict is not the way that it'll improve performance.

Here is my solution. It differs in:

• no use of threading, but random.shuffle to randomize the actions
• instead of letting Pile decide whether an agent would buy or sell an option, I made an Agent.offer(option) method, where the option is offered to the agent, and he decides what to do with it
• I added more parameters to the different parts of the code, so you can play with the decay. I even added the Agent and Option class as parameters to Pile, so you can subclass Option to for example:
• let the decay be a random variation of the price for example,
• let each agent have a different appreciation for the different options
• change the ways when an agent would buy or sell an option
• have different reactions to buy and sell for each option
• ...

# code

from collections import Counter
import random
import pandas as pd

class NotAvailable(Exception): pass

class NoBudget(Exception): pass


## default parameters

PRICE_INCREASE = 1.1
PRICE_DECREASE = .9
DECAY = 0.999


## Option class

class Option:
#     __slots__ = ['name', 'value', 'price', 'count', 'price_increase', 'price_decrease', 'price_decay']

def __init__(
self,
name: str,
value: float,
price: float,
count: int,
price_increase: float = PRICE_INCREASE,
price_decrease: float = PRICE_DECREASE,
price_decay: float = DECAY,
):
self.name: str = name
self.value: float = value
self.price: float = price
self.count: int = count
self.price_increase: float = price_increase
self.price_decrease: float = price_decrease
self.price_decay: float = price_decay

def __repr__(self):
return f'Option(name={self.name}, value={self.value}, count={self.count})'

if self.count < 1:
raise NotAvailable(f'{self} is not available')
self.count -= 1
self.price *= self.price_increase

def sell(self):
self.count += 1
self.price *= self.price_decrease

def decay(self):
self.price *= self.price_decay

def __hash__(self):
return hash(self.name)


## agent class

class Agent:
#     __slots__ = 'name', 'pile', 'budget', 'options'

def __init__(self, name, pile, budget, options=None):
options = options if options is not None else {}
self.name = name
self.options = Counter(options)
self.budget: float = budget

def __repr__(self):
return f'Agent(name={self.name}, budget={self.budget}, options={dict(self.options)})'

if self.budget - option.price < 0:
raise NoBudget(f'{self} has not enough budget to buy {option}')
price = option.price
try:
except NotAvailable:
return
self.budget -= price
self.options[option] += 1

def sell(self, option):
if self.options[option] < 1:
raise NotAvailable(f'{self} has no {option} to sell')
price = option.price
option.sell()
self.budget += price
self.options[option] -= 1

def offer(self, option):
price = option.price
if price > option.value:
try:
self.sell(option)
#                 print(f'{self.name} sold {option.name} for {price}')
except NotAvailable:
pass
elif option.value > price:
try:
#                 print(f'{self.name} bought {option.name} for {price}')
except NoBudget:
pass


# Pile class

class Pile:
def __init__(
self,
agents=40,
options=20,
budget=(500, 20),
price=(30, 5),
value=(29, 4),
price_decay=DECAY,
price_increase=PRICE_INCREASE,
price_decrease=PRICE_DECREASE,
agent_class=Agent,
agent_kwargs=None,
option_class=Option,
option_kwargs=None,
):
option_kwargs = option_kwargs if option_kwargs is not None else {}
agent_kwargs = agent_kwargs if agent_kwargs is not None else {}
self.agents: set = {agent_class(f'agent_{i:02}', self, budget=random.gauss(*budget), **agent_kwargs) for i in range(agents)}
self.options: set = {
option_class(
f'option_{i:02}',
price=random.gauss(*price),
value=random.gauss(*value),
count=random.randint(10, 50),
price_increase=price_increase,
price_decrease=price_decrease,
price_decay=price_decay,
**option_kwargs
) for i in range(options)}
self.index = [1000]
self._initial_mean_price = self._mean_price()

def decay(self):
for option in self.options:
option.decay()

def run(self):
options = list(self.options)
random.shuffle(options)
for option in options:
agents = list(self.agents)
random.shuffle(agents)
for agent in agents:
agent.offer(option)
self.decay()
self._update_index()

def option_data(self):
options = sorted(self.options, key=lambda x: x.name)
option_values = list(zip(
['name', 'count', 'value', 'price'],
zip(*[(option.name, option.count, option.value, option.price) for option in options], )
))
return pd.DataFrame.from_items(option_values).set_index('name')

def agent_stock(self):

options = sorted(self.options, key=lambda x: x.name)
agents = sorted(self.agents, key=lambda x: x.name)
agent_values = [
(agent.name, [agent.options.get(option, 0) for option in options])
for agent in agents
]
option_names = [option.name for option in options]
return pd.DataFrame.from_items(agent_values).assign(option=option_names).set_index('option')

def agent_data(self):
options = sorted(self.options, key=lambda x: x.name)
agents = sorted(self.agents, key=lambda x: x.name)
agent_values = [
(agent.name, [agent.budget] + [agent.options.get(option, 0) for option in options])
for agent in agents
]
option_names = [option.name for option in options]
return pd.DataFrame.from_items(agent_values, orient='index', columns=['budget'] + option_names )

def data(self):
return pd.merge(self.option_data(), self.agent_stock(), left_index=True, right_index=True)

def _update_index(self, method='average'):
if method == 'average':
ind = (self._mean_price() / self._initial_mean_price ) * self.index[0]
self.index.append(ind)

def _mean_price(self):
return self.option_data()['price'].mean()


# calling the code

random.seed(42)
p = Pile(agents=40, options=20)
for _ in range(50):
#     print(f'run {_}')
p.run()

p.data() # to get the data of the pile

p.agent_data()