Source code for modelly.experiments.space

"""The parameter space of an experiment: ranges, choices and fixed values.

A :class:`ScenarioSpace` names every scenario parameter an experiment sets.
A ``(low, high)`` tuple is a :class:`Range`, a list is a :class:`Choice`,
anything else is :class:`Fixed`::

    space = ScenarioSpace(beta=(0.05, 0.5), n=(50, 200), policy=["none", "lockdown"], width=20)

Designs (:mod:`modelly.experiments.designs`) sample the varying dimensions in
the unit cube; :meth:`ScenarioSpace.point` maps one sample to parameter values.
:meth:`ScenarioSpace.typed` checks the names and types against a Scenario class.
"""

from __future__ import annotations

import math
import typing
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any

import numpy as np

RANGE_LENGTH = 2  # a (low, high) tuple


def _plain(value: Any) -> Any:
    """A numpy scalar as the Python value it holds; anything else unchanged."""
    return value.item() if isinstance(value, np.generic) else value


def _is_int(value: Any) -> bool:
    """Whether ``value`` is an integer, booleans excluded."""
    return isinstance(value, int | np.integer) and not isinstance(value, bool)


[docs] @dataclass(frozen=True) class Range: """A continuous (or integer) interval ``[low, high]``, optionally on a log scale. Example: >>> Range(0.05, 0.5).from_unit(0.5) 0.275 """ low: float high: float log: bool = False integer: bool = False def __post_init__(self) -> None: """Reject an empty interval and a log scale that reaches zero.""" if not self.low < self.high: raise ValueError(f"Range needs low < high, got ({self.low}, {self.high})") if self.log and self.low <= 0: raise ValueError("a log-scale Range needs low > 0")
[docs] def from_unit(self, u: float) -> float | int: """The value at position ``u`` in ``[0, 1)`` of the interval.""" if self.integer: width = int(self.high) - int(self.low) + 1 return int(self.low) + min(int(u * width), width - 1) if self.log: low, high = math.log(self.low), math.log(self.high) return float(math.exp(low + u * (high - low))) return float(self.low + u * (self.high - self.low))
[docs] def levels(self, count: int) -> list[float | int]: """``count`` evenly spaced values from low to high (fewer for a short integer range).""" if count < 1: raise ValueError(f"levels must be at least 1, got {count}") bounds = (math.log(self.low), math.log(self.high)) if self.log else None grid = np.linspace(*(bounds or (self.low, self.high)), count) values = np.exp(grid) if self.log else grid if self.integer: return list(dict.fromkeys(round(float(v)) for v in values)) return [float(v) for v in values]
[docs] @dataclass(frozen=True) class Choice: """One of a list of values, each equally likely under a random design. Example: >>> Choice(["a", "b"]).from_unit(0.75) 'b' """ values: tuple[Any, ...] def __init__(self, values: Sequence[Any]) -> None: """Keep the values as a tuple of plain Python values.""" if not values: raise ValueError("Choice needs at least one value") object.__setattr__(self, "values", tuple(_plain(v) for v in values))
[docs] def from_unit(self, u: float) -> Any: """The value whose share of ``[0, 1)`` contains ``u``.""" count = len(self.values) return self.values[min(int(u * count), count - 1)]
[docs] def levels(self, count: int | None = None) -> list[Any]: """Every value (a factorial design takes them all).""" return list(self.values)
[docs] @dataclass(frozen=True) class Fixed: """A value every run takes.""" value: Any
Dimension = Range | Choice
[docs] def as_dimension(value: Any) -> Range | Choice | Fixed: """A Range for a ``(low, high)`` tuple, a Choice for a list, else Fixed.""" if isinstance(value, Range | Choice | Fixed): return value if isinstance(value, tuple): if len(value) != RANGE_LENGTH: raise TypeError(f"a range is a (low, high) tuple, got {value!r}") integer = all(_is_int(v) for v in value) return Range(_plain(value[0]), _plain(value[1]), integer=integer) if isinstance(value, list): return Choice(value) return Fixed(_plain(value))
[docs] @dataclass(frozen=True) class ScenarioSpace: """Every scenario parameter an experiment sets, varying or fixed. Example: >>> space = ScenarioSpace(beta=(0.1, 0.3), policy=["a", "b"], n=100) >>> space.point([0.5, 0.0]) {'beta': 0.2, 'policy': 'a', 'n': 100} """ parameters: dict[str, Range | Choice | Fixed] = field(default_factory=dict) def __init__(self, parameters: Mapping[str, Any] | None = None, /, **named: Any): """Build the space from a mapping and/or keyword arguments.""" merged = {**(parameters or {}), **named} dims = {name: as_dimension(value) for name, value in merged.items()} object.__setattr__(self, "parameters", dims) @property def names(self) -> list[str]: """Every parameter name, in declaration order.""" return list(self.parameters) @property def dimensions(self) -> dict[str, Dimension]: """The varying parameters (ranges and choices), in declaration order.""" return { name: dim for name, dim in self.parameters.items() if not isinstance(dim, Fixed) } @property def fixed(self) -> dict[str, Any]: """The fixed parameters and their values.""" return { name: dim.value for name, dim in self.parameters.items() if isinstance(dim, Fixed) }
[docs] def point(self, unit: Sequence[float]) -> dict[str, Any]: """The parameters at ``unit``, one coordinate in ``[0, 1)`` per dimension.""" dims = self.dimensions if len(unit) != len(dims): raise ValueError(f"expected {len(dims)} coordinates, got {len(unit)}") values = { name: dim.from_unit(float(u)) for (name, dim), u in zip(dims.items(), unit) } return {name: values.get(name, self.fixed.get(name)) for name in self.names}
[docs] def complete(self, values: Mapping[str, Any]) -> dict[str, Any]: """``values`` with the fixed parameters added; unknown names are an error.""" unknown = set(values) - set(self.names) if unknown: raise ValueError(f"parameters not in the space: {sorted(unknown)}") merged = {**self.fixed, **{k: _plain(v) for k, v in values.items()}} return {name: merged[name] for name in self.names if name in merged}
[docs] def typed(self, scenario: type) -> ScenarioSpace: """This space checked against ``scenario``'s declared fields and their types. Unknown names are a ValueError; a range or value of the wrong type is a TypeError. A Range with whole-number bounds over an ``int`` field becomes an integer Range. """ hints = typing.get_type_hints(scenario) declared = set(getattr(scenario, "_scenario_defaults", {})) | { name for name in hints if not name.startswith("_") } unknown = [name for name in self.names if name not in declared] if unknown: raise ValueError( f"{scenario.__name__} declares no parameter(s) {unknown}; " f"it declares {sorted(declared)}" ) return ScenarioSpace( { name: _typed(name, dim, hints.get(name)) for name, dim in self.parameters.items() } )
[docs] def to_dict(self) -> dict[str, Any]: """A JSON-ready description of the space (for the store's fingerprint).""" return { name: {"kind": type(dim).__name__, **vars(dim)} for name, dim in self.parameters.items() }
def _typed(name: str, dim: Range | Choice | Fixed, hint: Any) -> Range | Choice | Fixed: """``dim`` checked against the field's annotation ``hint`` (plain classes only).""" if hint not in (int, float, bool, str): return dim if isinstance(dim, Range): return _typed_range(name, dim, hint) values = dim.values if isinstance(dim, Choice) else (dim.value,) for value in values: if not _matches(value, hint): raise TypeError(f"{name} is {hint.__name__}, got {value!r}") return dim def _typed_range(name: str, dim: Range, hint: type) -> Range: """A Range over a float field is continuous, over an int field whole; bool and str take none.""" if hint is float: return Range(float(dim.low), float(dim.high), log=dim.log) whole = float(dim.low).is_integer() and float(dim.high).is_integer() if hint is int and whole: return Range(int(dim.low), int(dim.high), log=dim.log, integer=True) raise TypeError( f"{name} is {hint.__name__}: a Range({dim.low}, {dim.high}) cannot set it" ) def _matches(value: Any, hint: type) -> bool: """Whether ``value`` fits a field annotated ``hint`` (an int fits a float).""" if hint is float: return isinstance(value, float | int) and not isinstance(value, bool) if hint is int: return _is_int(value) return isinstance(value, hint)