Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DiscreteEventScheduler #1890

Merged
merged 10 commits into from
Nov 29, 2023
Merged

Conversation

EwoutH
Copy link
Member

@EwoutH EwoutH commented Nov 27, 2023

This PR introduces the DiscreteEventScheduler, an addition to the Mesa time module. This new scheduler is designed for discrete event simulation, where agent steps are scheduled to occur at specific simulation times, rather than at regular, step-wise intervals.

Key Features:

  • The scheduler advances the simulation to the time of the next scheduled event, handling multiple events efficiently.
  • If multiple events are scheduled for the same time, their execution order is randomized. This is achieved by adding a secondary sorting criterion based on a random value. This ensures fairness and reduces bias that might occur from consistent ordering.
  • The model.step() works the same by default: It advances time by 1. The DiscreteEventScheduler has a keyword argument time_step (default 1) which is the period in which the model is advanced by each model.step() call. In that period, agent may execute their step function zero, one or multiple times, depending on the amount of eventa scheduled for that agent.
    • This allows easy adaption from current models, and to gather data at regular intervals.

Modifications:

  • Added the DiscreteEventScheduler class with methods to schedule and process events.
  • Modified the schedule_event method to include a random value for secondary sorting in the event queue.
  • Adjusted the step method to handle events within a given time frame, advancing the simulation in fixed time steps.
  • Modified the add method to have a schedule_now keyword, which is True by default. When True, the first event for that agent is immediately added to the schedule.

Two example models are available, a very simple one and a slightly more complex one.

from mesa import Model, Agent
from mesa.time import DiscreteEventScheduler
import random

class MyAgent(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)
        # Agent initialization code here

    def step(self):
        # Agent action code here
        # For example, schedule next action after a random delay using schedule_in
        print(f"Agent {self.unique_id} executing step at time {self.model.schedule.time}")
        delay = random.uniform(1, 2)
        self.model.schedule.schedule_in(delay, self)

class MyModel(Model):
    def __init__(self, num_agents):
        self.schedule = DiscreteEventScheduler(self, time_step=1)
        # Initialize and add agents
        for i in range(num_agents):
            agent = MyAgent(i, self)
            self.schedule.add(agent, schedule_now=True)

    def step(self):
        self.schedule.step()  # Execute events for the next time step

# Create and run the model
model = MyModel(num_agents=5)
for i in range(10):  # Run for 20 steps
    model.step()

More complex example:

from mesa import Agent, Model
from mesa.space import MultiGrid
from mesa.datacollection import DataCollector
from mesa.time import DiscreteEventScheduler
import random


class Prey(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)
        self.energy = 5

    def step(self):
        # print(f"Prey {self.unique_id} executing step at time {self.model.schedule.time}")
        if self.energy <= 0:
            self.model.schedule.remove(self)
        else:
            self.move()
            self.energy -= 1
            self.eat()
            # Schedule next move at a random time in the future
            next_event_time = self.model.schedule.time + random.uniform(1, 2)
            self.model.schedule.schedule_event(next_event_time, self)

    def eat(self):
        self.energy += random.uniform(1, 2)
    def move(self):
        # Move to a random neighboring cell
        possible_steps = self.model.grid.get_neighborhood(
            self.pos,
            moore=True,
            include_center=False
        )
        new_position = random.choice(possible_steps)
        self.model.grid.move_agent(self, new_position)

    def reproduce(self):
        # If another prey is in the same cell, reproduce
        this_cell = self.pos
        x, y = this_cell
        this_cell_agents = self.model.grid[x][y]
        for agent in this_cell_agents:
            if isinstance(agent, Prey):
                child = Prey(self.model.next_id(), self.model)
                self.model.schedule.add(child)
                self.model.grid.place_agent(child, self.pos)
                return


class Predator(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)
        self.hunger = 5

    def step(self):
        # print(f"Predator {self.unique_id} executing step at time {self.model.schedule.time}")
        if self.hunger <= 0:
            self.model.schedule.remove(self)
        else:
            self.hunt()
            self.hunger -= 1
            # Schedule next hunt at a random time in the future
            next_event_time = self.model.schedule.time + random.uniform(2, 4)
            self.model.schedule.schedule_event(next_event_time, self)

    def hunt(self):
        # If a prey is in the same cell, eat it
        this_cell = self.pos
        x, y = this_cell
        this_cell_agents = self.model.grid[x][y]
        for agent in this_cell_agents:
            if isinstance(agent, Prey):
                self.model.schedule.remove(agent)
                self.model.grid.remove_agent(agent)

                self.hunger += random.uniform(5, 10)
                return
        else:
            # Move to a neighboring cell
            self.move()

    def move(self):
        # Move to a random neighboring cell
        possible_steps = self.model.grid.get_neighborhood(
            self.pos,
            moore=True,
            include_center=False
        )
        new_position = random.choice(possible_steps)
        self.model.grid.move_agent(self, new_position)

class EcosystemModel(Model):
    def __init__(self, N, width, height):
        super().__init__()
        self.num_agents = N
        self.grid = MultiGrid(width, height, True)
        self.schedule = DiscreteEventScheduler(self)
        self.prey = []
        self.predators = []
        self.datacollector = DataCollector(
            model_reporters={
                "N": lambda m: m.schedule.time,
                "Prey": lambda m: len(m.prey),
                "Predators": lambda m: len(m.predators),
                "Prey average energy": lambda m: sum([a.energy for a in m.prey]) / len(m.prey) if len(m.prey) > 0 else 0,
                "Predator average hunger": lambda m: sum([a.hunger for a in m.predators]) / len(m.predators) if len(m.predators) > 0 else 0,
            }
        )

        # Create predators and prey
        for i in range(self.num_agents):
            if random.random() < 0.5:
                a = Prey(self.next_id(), self)
                self.prey.append(a)
            else:
                a = Predator(self.next_id(), self)
                self.predators.append(a)
            self.schedule.add(a, schedule_now=True)
            # Place the agent on the grid
            x = random.randrange(self.grid.width)
            y = random.randrange(self.grid.height)
            self.grid.place_agent(a, (x, y))

    def step(self):
        self.schedule.step()
        # Update prey and predator lists
        self.prey = [a for a in self.schedule.agents if isinstance(a, Prey)]
        self.predators = [a for a in self.schedule.agents if isinstance(a, Predator)]
        self.datacollector.collect(self)


model = EcosystemModel(N=25, width=10, height=10)
for i in range(100):  # Run for 25 steps
    model.step()
print(model.datacollector.get_model_vars_dataframe())

Both are also available in this ZIP, and below: discrete_event_example_models.zip

Todo:

  • Check if manually needing to schedule first event can be integrated (yes!)
  • Add tests

Ready for review!

This commit introduces the DiscreteEventScheduler, an enhancement to the Mesa time module. This new scheduler is designed for discrete event simulation, where events are scheduled to occur at specific simulation times, rather than at regular, step-wise intervals.

Key Features:
- The scheduler advances the simulation to the time of the next scheduled event, handling multiple events efficiently.
- If multiple events are scheduled for the same time, their execution order is randomized. This is achieved by adding a secondary sorting criterion based on a random value. This ensures fairness and reduces bias that might occur from consistent ordering.

Modifications:
- Added the DiscreteEventScheduler class with methods to schedule and process events.
- Modified the `schedule_event` method to include a random value for secondary sorting in the event queue.
- Adjusted the `step` method to handle events within a given time frame, advancing the simulation in fixed time steps.
Utility function to schedule an event a certain time from now.
Copy link

codecov bot commented Nov 27, 2023

Codecov Report

Attention: 5 lines in your changes are missing coverage. Please review.

Comparison is base (70b2902) 77.10% compared to head (adbf7de) 77.35%.
Report is 4 commits behind head on main.

Files Patch % Lines
mesa/time.py 84.84% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1890      +/-   ##
==========================================
+ Coverage   77.10%   77.35%   +0.25%     
==========================================
  Files          15       15              
  Lines         974     1007      +33     
  Branches      214      220       +6     
==========================================
+ Hits          751      779      +28     
- Misses        195      197       +2     
- Partials       28       31       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

…ndtime

- Add type hints
- Check for negative values when scheduling (you can't schedule backwards in time (but you can on the current time).
- Include the end time in the step (change from < to <=). This enables you to schedule something at time = 1, then do one step of time 1, and have that step executed. Feels intuative.
Add a schedule_now keyword argument to the add() method of the DiscreteEventScheduler. It defaults to True, and if True, it will schedule the first event for the added agent immediately.

Also add a Usage section to the docs.
Add a bunch of tests for the DiscreteEventScheduler
@EwoutH EwoutH marked this pull request as ready for review November 27, 2023 20:01
I don't know how to fix the last one

mesa\mesa\time.py:389:13: S311 Standard pseudo-random generators are not suitable for cryptographic purposes
@EwoutH
Copy link
Member Author

EwoutH commented Nov 27, 2023

Implemented the last stuff, added tests, fixed two ruff errors (the last one I don't understand).

This PR is ready for review. I'm quite happy with it, very curious what everybody thinks!

@rht
Copy link
Contributor

rht commented Nov 28, 2023

How do you implement Poisson activation with this?

mesa/time.py Outdated Show resolved Hide resolved
@EwoutH
Copy link
Member Author

EwoutH commented Nov 28, 2023

How do you implement Poisson activation with this?

You're probably talking about an arrival process right? Interesting question, could you expand a bit on what you're exactly thinking about?

@EwoutH
Copy link
Member Author

EwoutH commented Nov 28, 2023

Right, I thought about it a bit and I think I understand what you mean (please expand a bit next time. PEP 20, explicit is better than implicit).

We are thinking at this from two different ways:

  • I'm thinking from an agent-central behavior principle: An agent takes a certain, stochastic time to do something, and then wants to continue with the next thing, irrespectabele from if the model time step is done or not.
  • You're thinking from a model-coordinated perspective, in which the model draws the next (arrival) time for an event and schedules that.

So currently, the model_step uses a fixed time step. I build it that way to keep it consistent with the other schedulers (you can still do model.step and it advances some predicable time period time_step). This way, data can also be collected in regular intervals, as currently can be done with all other schedulers.

Basically I made the (implicit) design decision: Agents are scheduled with discrete events, but the model is not.

However, if you want to centrally schedule events based on some distribution, like drawing from the Poisson distribution to simulate stochastic arrival times, the fixed time_step of the model doesn't allow that (currently).

If we are going to modify this, the most logical option is for the model to step at each event. But then what does a model step mean, since it's the same as an agent step.

I can see a few options:

  • Just don't support it (for now / in this scheduler)
  • Ask another entity (Agent) to schedule the next stochastic event
  • Drop the fixed model time step

I tried a few quick and dirty implementations, but each option with dropping the fixed model time step looks more complicated an note really like a Mesa scheduler anymore. Therefore, this also becomes a bit of a scoping decision.

I think we need input from @jackiekazil, @tpike3 and @Corvince for that. To what extend do we want to support discrete-event simulation, and at what costs?

@EwoutH
Copy link
Member Author

EwoutH commented Nov 28, 2023

As for the second option, letting arriving agent schedule the next one, we could add a schedule_poisson method to the DiscreteEventScheduler:

def schedule_poisson(self, rate: float, agent: Agent) -> None:
    """ Schedule an event based on Poisson process. """
    if rate <= 0:
        raise ValueError("Rate must be positive")
    delay = random.expovariate(rate)
    self.schedule_in(delay, agent)

Then, an arrival process could look something like this:

class CustomerAgent(Agent):
    def __init__(self, unique_id, model):
        super().__init__(unique_id, model)

    def step(self):
        # Implement customer's behavior here
        # Example: print("Customer", self.unique_id, "is being served.")
        
        # Schedule the next customer's arrival
        next_customer = CustomerAgent(self.unique_id + 1, self.model)
        self.model.schedule.add(next_customer)
        self.model.schedule.schedule_poisson(self.model.lambda_arrival, next_customer)

class ServiceCenterModel(Model):
    def __init__(self, lambda_arrival):
        self.schedule = DiscreteEventScheduler(self)
        self.lambda_arrival = lambda_arrival
        self.next_customer_id = 0

        # Schedule the first customer
        first_customer = CustomerAgent(self.next_customer_id, self)
        self.schedule.add(first_customer)
        self.schedule.schedule_poisson(self.lambda_arrival, first_customer)
        self.next_customer_id += 1

    def step(self):
        self.schedule.step()

# Example usage
lambda_arrival = 0.5  # Average rate of customer arrival
model = ServiceCenterModel(lambda_arrival)
for _ in range(10):  # Simulate 10 time steps
    model.step()

using self.model.random.random() allows seeding and thus reproduceable runs.
Ruff really is a bitch. I liked just Black better.
@tpike3
Copy link
Member

tpike3 commented Nov 28, 2023

Wow! This is great thanks @EwoutH!

Regarding the Poisson activation, I don't think it is necessary for this class. Although for a different mesa.time feature, we should add a PoissonActivation scheduler.

I am fine with merging....thanks!

@EwoutH
Copy link
Member Author

EwoutH commented Nov 28, 2023

Thanks!

Regarding the Poisson activation, I don't think it is necessary for this class. Although for a different mesa.time feature, we should add a PoissonActivation scheduler.

I think adding the schedule_poisson is fairly easy (5 lines of code) and helps some of the situations. On the other had, then we might also want to Gaussian, Uniform, Exponential, etc., so maybe we can better just give some examples on how to do that with the current implementations.

What do you think? (also @rht)

What do you think would be useful for documentation. I attempted to get the docstring as clear as possible, but it might be nice to have a small tutorial or example model somewhere. What do you think would be most useful?

@EwoutH
Copy link
Member Author

EwoutH commented Nov 28, 2023

Had a nice long bike ride, and thought about it some.

In the core, Mesa is an agent-based modelling library. Agent-based modelling is built from the postulate that relatively simple behavior of many individual entities can lead to complex, emergent behavior. In this postulate, the environment is mostly just a shared state, with some dimensions like space and time, though which agent communicate. The agent is active, while the model is passive.

The current DiscreteEventScheduler fits in that narrative, an agent undertakes some task, which takes some fixed or variable amount of time, and then decides what's going to do next. Task/step scheduling is agent driven. It might fit it even better than the other schedulers.

In central discrete-event scheduler, events are mostly driven by a shared entity or model. So flips the principle around: The model decides what's happening to the agents, the model is active while the agents are passive. So while that's extremely interesting, it start to become another domain.

Therefore, I would say that the current implementation is very much in line with what Mesa tries to attempt, and other extension might not be.

The scheduler might need a different name though.

@EwoutH
Copy link
Member Author

EwoutH commented Nov 28, 2023

One potential issue could be that at some point multiple events are (unintended) added to the stack for one agent, in which case it might do too many steps. I tried some mechanisms to circumvent this, but it isn't easy to get events for specific agents out of the heapq queue. So I will consider that future improvement.

As long as you schedule one event for each agent from the model, and then just let the agent itself schedule it's next event when it's done, it works great.

So this thing is far from perfect and far from done, but I think it's a good start, and can be reasonable well extended with future desired behavior. I am mostly thinking about tasks interruptions and system-level events. I've made an issue there at #1893.

This implementation uses a priority queue (heapq) to manage events. Each
event is a tuple of the form (time, random_value, agent), where:
- time (float): The scheduled time for the event.
- random_value (float): A secondary sorting criterion to randomize
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would have been clearer to say that the random_value is used only once for the heapq.push operation. I had to check elsewhere in the code for its usage, until I realized of this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read it for again, I think it’s quite clear, especially together with the comments lower in the code..

@rht
Copy link
Contributor

rht commented Nov 29, 2023

Implementation LGTM. Waiting for agreement from the others for the merge. The Poisson activation can be showcased in an example model or in the how-to guide.

@EwoutH
Copy link
Member Author

EwoutH commented Nov 29, 2023

Thanks! Tom already approved right? Anyone else you would like agreement from? (if so, maybe tag them)

@tpike3
Copy link
Member

tpike3 commented Nov 29, 2023

I think 2 is sufficient, so I will merge. Thanks!

If you could also put the poisson activation into a how to I think that would be very useful

@tpike3 tpike3 merged commit 76229a5 into projectmesa:main Nov 29, 2023
13 checks passed
@EwoutH
Copy link
Member Author

EwoutH commented Nov 29, 2023

Awesome, thanks for merging!

Will work on a How To entry. By the way, I saw we're converting some docs from rst to md, is that also the plan for the howto.rst file?

@EwoutH EwoutH added the experimental Release notes label label Jan 9, 2024
EwoutH pushed a commit that referenced this pull request Apr 10, 2024
# Summary
This PR adds an experiment feature that puts event scheduling at the heart of how MESA works. An earlier draft of this code was shown and discussed in #2032. This code generalizes #1890 by making discrete event scheduling central to how mesa models are run. This makes it trivial to maintain discrete time progression while allowing for event scheduling, thus making it possible to build hybrid ABM-DEVS models. 

# Motive
Agent-based models can be quite slow because, typically, they involve the activation of all agents at each tick. However, this can become very expensive if it can be known upfront that the agent is not active. Combining ABM tick-based activation with event scheduling makes it easy to avoid activating dormant agents. 

For example, in Epstein's civil violence model, agents who are in jail need not be activated until they are released from jail. This release from jail can be scheduled as an event, thus avoiding unnecessary agent activations. Likewise, in Wolf-Sheep with grass, the regrowth of grass patches can be scheduled instead of activating all patches for each tick only to decrement a counter. 

# Implementation
The experimental feature adds three core new classes: Simulator, EventList, and SimulationEvent. 

The simulator is analogous to a numerical solver for ODEs. Theoretically, the idea of a Simulator is rooted in the work of [Zeigler](https://www.sciencedirect.com/book/9780128133705/theory-of-modeling-and-simulation), The Simulator is responsible for controlling and advancing time. The EventList is a heapq sorted list of SimulationEvents. SimulationEvents are sorted based on their time of execution, their priority, and their unique_id. A SimulationEvent is, in essence, a callable that is to be executed at a particular simulation time instant. 

This PR adds two specific simulators: ABMSimulator and DEVSSimulator. ABMSimulator uses integers as the base unit of time and automatically ensures that `model.step` is scheduled for each tick. DEVSSimulator uses float as the base unit of time. It allows for full discrete event scheduling. 

Using these new classes requires a minor modification to a Model instance. It needs a simulator attribute to be able to schedule events. 

# Usage
The basic usage is straightforward as shown below. We instantiate an ABMSimulator, instantiate the model, and call `simulator.setup`. Next, we can run the model for, e.g., 100 time steps). 

```python
    simulator = ABMSimulator()
    
    model = WolfSheep(simulator,25, 25, 60, 40, 0.2, 0.1, 20, seed=15,)

    simulator.setup(model)
    simulator.run(100)
    print(model.time)  # prints 100
    simulator.run(50)  
    print(model.time)  # prints 150
```

The simulator comes with a whole range of methods for scheduling events: `schedule_event_now`, `schedule_event_relative`, `schedule_event_absolute`, and the ABMSimulator also has a `schedule_event_next_tick`. See `experimental/devs/examples/*.*` for more details on how to use these methods.
vitorfrois pushed a commit to vitorfrois/mesa that referenced this pull request Jul 15, 2024
# Summary
This PR adds an experiment feature that puts event scheduling at the heart of how MESA works. An earlier draft of this code was shown and discussed in projectmesa#2032. This code generalizes projectmesa#1890 by making discrete event scheduling central to how mesa models are run. This makes it trivial to maintain discrete time progression while allowing for event scheduling, thus making it possible to build hybrid ABM-DEVS models. 

# Motive
Agent-based models can be quite slow because, typically, they involve the activation of all agents at each tick. However, this can become very expensive if it can be known upfront that the agent is not active. Combining ABM tick-based activation with event scheduling makes it easy to avoid activating dormant agents. 

For example, in Epstein's civil violence model, agents who are in jail need not be activated until they are released from jail. This release from jail can be scheduled as an event, thus avoiding unnecessary agent activations. Likewise, in Wolf-Sheep with grass, the regrowth of grass patches can be scheduled instead of activating all patches for each tick only to decrement a counter. 

# Implementation
The experimental feature adds three core new classes: Simulator, EventList, and SimulationEvent. 

The simulator is analogous to a numerical solver for ODEs. Theoretically, the idea of a Simulator is rooted in the work of [Zeigler](https://www.sciencedirect.com/book/9780128133705/theory-of-modeling-and-simulation), The Simulator is responsible for controlling and advancing time. The EventList is a heapq sorted list of SimulationEvents. SimulationEvents are sorted based on their time of execution, their priority, and their unique_id. A SimulationEvent is, in essence, a callable that is to be executed at a particular simulation time instant. 

This PR adds two specific simulators: ABMSimulator and DEVSSimulator. ABMSimulator uses integers as the base unit of time and automatically ensures that `model.step` is scheduled for each tick. DEVSSimulator uses float as the base unit of time. It allows for full discrete event scheduling. 

Using these new classes requires a minor modification to a Model instance. It needs a simulator attribute to be able to schedule events. 

# Usage
The basic usage is straightforward as shown below. We instantiate an ABMSimulator, instantiate the model, and call `simulator.setup`. Next, we can run the model for, e.g., 100 time steps). 

```python
    simulator = ABMSimulator()
    
    model = WolfSheep(simulator,25, 25, 60, 40, 0.2, 0.1, 20, seed=15,)

    simulator.setup(model)
    simulator.run(100)
    print(model.time)  # prints 100
    simulator.run(50)  
    print(model.time)  # prints 150
```

The simulator comes with a whole range of methods for scheduling events: `schedule_event_now`, `schedule_event_relative`, `schedule_event_absolute`, and the ABMSimulator also has a `schedule_event_next_tick`. See `experimental/devs/examples/*.*` for more details on how to use these methods.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
experimental Release notes label
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants