Source code for modelly.batch.cohorts

"""Counted populations: one row stands for every individual in the same state.

When individuals that share a state (stage, age, sex, patch...) also share
every probability they face, the model does not need one object per
individual. A ``CohortTable`` keeps one row per state with a ``count``, and
the per-individual draws become one binomial draw per row:

* survival with probability ``s``: ``Binomial(count, s)`` survivors (``thin``);
* a two-way outcome with probability ``p`` (sex, mating, emergence):
  ``Binomial(count, p)`` rows move to the new state (``split``);
* an event each member triggers with probability ``p``: ``Binomial(count, p)``
  events (``draw``).

Each has exactly the law of the independent per-individual draws (a sum of
``count`` independent Bernoulli(p) variables is Binomial(count, p)), so the
counts per state have the same distribution as in the individual model; see
``docs/vectorised_phases.md``. The table grows by doubling and ``compact`` /
``merge`` keep it small: it holds as many rows as there are distinct states.

    eggs = CohortTable(rng, stage=np.int8, age=np.int16)
    eggs.add(500, stage=EGG, age=0)
    eggs.thin(survival[eggs["stage"]])
    eggs["age"] += 1
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

import numpy as np
import pandas as pd

COUNT = "count"
INITIAL_ROWS = 64
GROWTH_FACTOR = 2


[docs] class CohortTable: """Rows of (state columns, count); every operation is one numpy call per column.""" def __init__(self, rng: np.random.Generator, **columns: Any) -> None: """Create an empty table with an int64 ``count`` and the given state columns. Args: rng: the model's seeded numpy generator (``model.rng``). columns: state column names and their numpy dtypes. """ if COUNT in columns: raise ValueError(f"'{COUNT}' is reserved for the cohort sizes") self.rng = rng self._dtypes = {COUNT: np.dtype(np.int64)} | { name: np.dtype(dtype) for name, dtype in columns.items() } self._data = { name: np.zeros(INITIAL_ROWS, dtype=dtype) for name, dtype in self._dtypes.items() } self._n = 0 def __len__(self) -> int: """Number of rows (cohorts), not individuals.""" return self._n def __getitem__(self, name: str) -> np.ndarray: """A writable view of column ``name`` over the live rows.""" return self._data[name][: self._n] def __setitem__(self, name: str, values: Any) -> None: """Write column ``name`` for every live row, in place.""" self._data[name][: self._n] = values @property def columns(self) -> list[str]: """``count`` then the state columns.""" return list(self._data)
[docs] def total(self, where: np.ndarray | None = None) -> int: """Individuals in the table, or in the rows selected by the mask ``where``.""" counts = self["count"] return int(counts.sum() if where is None else counts[where].sum())
[docs] def totals_by(self, column: str, minlength: int = 0) -> np.ndarray: """Individuals per value of an integer state column (a weighted bincount).""" return np.bincount( self[column], weights=self["count"], minlength=minlength ).astype(np.int64)
[docs] def add(self, count: Any, **values: Any) -> np.ndarray: """Append cohorts; scalars or equal-length arrays. Returns their row indices. Unset state columns are zero. Cohorts of size zero are kept until ``compact``. """ counts = np.atleast_1d(np.asarray(count, dtype=np.int64)) k = counts.shape[0] for name, value in values.items(): if name not in self._data: raise KeyError(f"unknown column {name!r}") if np.ndim(value) and np.shape(value)[0] != k: raise ValueError( f"column {name!r} has {np.shape(value)[0]} values for {k} cohorts" ) self._reserve(self._n + k) rows = np.arange(self._n, self._n + k) for name, column in self._data.items(): column[rows] = counts if name == COUNT else values.get(name, 0) self._n += k return rows
[docs] def thin(self, survival: Any) -> np.ndarray: """Each count becomes Binomial(count, survival); returns the new counts.""" counts = self["count"] counts[:] = self.rng.binomial(counts, survival) return counts
[docs] def draw(self, p: Any, where: np.ndarray | None = None) -> np.ndarray: """Binomial(count, p) events per row (or per selected row); counts are unchanged.""" counts = self["count"] if where is None else self["count"][where] return self.rng.binomial(counts, p)
[docs] def split( self, p: Any, where: np.ndarray | None = None, **changes: Any ) -> np.ndarray: """Move Binomial(count, p) individuals of each selected row to a new row. The new rows copy the state of their source row, then apply ``changes`` (scalars or one value per selected row). The source rows keep the rest. Returns the new rows' indices. """ rows = np.arange(self._n) if where is None else np.flatnonzero(where) moving = self.rng.binomial(self["count"][rows], p) self["count"][rows] -= moving state = {name: self[name][rows] for name in self._data if name != COUNT} return self.add(moving, **(state | changes))
[docs] def compact(self) -> int: """Drop the empty cohorts, keeping row order; returns how many were dropped.""" keep = self["count"] > 0 kept = int(keep.sum()) dropped = self._n - kept if dropped: for column in self._data.values(): column[:kept] = column[: self._n][keep] self._n = kept return dropped
[docs] def merge(self) -> int: """Combine rows with identical state into one, summing counts; drops empties. Rows come out sorted by state. Returns how many rows were removed. """ self.compact() names = [name for name in self._data if name != COUNT] if not names or self._n == 0: return 0 keys = np.rec.fromarrays([self[name] for name in names], names=names) unique, inverse = np.unique(keys, return_inverse=True) sums = np.bincount(inverse, weights=self["count"]).astype(np.int64) removed = self._n - unique.shape[0] self._n = unique.shape[0] self["count"] = sums for name in names: self[name] = unique[name] return removed
[docs] def to_dataframe(self) -> pd.DataFrame: """A copy of the live rows.""" return pd.DataFrame({name: self[name].copy() for name in self._data})
def _reserve(self, rows: int) -> None: capacity = self._data[COUNT].shape[0] if rows <= capacity: return while capacity < rows: capacity *= GROWTH_FACTOR for name, column in self._data.items(): grown = np.zeros(capacity, dtype=column.dtype) grown[: self._n] = column[: self._n] self._data[name] = grown def __repr__(self) -> str: """Rows, individuals and columns.""" return f"<CohortTable {self._n} cohorts, {self.total():,} individuals, columns={self.columns}>"
[docs] def from_counts( rng: np.random.Generator, counts: Mapping[tuple, int], **columns: Any ) -> CohortTable: """A table from ``{(state values...): count}``, the state in ``columns`` order.""" table = CohortTable(rng, **columns) if counts: states = np.array(list(counts.keys())) table.add( list(counts.values()), **{name: states[:, i] for i, name in enumerate(columns)}, ) return table