mesa icon indicating copy to clipboard operation
mesa copied to clipboard

model: Automatically increase time and step count

Open EwoutH opened this issue 1 year ago • 47 comments

This pull request adds automatically increasing of time and step counters in the Mesa model, even if the users overwrites the Model.step() method.

Background

Previously, Mesa required users to manually increment the time and step counters using the _advance_time() method, or by modifying the model._time and model._step values directly. This use of an private method was non-ideal.

  • Closes #2222
  • Initial discussion: https://github.com/projectmesa/mesa/pull/1942#issuecomment-1903386624

Changes

time and step are now incremented automatically within the Model class itself. By default, each call to the step() method will increment both counters by 1.

Users can still override this default behavior. The counters can be adjusted or disabled by passing explicit values to the step() method. For example, setting time=False or step=False will disable incrementing for these respective counters, while time=5 or step=1 will increase the time more than the step.

Implementation details

The core change involves wrapping the user-defined step method with _wrapped_step which handles the incrementing process before executing the user's step logic. This ensures that all increment operations are centrally managed and transparent to the user.

I choose to increment the time before the user step, in the spirit of "a new step" (or a new day) has begun. Then the model does things during the step, and.

def step():
    # Time is increased automatically at the beginning of the step
    self.agents.shuffle().do("step")  # Do things during the step
    self.datacollector.collect()      # Collect data at the end of the step

Here's the updated version of your examples with the initial whitespaces removed from the code blocks:

Example Usage

Below are examples of how the new functionality can be used:

By default, time and step automatically increment by 1:

class MyModel(Model):
    def step(self):
        # User logic here

my_model = MyModel()
my_model.step()

We can also increase the time always by 5:

class MyModel(Model):
    def step(self, time=5):
        # User logic here

my_model = MyModel()
my_model.step()

We can disable time incrementing:

class MyModel(Model):
    def step(self, time=False, step=False):
        # User logic here

my_model = MyModel()
my_model.step()

We could also increment the time in different stages (replicating the current StagedActivation behavior):

class MyModel(Model):
    def step(self, time=0.5):
        model.agents.do("first_thing")
        model._time += 0.5
        model.agents.do("second_thing")

my_model = MyModel()
my_model.step()

Todo:

  • [x] Tests pass
  • [x] Check and if needed, update examples
    • [x] https://github.com/projectmesa/mesa-examples/pull/162
  • [ ] Add tests
  • [ ] Check how this interacts with the experimental DEVS simulator

Resolve:

  • https://github.com/projectmesa/mesa/discussions/2227
    • [ ] Resolution:
  • https://github.com/projectmesa/mesa/discussions/2228
    • [ ] Resolution:
  • https://github.com/projectmesa/mesa/discussions/2229
    • [ ] Resolution:
  • https://github.com/projectmesa/mesa/discussions/2230
    • [ ] Resolution:
  • https://github.com/projectmesa/mesa/discussions/2231
    • [ ] Resolution:

EwoutH avatar Aug 18 '24 12:08 EwoutH

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
Schelling small 🟢 -4.2% [-4.5%, -3.9%] 🔵 +1.4% [+1.3%, +1.6%]
Schelling large 🟢 -5.3% [-6.0%, -4.7%] 🔵 +2.5% [+1.9%, +3.1%]
WolfSheep small 🟢 -5.5% [-6.6%, -4.2%] 🔵 -2.4% [-2.7%, -2.1%]
WolfSheep large 🟢 -6.1% [-6.4%, -5.8%] 🟢 -3.6% [-3.9%, -3.3%]
BoidFlockers small 🟢 -7.6% [-8.2%, -7.0%] 🔵 -3.2% [-3.9%, -2.5%]
BoidFlockers large 🟢 -7.0% [-7.5%, -6.4%] 🔵 -1.4% [-2.1%, -0.7%]

github-actions[bot] avatar Aug 18 '24 12:08 github-actions[bot]

I'll try to look at the code later today, but just some quick first questions/thoughts/concerns.

  1. Do we want to maintain the distinction between step and time? Is this distinction not confusing to a novice user?
  2. Should time not be handled by the simulator?

quaquel avatar Aug 18 '24 13:08 quaquel

  • Do we want to maintain the distinction between step and time? Is this distinction not confusing to a novice user?

I think both can be useful in some cases. Steps are just the number of times model.step() is called (by default). Time can be anything you want.

For example, if I have a model that runs from 8 o'clock in the morning to 22 o'clock in the evening, with 5 minute steps, I might do:

class MyModel(Model):
    def __init__(self):
        ...
        self._time = 8
    def step(self, time=1/12):
        ...

Then I have a human-readable value of the time of day, while the steps can be useful for plotting, datacollection and debugging. So one is human readable and one is an uninterrupted sequence of integers.

Of course, we could aim our documentation and tutorials to mainly use one.

  • Should time not be handled by the simulator?

In my opinion, a model should be runnable without too many additional fluff. A simulator or scheduler should not be required for a simple ABM (and https://github.com/projectmesa/mesa-examples/pull/161 proves those aren't needed for the vast majority of models).

EwoutH avatar Aug 18 '24 13:08 EwoutH

In my opinion, a model should be runnable without too many additional fluff. A simulator or scheduler should not be required for a simple ABM

Fair enough, but then I would keep support for tracking time / the number of ticks also minimal. What about having full time support in the simulator and only have number of ticks/steps in the model class?

quaquel avatar Aug 18 '24 15:08 quaquel

To do stages / simultaneous activation it can be nice to separate steps and time (see example in PR start). Also steps is just a counter and time can be used for a lot more.

Maybe the experimental ABMSimulator is redundant with this PR, and we only need to keep the DEVSSimulator

EwoutH avatar Aug 18 '24 15:08 EwoutH

Maybe the experimental ABMSimulator is redundant with this PR, and we only need to keep the DEVSSimulator

No, ABMSimulator supports event scheduling while also having fixed time advancement (i.e., ABM style ticks).

quaquel avatar Aug 18 '24 15:08 quaquel

I have been thinking about this PR a bit more. I think having an automatic counter for the number of times model.step is called is useful.

However, I would keep the other stuff like time, running automatically for a given number of steps, etc. for the ABMSimulator which already supports most of this via run_for and run_until. Being able to increment steps with more than one, even though step is called only once, as is possible with this API, really makes no sense to me and is confusing. Likewise, if a model needs to translate between steps and wall clock time, why not simply multiply with a fixed constant? Making models where two ticks represent different periods of wall clock time is conceptually confusing and invites bad modeling.

Splitting the functionality between tracking the number of times steps is called in the model and having a separate class for more advanced time management is my preferred way forward. This makes it possible for a user to keep building simple models as currently done (via a for loop as is current practice), but if you want more advanced control over time and running, we have an optional class that can be used.

quaquel avatar Aug 18 '24 18:08 quaquel

Thanks for your insights. I will be thinking about them a bit more, but for now, I see it this way:

  • Steps is the most basic variant. It's like ticks in NetLogo. Just the number. Ideal for simple models that loop model.step().
    • I agree that maybe we need to leave it fixed to 1 (otherwise, what harm does it do if people find a good reason to change is?)
  • Time is the intermediate variant. You can adjust it, conditionally skip it or increase it, let it depend on model or agent variables. It offers a bit of flexibility when you need just a bit more control, or want a human readable value (like in the example).
  • The ABMSimulator is where you do things between ticks, but still want to have ticks and a model step as construct. Just to give some certainty.
  • The DEVS scheduler you can do whatever you want. Go wild.

The ABMSimulator sits (for me) now in a weird place. I accidentally started using the DEVSimulator, before I knew I also wanted a model.step, and getting a model.step back was one or two lines of code. So I don't know if we really need it.

Looking at code complexity, adding and tracking a time aside from step is exactly one line of code. The ABMSimulator is about 90, with additional code you need to understand, document, write tutorial for, etc.

So, my current standpoint:

  • Maybe fix steps to always be 1. But what's the harm from letting it be a nice little hook to mount in to?
  • Keep the time. It adds very little complexity, and I think the examples above show it can sometimes be useful.
  • Consider removing the ABMSimulator, and see if we can just document how to use the DEVSimulator with an Model step. In advanced use cases, implicit is better than explicit.
  • Document that if you start to use a simulator, you should use the simulator time.

Edit: We might allow time to be a full Datetime object. Can imagine some scenarios in which that would be very handy.

EwoutH avatar Aug 18 '24 18:08 EwoutH

steps is bread and butter: used in the batch runner and the visualization (to detect if user has reset the simulation), so it can't be fixed to 1 by default.

rht avatar Aug 18 '24 18:08 rht

I didn't want to run too far ahead, but I was thinking of allowing the index of the DataCollector to be changeable. Default could be steps or time, but you could input anything you did like.

And maybe the same for visualisation.

(with fixed to 1, I meant to fix it to increase always by 1)

EwoutH avatar Aug 18 '24 18:08 EwoutH

Time is the intermediate variant. You can adjust it, conditionally skip it or increase it, let it depend on model or agent variables. It offers a bit of flexibility when you need just a bit more control, or want a human readable value (like in the example)

Why skip it or increase it in a non-fixed way? from a modeling point of view, this makes no sense to me. If "for readability", why should this be supported by MESA?

Consider removing the ABMSimulator, and see if we can just document how to use the DEVSimulator with an Model step. In advanced use cases, implicit is better than explicit.

The ABMSimulator can indeed be done by DEVSSimulator as well. The main use case for me is fixed time advancement (so increment by 1 and automagically calls model.step each tick) while having integer-based event scheduling. Conceptually, it is a real devs-ABM hybrid.

quaquel avatar Aug 18 '24 19:08 quaquel

You can do cute stuff like conditionally modifying your time resolution, to perform more ticks at certain moments:

    def step(self, time=False):
        # Increase volatility and decrease time increment during opening and closing hours
        if 9 <= self._time < 10 or 15 <= self._time < 16:
            self.volatility = 0.03
            self._time += timedelta(minutes=5)
        else:
            self.volatility = 0.01
            self._time += timedelta(minutes=30)

        # Update stock prices
        for stock in self.agents:
            stock.update_price(self.volatility)

If you allow datetime, you can even go a bit further:

        # If we've passed 4:00 PM, move to 9:00 AM the next day
        if self._time >= 16:
            next_day = self._time.date() + timedelta(days=1)
            self._time = datetime.combine(next_day, datetime.min.time()) + timedelta(hours=9)

And imagine you have external data lookups that use the time. Or in your data collector you can aggerate by hour, while still having more resolution available.

It's basically just an additional built-in counter you can use before having to jump immediately to discrete even scheduling.

EwoutH avatar Aug 18 '24 19:08 EwoutH

You can do cute stuff like conditionally modifying your time resolution, to perform more ticks at certain moments:

That's not cute stuff, but bad modeling. It breaks discrete time advancement, which is a core principle behind ABMs. If a model requires variable time steps, you should use DEVS and carefully check all code to ensure time units are handled correctly.

Also, and separately, is this use case so common that it should be supported in a core class of MESA?

quaquel avatar Aug 19 '24 06:08 quaquel

Also, and separately, is this use case so common that it should be supported in a core class of MESA?

If you looked at the PR's timeline, the time attribute was added months before the DEVS PR https://github.com/projectmesa/mesa/pulls?q=is%3Apr+discrete+event+is%3Aclosed. At the time, it was the only official way of having time being tracked. But again, even if the DEVS did not exist, the time attribute doesn't necessarily have to be there by default: for users who need it, they can always define their time attribute as needed, and it would need only a few lines of code for the feature.

rht avatar Aug 19 '24 08:08 rht

That's not cute stuff, but bad modeling. It breaks discrete time advancement, which is a core principle behind ABMs.

And that's why it's not the default. I agree that it's not bad practice for most cases. But if an users finds a novel case or use, why deny the flexibility?


I'm going to summarize my points:

  • By default, model.time is increased with a fixed amount of 1 each step.
  • model.time can be used as a human-readable values and potentially Datetime object
    • This allows data lookups in time-based lookup tables, decision logic based on a human-readable value, etc.
  • model.time can be used to acknowledge that a step has different sub-steps. It can be increased by a custom (but fixed) amount if you want divide you steps in different substeps
    • This is exactly how StagedActivation now works:

      The scheduler also tracks steps and time separately, allowing fractional time increments based on the number of stages. Time advances in fractional increments of 1 / (# of stages), meaning that 1 step = 1 unit of time.

    class MyModel(Model):
        def step(self, time=0.25):
            model.agents.do("morning")
            model.time += 0.25
            model.agents.do("afternoon")
            model.time += 0.25
            model.agents.do("evening")
            model.time += 0.25
            model.agents.do("night")
    
    my_model = MyModel()
    my_model.step()
    
    • Which means we can even keep track of both the full steps and the sub-steps, without having to do any rounding or flooring or sorts.
  • It gives users an additional built-in variable to play with, offering a nice hook to tie into, like a proper library would.
  • It adds very little complexity (a single line of code in our codebase, and none for users that don't want to modify it).

EwoutH avatar Aug 19 '24 08:08 EwoutH

I think my main objection is not to time attribute perse but to the use of time and step as optional keyword arguments to model.step. This is a new feature, might break existing models, and gives rise to a lot of the problems I have been alluding to. If users want to do something like this in their custom models, they can do so. But why support it within MESA itself given the conceptual issues I have been highlighting?

Staged activation has its own internal time attribute. Which is conceptually strange. As argued before, there should be a single truth for time. I have to think about substeps with staged activation. I am unsure about how to handle that properly but seperating step and time might indeed be defendable for this.

quaquel avatar Aug 19 '24 11:08 quaquel

Would removing the step=1 argument from the Model.step be an acceptable compromise for you, and then it will always be increased by 1?


Fun fact: I have a DEVS model in development right now where a steps counter would be very useful, since I still have a model step and want to do something once every N steps, while the remainder of the model is DEVS controlled.

EwoutH avatar Aug 20 '24 08:08 EwoutH

Wait there is one use case we haven't discussed: Some people might want to step at another place in the step, than only at the beginning of the step. Thereful it would be useful to turn the automatic step increase of.

We could make the step argument a bool, allowing it either to be False (interpreted as 0) or True (interpreted as 1).

But in general, I'm for a flexible library with sensible defaults. time=1, step=1 are very sensible defaults, while also being very flexible.

EwoutH avatar Aug 20 '24 08:08 EwoutH

Fun fact: I have a DEVS model in development right now where a steps counter would be very useful, since I still have a model step and want to do something once every N steps, while the remainder of the model is DEVS controlled.

use devs_simulator.time? or swicht to the ABMSimulator and use its time attribute should fix this for you.

Wait there is one use case we haven't discussed: Some people might want to step at another place in the step, than only at the beginning of the step.

why? I think with clear documentation (so allways at the start or allways at the end) any use case can easily be accomodated.

quaquel avatar Aug 20 '24 08:08 quaquel

I think at this point we disagree on a philosophical level what a simulation library should offer.

@projectmesa/maintainers, I would love some fresh perspectives.

EwoutH avatar Aug 20 '24 09:08 EwoutH

As an alternative example, in our agents-and-networks gis example, we keep a clock manually : https://github.com/projectmesa/mesa-examples/blob/0ebc4d11711eb59480942d24dd13e9a744744704/gis/agents_and_networks/src/model/model.py#L215

  • By default, model.time is increased with a fixed amount of 1 each step.

This default behavior seems a bit redundant to me. TBH I wasn't really aware of the _time attribute and always used _steps. Unitless time is essentially the same thing as step (or am I missing something?)

If a clock is really really needed, we probably need something more dedicated, like what AnyLogic offers: https://www.anylogic.com/upload/books/new-big-book/16-model-time-date-and-calendar.pdf which is also mentioned above:

We might allow time to be a full Datetime object. Can imagine some scenarios in which that would be very handy.

But this may not be needed by all models, especially those simple models. So it may be better to be optional.

wang-boyu avatar Aug 20 '24 09:08 wang-boyu

So it may be better to be optional.

Let me be perfectly clear: It's 100% optional. You can just do model.step(). Simple models don't need to adjust anything.

As an alternative example, in our agents-and-networks gis example, we keep a clock manually

That's quite interesting. It supports the need for a separate clock/time aside from model.steps.

EwoutH avatar Aug 20 '24 09:08 EwoutH

I think at this point we disagree on a philosophical level what a simulation library should offer.

My last question was really about trying to understand the issue. So not philosopical at al.

In line with @wang-boyu, however, I am in favor of simply counting steps and leave detailed time management to an optional more advanced feature (possibly to be integrated into the simulator classes which offer much of what is described in the book chapter).

quaquel avatar Aug 20 '24 09:08 quaquel

I choose to increment the time before the user step, in the spirit of "a new step" (or a new day) has begun. Then the model does things during the step, and.

But that means the steps start from 1 instead of 0. There is no action happening at steps 0, the zeroth-day. Wouldn't this introduce a breaking change in other area, e.g. batchrunner?

rht avatar Aug 20 '24 09:08 rht

why? I think with clear documentation (so allways at the start or allways at the end) any use case can easily be accomodated.

Yeah on that point I incline to agree with you, I don't see many scenarios where you want to increase the step counter at another point in the step.

What I meant with philosophical differences, is that I think convention over configuration is in general a good principle for Mesa to follow. Why? Because it offers two things that perfectly fits our mixed target audience.

  1. Good defaults, that allow it to really do things with minimal code. Our starting modellers are not programmers, every complexity that they have to think about matters.
    • This PR removes one thing they have to think about (incrementing time) and adds zero things they have to think about
    • See as an another example, how this removed a lot of boilerplate: https://github.com/projectmesa/mesa-examples/pull/161
  2. Highly flexible configuration. We're a library. Our classes get literarily subclassed to extend them. We target a wide variety of researchers. What do they want? Nice code hooks where they can tie into, so save themselves work when implementing complex problems. That's why the AgentSet, DEVS, PropertyLayers and a lot of other stuff exists. This is just another hook people can tie into. And an extremely low complexity one on our side.

And are there use cases for this specific hook? In my opinion, yes:

  • model.time can be used as a human-readable values and potentially Datetime object
    • This allows data lookups in time-based lookup tables, decision logic based on a human-readable value, etc.
  • model.time can be used to acknowledge that a step has different sub-steps. It can be increased by a custom (but fixed) amount if you want divide you steps in different substeps
    • This is exactly how StagedActivation now works:

      The scheduler also tracks steps and time separately, allowing fractional time increments based on the number of stages. Time advances in fractional increments of 1 / (# of stages), meaning that 1 step = 1 unit of time.

    class MyModel(Model):
        def step(self, time=0.25):
            model.agents.do("morning")
            model.time += 0.25
            model.agents.do("afternoon")
            model.time += 0.25
            model.agents.do("evening")
            model.time += 0.25
            model.agents.do("night")
    
    my_model = MyModel()
    my_model.step()
    
    • Which means we can even keep track of both the full steps and the sub-steps, without having to do any rounding or flooring or sorts.

EwoutH avatar Aug 20 '24 09:08 EwoutH

There is no action happening at steps 0.

The whole __init__ is happening at step 0. If you have N steps, step 0 is the init and step 1 to N are the steps. I think this would be a good convention for Mesa, also something what flexible and easily explainable is.

EwoutH avatar Aug 20 '24 09:08 EwoutH

Let me be perfectly clear: It's 100% optional.

No it is not. step and time are now reserved keyword arguments used by Model.step. Sorry to be annoying about this. But this PR does more than just automatically increase time and step. And this additional stuff I have issues with. As well as that this PR reopens some earlier discussions about time/clock management in MESA that have never been fully resolved.

quaquel avatar Aug 20 '24 09:08 quaquel

What I meant with philosophical differences, is that I think convention over configuration is in general a good principle for Mesa to follow. Why? Because it offers two things that perfectly fits our mixed target audience.

Ok, clear and I don't disagree with you on the principle. I think the disagreement is rather how to go about it in this particular case. A lot is handled here through keyword arguments. My intuition, in contrast, is to have basic step counting as default and have classes for more advanced time management.

quaquel avatar Aug 20 '24 10:08 quaquel

There is no action happening at steps 0.

The whole __init__ is happening at step 0. If you have N steps, step 0 is the init and step 1 to N are the steps. I think this would be a good convention for Mesa, also something what flexible and easily explainable is.

Regardless of my opinion on this, this means it is a breaking change to the existing convention. And would require documentation in the release note. The max_steps in the batch runner stopping condition needs to be augmented by 1, and so on. This is a lot of change for a PR meant to only automatically increase the steps count.

rht avatar Aug 20 '24 10:08 rht

Okay, this PR is indeed opening a lot of old, partially unresolved issues and discussions

  1. Increasing the step counter automatically
  2. Does we want an time/clock in Mesa
  3. The order of which things happen in a step and what counts as the first step
  4. How to handle (private) / reserved variable in Mesa, considering we get subclassed all the time
  5. What do we want with schedulers in future (if at all)

Shall we put this PR on pause and open a separate discussion topics on each of them?


On a bit of a personal note: This issue was open for half a year, and I was so proud and excited I finally found a solution and everything clicked in a very nice and elegant way yesterday. Just a few lines of code, very pretty API, sensible defaults, yet very flexible. Finally properly standardized step order and counting, complexity removed from user flexibility, it all just clicked together. All examples passed and only one small test case needed to be updated, in line with the philosophy behind it.

Never celebrate your victory early.

EwoutH avatar Aug 20 '24 10:08 EwoutH