Skip to content

Add explicit support for Scenarios#3103

Merged
quaquel merged 55 commits intomesa:mainfrom
quaquel:scenario
Jan 14, 2026
Merged

Add explicit support for Scenarios#3103
quaquel merged 55 commits intomesa:mainfrom
quaquel:scenario

Conversation

@quaquel
Copy link
Copy Markdown
Member

@quaquel quaquel commented Jan 9, 2026

This PR adds explicit support for running scenarios/experiments too Mesa. As discussed in the Mesa 4 goals discussion, it would be really convenient to have proper support for scenarios in Mesa. This PR is a first stab at achieving this.

the problem this solves

Running computational experiments, or scenarios, with ABMs is at the core of ABM use. However, Mesa does not currently offer first-class support for this. Instead, users have to roll their own, or Mesa makes assumptions (in the GUI and the batch runner). Basically, all scenario parameters are assumed to be passed as keyword arguments to the Model.__init__. I personally tend to complement this with class-level attributes that I can easily change (e.g., MyAgent.rationality = "expected_utility"). As a side effect of all this is that if you want to experiment with specific parameters deep down in your agents, you have to pass them first to Model.__init__ and then to the __init__ of your custom agent. Moreover, if you want to experiment over a wide range of parameters, your Model.__init__ becomes unwieldy with too many keyword arguments.

implementation details

This PR adds a new Scenario class and updates the Model class to optionally use it. The scenario class takes keyword arguments only, and all keywords become available as attributes. I also decided to explicitly encapsulate the seeding of the random number generator into the scenario class using the spec-7 compliant rng argument.

With all in place, model.scenario becomes your single source of truth for scenario parameters (hence the explicit inclusion of rng). No need to pass stuff around anymore via __init__'s of tinker with class variables.

API

scenario = Scenario(rng=42, density=0.8, minory_pc=0.5, homophily=0.5, radius=1)

model = Schelling(height=20, width=20, scenario=scenario)

# inside model
	
def __init__(self, height: int = 20, width: int = 20, scenario:Scenario|None=None):
	super().__init__(scenario=scenario)

	self.density = self.scenario["density"]
	self.minority_pc = self.scenario.minority_pc  # attribute access also work

	# rest proceeds as normal, see basic Schelling example

# inside agent
def __init__(self, model, cell, agent_type):
	super().__init__(model)
	self.cell = cell
	self.agent_type = agent_type

	self.homophily = model.scenario["homophily"]
	self.radius = model.scenario["radius"]

@quaquel quaquel added feature Release notes label experimental Release notes label labels Jan 9, 2026
@github-actions

This comment was marked as off-topic.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Jan 10, 2026

I really really like the idea of this. Kind of obvious, weird we didn't think of it earlier.

I personally tend to complement this with class-level attributes that I can easily change (e.g., MyAgent.rationality = "expected_utility"). As a side effect of all this is that if you want to experiment with specific parameters deep down in your agents, you have to pass them first to Model.__init__ and then to the __init__ of your custom agent.

Oh yes this is always a chore.

I would change one thing:

    self.density = self.scenario["density"]
    self.minority_pc = self.scenario["minority_pc"]

What if we can stop doing this? Instead, why not just access scenario parameters directly?

    # Access scenario parameters directly
    self.grid = MultiGrid(width, height, torus=True, rng=self.scenario.rng)
    
    # Create agents based on scenario parameters
    num_agents = int(width * height * self.scenario.density)
    num_minority = int(num_agents * self.scenario.minority_pc)

In this design, it's always explicit what's scenario-based, and what's just a static model variable. You also prevent making errors in the Model init.

Implementation wise, I think you can use a (frozen) dataclass for this.

We could even allow passing an equivalent policy object, that works exactly the same, but now you can semantically separate them.

Anyway, thanks for initiating this, really interesting direction!

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 10, 2026

What if we can stop doing this? Instead, why not just access scenario parameters directly?
You also prevent making errors in the Model init.

See my point 3. Basically, I am using a dict internally. I realize now that I can make attribute access work via __getattr__ or some related magic. I doubt however that this will prevent typos and stuff like that because of all this works via the dynamic nature of python and thus won't be available to static language analysis tools (like type checking, autocomplete).

Implementation-wise, I think you can use a (frozen) dataclass for this.

That was also on my list of things to check, but again, I am not sure due to its dynamic nature. I don't want to force users to subclass Scenario to make any of this work.

update:

  1. attribute style, or dot style access works (and did work for the original commit). Because the keyword arguments are stored in the internal __dict__, they will be found automatically if you use attribute-style access. I enhanced this a bit, so deleting and setting can also use attribute access.
  2. Had a quick look at dataclasses. It can be done via make_dataclass, but that still requires the user to do something like
Scenario = make_dataclass(Scenario, [("a", int), ("b", float)])

So rather cumbersome for no apparent benefit that I can see.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 10, 2026

We could even allow passing an equivalent Policy object, that works exactly the same, but now you can semantically separate them.

From a policy analysis point of view, I agree. It makes good sense to separate policies and scenarios. However, this is a very specific vocabulary. Scenario is more common and often used as a catch-all for model input. The other option that I am considering is to just call it Experiment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall a clean implementation! I like it!

@codebreaker32
Copy link
Copy Markdown
Collaborator

I think we should explicitly state in the documentation that users must perform any validation or logic on the Scenario before calling super().__init__() in their custom models because doing it afterwards will raise ValueError

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 13, 2026

I think we should explicitly state in the documentation that users must perform any validation or logic on the Scenario before calling super().init() in their custom models because doing it afterwards will raise ValueError

I am not sure what you mean. Could you elaborate?

@codebreaker32
Copy link
Copy Markdown
Collaborator

codebreaker32 commented Jan 13, 2026

I mean doing this straight away will result in ValueError because self.model.running = True

class MyModel(Model):
    def __init__(self, scenario):
        super().__init__(scenario=scenario)
        if self.scenario.density < 0:
            self.scenario.density = 0

Either

  1. we can set self.running = False then apply logic or validate scenario OR
  2. we can call super().__init__() afterwards

I think either way, we should explicitly mention it in documentation

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Jan 13, 2026

Could we use the new Model.time variable, to check Model.time > 0 instead of running?

Maybe don't enforce it now, but first think about time progression and run control.

@mariuzka
Copy link
Copy Markdown

For me, this is a stepping stone to making batch run obsolete. In this, scenarios are expected to be simple data containers that only hang around shortly and so the circular reference should not really be an issue.

Out of curiosity, as I try to understand the direction Mesa is currently going: how could scenarios replace batch_run? Where would the variation of parameter values happen?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 14, 2026

Out of curiosity, as I try to understand the direction Mesa is currently going: how could scenarios replace batch_run? Where would the variation of parameter values happen?

See my answer on the mesa 4 discussion. In short, Scenario just encapsulates a single experiment.

A next step is to have some helper functions to create a collection of scenarios. Since there are ample libraries out that for creating experimental designs (e.g., salib, numpy.stats.qmc, ema_workbench), I am inclined to start with a helper function that takes a dataframe or numpy array with a list of columns, which returns an iterable of experiments. I have to think a bit about how to splice in seeds in this, but that is a minor detail.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 14, 2026

@mariuzka, I just added two helper functions to scenario.py that might help clarify my intended direction: scenarios_from_dataframe and scenarios_from_numpy.

While adding these two helper functions, I realized that, from a computational experimentation perspective, there is an important difference between an experiment and any specific stochastic realization of the experiment (i.e., an experiment and a specific seed value). Ideally, there is a way of identifying both easily in post-processing. For example, you might want to group by experiment identifier before taking statistics over the stochastic realizations (i.e., replications using different seeds).

Conceptually, a model need not be aware of either the scenario identifier or the experiment identifier. But when running experiments using, e.g., a ProcessPoolExecutor, it's rather useful if this information is encapsulated in the scenario object. For now, I have solved this by simply adding a keyword argument called _experiment_id; however, I am inclined to incorporate this into the Scenario class itself and handle it in a similar manner to _scenario_id. So, if it is passed in the init, we use that; otherwise, we use an internal counter to generate it. Thoughts are welcome.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Jan 14, 2026

I just added two helper functions to scenario.py that might help clarify my intended direction: scenarios_from_dataframe and scenarios_from_numpy.

While potentially the way to go, I think it's good to have a broader discussion about A) scenario generation and B) scenario object storage. Can we keep the helper function outside this PR, and discuss them for follow-up work in a separate thread?

Maybe open a new discussion in which we can discuss scenarios and experimentation on a higher level.

@EwoutH EwoutH mentioned this pull request Jan 14, 2026
42 tasks
@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 14, 2026

Let's just get this merged. I removed the two functions for now.

Copy link
Copy Markdown
Member

@EwoutH EwoutH left a comment

Choose a reason for hiding this comment

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

Looks great, thanks!

Could you update the PR description to reflect the final state of the PR?

@quaquel quaquel merged commit 690216c into mesa:main Jan 14, 2026
15 checks passed
@quaquel quaquel deleted the scenario branch January 19, 2026 21:02
@EwoutH EwoutH changed the title Add explicit support for scenarios to mesa Add explicit support for Scenarios Feb 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Release notes label feature Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants