3
\$\begingroup\$

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:
        # buy the option
        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

        # add option count 
        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.

\$\endgroup\$
  • 3
    \$\begingroup\$ 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 \$\endgroup\$ – Josiah May 4 '18 at 6:40
  • 1
    \$\begingroup\$ 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. \$\endgroup\$ – Josiah May 4 '18 at 6:43
  • 1
    \$\begingroup\$ @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. \$\endgroup\$ – Mathias Ettinger May 4 '18 at 8:29
  • \$\begingroup\$ @Josiah I updated the code to be reproducible and complete. I appreciate your guidance. \$\endgroup\$ – Nal May 4 '18 at 10:36
  • \$\begingroup\$ @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. \$\endgroup\$ – Nal May 4 '18 at 10:37
2
\$\begingroup\$

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
import threading


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

    def buy(self):
        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:
            print(id(self), 'buying', option)
            self.wallet -= option.price
            self.portfolio[option.id] += 1
            option.buy()

    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):
    mutex = threading.Lock()
    barrier = threading.Barrier(agents)

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

    for time in range(duration):
        print('time', time, 'starting')
        agents_threads = [
                threading.Thread(
                    target=run_agent,
                    args=(agent, options, barrier, mutex),
                ) for agent in agents
        ]
        for thread in agents_threads:
            thread.start()
        for thread in agents_threads:
            thread.join()

        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.

\$\endgroup\$
  • \$\begingroup\$ 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 \$\endgroup\$ – Maarten Fabré May 5 '18 at 8:42
  • \$\begingroup\$ @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. \$\endgroup\$ – Mathias Ettinger May 5 '18 at 11:18
  • \$\begingroup\$ 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! \$\endgroup\$ – Nal May 6 '18 at 7:28
  • \$\begingroup\$ @MathiasEttinger, the portfolio is always 0 for all options at the end. How can I fix that? \$\endgroup\$ – Nal May 6 '18 at 10:31
  • \$\begingroup\$ @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. \$\endgroup\$ – Mathias Ettinger May 6 '18 at 12:07
2
\$\begingroup\$

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})'

    def buy(self):
        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)})'

    def buy(self, option):
        if self.budget - option.price < 0:
            raise NoBudget(f'{self} has not enough budget to buy {option}')
        price = option.price
        try:
            option.buy()
        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:
                self.buy(option)
#                 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()
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.