Skip to content

Commit b3d90d5

Browse files
authored
Albrja/mic-5664/update-rate-to-probability (#620)
Albrja/mic-5664/update-rate-to-probability Update utility functions for rate to probability and probability to rate conversions - *Category*: Feature - *JIRA issue*: https://jira.ihme.washington.edu/browse/MIC-5664 Changes and notes -updates for rate and probability conversions -Discussion and requested change can be found here: ihmeuw/vivarium_research#1391 ### Testing <!-- Details on how code was verified, any unit tests local for the repo, regression testing, etc. At a minimum, this should include an integration test for a framework change. Consider: plots, images, (small) csv file. -->
1 parent 8e5c9bb commit b3d90d5

File tree

8 files changed

+248
-38
lines changed

8 files changed

+248
-38
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
**3.4.0 - 05/19/25**
2+
3+
- Feature: Update utility functions for rate and probability conversions
4+
15
**3.3.22 - 05/05/25**
26

37
- Add py.typed marker

docs/source/tutorials/exploration.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ configuration by simply printing it.
112112
component_configs: 0
113113
additional_seed:
114114
component_configs: None
115+
rate_conversion_type:
116+
component_configs: linear
115117
time:
116118
start:
117119
year:
@@ -202,10 +204,12 @@ just those subsets if we like.
202204
component_configs: 0
203205
additional_seed:
204206
component_configs: None
207+
rate_conversion_type:
208+
component_configs: linear
205209

206210
This subset of configuration data contains more keys. All of the keys in
207-
our example here (key_columns, map_size, random_seed, and additional_seed)
208-
point directly to values. We can access these values from the simulation
211+
our example here (key_columns, map_size, random_seed, additional_seed,
212+
and rate_conversion_type) point directly to values. We can access these values from the simulation
209213
as well.
210214

211215
.. testcode::
@@ -214,6 +218,7 @@ as well.
214218
print(sim.configuration.randomness.map_size)
215219
print(sim.configuration.randomness.random_seed)
216220
print(sim.configuration.randomness.additional_seed)
221+
print(sim.configuration.randomness.rate_conversion_type)
217222

218223

219224
.. testoutput::
@@ -222,6 +227,7 @@ as well.
222227
1000000
223228
0
224229
None
230+
linear
225231

226232
However, we can no longer modify the configuration since the simulation
227233
has already been setup.
@@ -252,6 +258,8 @@ should be one more layer of keys.
252258
component_configs: 0
253259
additional_seed:
254260
component_configs: None
261+
rate_conversion_type:
262+
component_configs: linear
255263
256264
This last layer reflects a priority level in the way simulation configuration
257265
is managed. The ``component_configs`` under ``map_size``, ``random_seed``, and

src/vivarium/framework/randomness/manager.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from __future__ import annotations
88

99
from collections.abc import Callable
10-
from typing import TYPE_CHECKING
10+
from typing import TYPE_CHECKING, Literal
1111

1212
import pandas as pd
1313

@@ -33,6 +33,7 @@ class RandomnessManager(Manager):
3333
"key_columns": [],
3434
"random_seed": 0,
3535
"additional_seed": None,
36+
"rate_conversion_type": "linear",
3637
}
3738
}
3839

@@ -42,6 +43,7 @@ def __init__(self) -> None:
4243
self._key_columns: list[str] = []
4344
self._key_mapping_: IndexMap | None = None
4445
self._decision_points: dict[str, RandomnessStream] = dict()
46+
self._rate_conversion_type: Literal["linear", "exponential"] = "linear"
4547

4648
@property
4749
def name(self) -> str:
@@ -74,7 +76,7 @@ def setup(self, builder: Builder) -> None:
7476
pop_size = builder.configuration.population.population_size
7577
map_size = max(map_size, 10 * pop_size)
7678
self._key_mapping_ = IndexMap(self._key_columns, map_size)
77-
79+
self._rate_conversion_type = builder.configuration.randomness.rate_conversion_type
7880
self.resources = builder.resources
7981
self._add_constraint = builder.lifecycle.add_constraint
8082
self._add_constraint(self.get_seed, restrict_during=["initialization"])
@@ -95,6 +97,7 @@ def get_randomness_stream(
9597
decision_point: str,
9698
component: Component | None,
9799
initializes_crn_attributes: bool = False,
100+
rate_conversion_type: Literal["linear", "exponential"] = "linear",
98101
) -> RandomnessStream:
99102
"""Provides a new source of random numbers for the given decision point.
100103
@@ -112,6 +115,10 @@ def get_randomness_stream(
112115
in the Common Random Number framework. These streams cannot be
113116
copied and should only be used to generate the state table columns
114117
specified in ``builder.configuration.randomness.key_columns``.
118+
rate_conversion_type
119+
The type of conversion to use. Default is "linear" for a simple
120+
multiplication of rate and time_scaling_factor. The other option is
121+
"exponential".
115122
116123
Returns
117124
-------
@@ -126,7 +133,7 @@ def get_randomness_stream(
126133
with the same identifier.
127134
"""
128135
stream = self._get_randomness_stream(
129-
decision_point, component, initializes_crn_attributes
136+
decision_point, component, initializes_crn_attributes, rate_conversion_type
130137
)
131138
if not initializes_crn_attributes:
132139
# We need the key columns to be created before this stream can be called.
@@ -152,6 +159,7 @@ def _get_randomness_stream(
152159
decision_point: str,
153160
component: Component | None,
154161
initializes_crn_attributes: bool = False,
162+
rate_conversion_type: Literal["linear", "exponential"] = "linear",
155163
) -> RandomnessStream:
156164
if decision_point in self._decision_points:
157165
raise RandomnessError(
@@ -165,6 +173,7 @@ def _get_randomness_stream(
165173
index_map=self._key_mapping,
166174
component=component,
167175
initializes_crn_attributes=initializes_crn_attributes,
176+
rate_conversion_type=rate_conversion_type,
168177
)
169178
self._decision_points[decision_point] = stream
170179
return stream

src/vivarium/framework/randomness/stream.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
import hashlib
3030
from collections.abc import Callable
31-
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
31+
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar
3232

3333
import numpy as np
3434
import numpy.typing as npt
@@ -102,6 +102,7 @@ def __init__(
102102
# TODO [MIC-5452]: all resources should have a component
103103
component: Component | None = None,
104104
initializes_crn_attributes: bool = False,
105+
rate_conversion_type: Literal["linear", "exponential"] = "linear",
105106
):
106107
super().__init__("stream", key, component)
107108
self.key = key
@@ -114,6 +115,15 @@ def __init__(
114115
"""A key-index mapping with a vectorized hash and vectorized lookups."""
115116
self.initializes_crn_attributes = initializes_crn_attributes
116117
"""A boolean indicating whether the stream is used to initialize CRN attributes."""
118+
self.rate_conversion_type = rate_conversion_type
119+
"""The type of rate conversion to use when converting rates to probabilities.
120+
Allowable types are 'linear' or 'exponential'.
121+
"""
122+
if self.rate_conversion_type not in ["linear", "exponential"]:
123+
raise ValueError(
124+
f"Rate conversion type {self.rate_conversion_type} is not implemented. "
125+
"Allowable types are 'linear' or 'exponential'."
126+
)
117127

118128
def _key(self, additional_key: Any = None) -> str:
119129
"""Construct a hashable key from this object's state.
@@ -224,7 +234,9 @@ def filter_for_rate(
224234
The return type will be the same as type(population).
225235
"""
226236
return self.filter_for_probability(
227-
population, rate_to_probability(rate), additional_key
237+
population,
238+
rate_to_probability(rate, rate_conversion_type=self.rate_conversion_type),
239+
additional_key,
228240
)
229241

230242
def filter_for_probability(

src/vivarium/framework/utilities.py

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
from bdb import BdbQuit
1212
from collections.abc import Callable, Sequence
1313
from importlib import import_module
14-
from typing import Any, TypeVar
14+
from typing import Any, Literal, TypeVar
1515

1616
import numpy as np
17+
from loguru import logger
1718

1819
from vivarium.types import NumberLike, NumericArray, Timedelta
1920

@@ -28,20 +29,91 @@ def to_yearly(value: TimeValue, time_step: Timedelta) -> TimeValue:
2829
return value / (time_step.total_seconds() / (60 * 60 * 24 * 365.0))
2930

3031

31-
def rate_to_probability(rate: Sequence[float] | NumberLike) -> NumericArray:
32-
# encountered underflow from rate > 30k
33-
# for rates greater than 250, exp(-rate) evaluates to 1e-109
34-
# beware machine-specific floating point issues
32+
def rate_to_probability(
33+
rate: Sequence[float] | NumberLike,
34+
time_scaling_factor: float | int = 1.0,
35+
rate_conversion_type: Literal["linear", "exponential"] = "linear",
36+
) -> NumericArray:
37+
"""Converts a rate to a probability.
38+
39+
Parameters
40+
----------
41+
rate
42+
The rate to convert to a probability.
43+
time_scaling_factor
44+
The time factor in to scale the rates by. This is usually the time step.
45+
rate_conversion_type
46+
The type of conversion to use. Default is "linear" for a simple multiplcation
47+
of rate and time_scaling_factor. The other option is "exponential" which should be
48+
used for continuous time event driven models.
49+
50+
Returns
51+
-------
52+
An array of floats representing the probability of the converted rates
53+
"""
54+
if rate_conversion_type not in ["linear", "exponential"]:
55+
raise ValueError(
56+
f"Rate conversion type {rate_conversion_type} is not implemented. "
57+
"Allowable types are 'linear' or 'exponential'."
58+
)
59+
if rate_conversion_type == "linear":
60+
# NOTE: The default behavior for randomness streams is to use a rate that is already
61+
# scaled to the time step which is why the default time scaling factor is 1.0.
62+
probability = np.array(rate * time_scaling_factor)
63+
64+
# Clip to 1.0 if the probability is greater than 1.0.
65+
exceeds_one = probability > 1.0
66+
if exceeds_one.any():
67+
probability[exceeds_one] = 1.0
68+
logger.warning(
69+
"The rate to probability conversion resulted in a probability greater than 1.0. "
70+
"The probability has been clipped to 1.0 and indicates the rate is too high. "
71+
)
72+
else:
73+
# encountered underflow from rate > 30k
74+
# for rates greater than 250, exp(-rate) evaluates to 1e-109
75+
# beware machine-specific floating point issues
76+
rate = np.array(rate)
77+
rate[rate > 250] = 250.0
78+
probability: NumericArray = 1 - np.exp(-rate * time_scaling_factor)
3579

36-
rate = np.array(rate)
37-
rate[rate > 250] = 250.0
38-
probability: NumericArray = 1 - np.exp(-rate)
3980
return probability
4081

4182

42-
def probability_to_rate(probability: Sequence[float] | NumberLike) -> NumericArray:
43-
probability = np.array(probability)
44-
rate: NumericArray = -np.log(1 - probability)
83+
def probability_to_rate(
84+
probability: Sequence[float] | NumberLike,
85+
time_scaling_factor: float | int = 1.0,
86+
rate_conversion_type: Literal["linear", "exponential"] = "linear",
87+
) -> NumericArray:
88+
"""Function to convert a probability to a rate.
89+
90+
Parameters
91+
----------
92+
probability
93+
The probability to convert to a rate.
94+
time_scaling_factor
95+
The time factor in to scale the probability by. This is usually the time step.
96+
rate_conversion_type
97+
The type of conversion to use. Default is "linear" for a simple multiplcation
98+
of rate and time_scaling_factor. The other option is "exponential" which should be
99+
used for continuous time event driven models.
100+
101+
Returns
102+
-------
103+
An array of floats representing the rate of the converted probabilities
104+
"""
105+
# NOTE: The default behavior for randomness streams is to use a rate that is already
106+
# scaled to the time step which is why the default time scaling factor is 1.0.
107+
if rate_conversion_type not in ["linear", "exponential"]:
108+
raise ValueError(
109+
f"Rate conversion type {rate_conversion_type} is not implemented. "
110+
"Allowable types are 'linear' or 'exponential'."
111+
)
112+
if rate_conversion_type == "linear":
113+
rate = np.array(probability / time_scaling_factor)
114+
else:
115+
probability = np.array(probability)
116+
rate: NumericArray = -np.log(1 - probability)
45117
return rate
46118

47119

tests/framework/randomness/test_manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def test_randomness_manager_get_randomness_stream() -> None:
2323
rm._clock_ = mock_clock
2424
rm._key_columns = ["age", "sex"]
2525
rm._key_mapping_ = IndexMap(["age", "sex"])
26+
rm._rate_conversion_type = "linear"
2627
stream = rm._get_randomness_stream("test", component)
2728

2829
assert stream.key == "test"

0 commit comments

Comments
 (0)