Source code for experimental.scenarios.scenario

"""Base Scenario class."""

from __future__ import annotations

from collections import defaultdict
from collections.abc import Sequence
from functools import partial
from itertools import count
from typing import Any, ClassVar

import numpy as np
import pandas as pd

SeedLike = int | np.integer | Sequence[int] | np.random.SeedSequence
RNGLike = np.random.Generator | np.random.BitGenerator


[docs] def rescale_samples( samples: np.ndarray, ranges: np.ndarray, *, inplace: bool = False, ) -> np.ndarray: """Rescale samples from the unit interval [0, 1] to parameter ranges. Parameters ---------- samples : ndarray (n, d) Samples drawn from the unit interval. ranges : ndarray (d, 2) Parameter ranges given as [[min, max], ...]. inplace : bool, optional If True, the input ``samples`` array is modified in place. If False (default), a new array containing the rescaled samples is returned. Returns: ------- ndarray (n, d) Rescaled samples. Notes: ----- The rescaling is performed using NumPy broadcasting. If ``inplace=True``, the original ``samples`` array is overwritten. """ samples = np.asarray(samples) ranges = np.asarray(ranges) mins = ranges[:, 0] scale = ranges[:, 1] - mins if inplace: samples *= scale samples += mins return samples return samples * scale + mins
[docs] class Scenario: """A Scenario class for defining model parameters and experiments. Supports both simple instantiation and type-hinted subclassing: # Simple usage scenario = Scenario(rng=42, density=0.8, minority_pc=0.5) # Type-hinted subclass (recommended for complex models) class MyScenario(Scenario): citizen_density: float = 0.7 cop_vision: int = 7 movement: bool = True scenario = MyScenario(rng=42, cop_vision=10) # Override defaults Attributes: scenario_id: A unique identifier for this scenario, auto-generated starting from 0 experiment_id: Identifies the design point (e.g., row in a QMC sample matrix) replication_id: Identifies the stochastic replication within a design point rng: Random number generator seed value Notes: All parameters are accessible via attribute access (scenario.param). Class-level attributes in subclasses serve as default values. Scenario instances are frozen after initialisation; parameters cannot be modified. To create replications with derived seeds, use replicate(). """ _ids: ClassVar[defaultdict] = defaultdict(partial(count, 0)) _scenario_defaults: ClassVar[dict[str, Any]] = {} __slots__ = ( "__dict__", "_frozen", "initial_rng_state", "replication_id", "rng", "scenario_id", ) @classmethod def __init_subclass__(cls): """Called once when a subclass is created.""" defaults = {} for base in reversed(cls.__mro__): if base is Scenario or base is object: continue annotations = getattr(base, "__annotations__", {}) for key in annotations: if hasattr(base, key) and not key.startswith("_"): defaults[key] = getattr(base, key) cls._scenario_defaults = defaults @classmethod def _reset_counter(cls): """Reset the scenario counter for this class.""" cls._ids[cls] = count(0) def __init__( self, *, rng: RNGLike | SeedLike | None = None, scenario_id: int | None = None, replication_id: int | None = None, **kwargs, ): """Initialize a Scenario. Args: rng: Seed for the random number generator. Accepts any value accepted by numpy.random.default_rng(). scenario.rng is always a Generator after initialisation. The initial rng state is stored in scenario.initial_rng_state and used by spawn_replications() to derive child seeds. scenario_id: Index of the design point in the experiment matrix. replication_id: Index of the stochastic replication for this design point. **kwargs: All other scenario parameters (override class-level defaults). """ self._frozen = False self.scenario_id = ( next(self._ids[type(self)]) if scenario_id is None else scenario_id ) self.replication_id = replication_id self.rng = np.random.default_rng(rng) self.initial_rng_state = self.rng.bit_generator.state self.__dict__.update(self._scenario_defaults) self.__dict__.update(kwargs) self._frozen = True def __setattr__(self, name: str, value: object) -> None: """Prevent any modification after initialisation.""" if getattr(self, "_frozen", False): raise TypeError( f"Scenario is frozen; cannot set '{name}'. " "Create a new Scenario instance instead." ) super().__setattr__(name, value) def __delattr__(self, name: str) -> None: """Prevent deletion of attributes after initialisation.""" if getattr(self, "_frozen", False): raise TypeError(f"Scenario is frozen; cannot delete '{name}'.") super().__delattr__(name) def __getstate__(self): """Return state for pickling.""" return ( self.__dict__.copy(), self.scenario_id, self.replication_id, self.initial_rng_state, ) def __setstate__(self, state): """Restore state when unpickling.""" dict_state, scenario_id, replication_id, initial_rng_state = state self._frozen = False self.scenario_id = scenario_id self.replication_id = replication_id self.initial_rng_state = initial_rng_state bg_class = getattr(np.random, initial_rng_state["bit_generator"]) bg = bg_class() bg.state = initial_rng_state self.rng = np.random.Generator(bg) self.__dict__.update(dict_state) self._frozen = True @property def _stdlib_seed(self) -> int: """Derive a reproducible stdlib seed from the initial rng state.""" inner = self.initial_rng_state["state"]["state"] if hasattr(inner, "tolist"): return int(inner.tolist()[0]) % (2**31) return int(inner) % (2**31) def __iter__(self): """Iterate over (key, value) pairs of the user specified parameters (excluding rng).""" return iter(self.__dict__.items()) def __len__(self): """Return number of user defined parameters (excluding rng).""" return len(self.__dict__)
[docs] def to_dict(self) -> dict[str, Any]: """Return dict representation of the scenario.""" return { **self.__dict__, "scenario_id": self.scenario_id, "replication_id": self.replication_id, "initial_rng_state": self.initial_rng_state, }
[docs] def spawn_replications(self, n: int) -> list[Scenario]: """Spawn n replications of this scenario with deterministically derived seeds. Each replication has identical user provided parameters but a unique random number generator and replication_id. The rng is spawned from the original rng of the base scenario instance. Args: n: Number of replications to create. Returns: A list of n Scenario instances with replication_id 0..n-1. """ inner = self.initial_rng_state["state"]["state"] entropy = inner.tolist() if hasattr(inner, "tolist") else inner child_seeds = np.random.SeedSequence(entropy).spawn(n) return [ self.__class__( rng=child_seeds[i], scenario_id=self.scenario_id, replication_id=i, **self.__dict__, ) for i in range(n) ]
[docs] @classmethod def from_dataframe( cls, experiments: pd.DataFrame, *, rng: SeedLike | None = None, replications: int | None = None, ) -> list[Scenario]: """Turn a dataframe into a list of scenarios. Args: experiments: Dataframe containing the parameters for the scenarios. rng: the number of random seeds to use or a list of seeds. replications: the number of replications to create for each scenario Returns: a list of scenario instances If rng is an integer, numpy will be used to generate that many seed values. """ scenarios = [] for i, entry in enumerate(experiments.to_dict(orient="records")): scenario = cls(rng=rng, scenario_id=i, **entry) if replications is None: scenarios.append(scenario) else: for replication in scenario.spawn_replications(replications): scenarios.append(replication) return scenarios
[docs] @classmethod def from_ndarray( cls, experiments: np.ndarray, parameter_names: list[str], *, rng: SeedLike | None = None, replications: int | None = None, ) -> list[Scenario]: """Turn a numpy array into a list of scenarios. Args: experiments: Dataframe containing the parameters for the scenarios. parameter_names: the names of the parameters rng: the number of random seeds to use or a list of seeds. replications: the number of replications to create for each scenario Returns: a list of scenario instances If rng is an integer, numpy will be used to generate that many seed values. """ if len(parameter_names) != experiments.shape[1]: raise ValueError( "The number of parameter names does not match the number of columns in the numpy array." ) return cls.from_dataframe( pd.DataFrame(experiments, columns=parameter_names), rng=rng, replications=replications, )