Skip to content

Commit 16464e1

Browse files
authored
Albrja/mic 6263/general fuzzy output (#107)
Albrja/mic 6263/general fuzzy output Add new dataclass and FuzzyChecker method to return class for proportion tests - *Category*: Feature - *JIRA issue*: https://jira.ihme.washington.edu/browse/MIC-6263 Changes and notes -adds new dataclass to store metadata for each proportion test -adds method on FuzzyChecker to return class and not raise assertions
1 parent 5148550 commit 16464e1

File tree

2 files changed

+125
-50
lines changed

2 files changed

+125
-50
lines changed

src/vivarium_testing_utils/fuzzy_checker.py

Lines changed: 107 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#################
44
from __future__ import annotations
55

6+
from dataclasses import dataclass
67
from functools import cache
78
from pathlib import Path
89
from typing import Any
@@ -14,6 +15,34 @@
1415
from scipy.stats._distn_infrastructure import rv_continuous_frozen, rv_discrete_frozen
1516

1617

18+
@dataclass
19+
class TestResult:
20+
"""Class to store metadata for individual tests run by FuzzyChecker."""
21+
22+
name: str
23+
"""Name of the test proportion being calculated."""
24+
name_additional: str
25+
"""Additional name for test, used for when the same proportion is calculated multiple times."""
26+
observed_proportion: float
27+
"""The observed proportion of a specific event happening."""
28+
observed_numerator: int
29+
"""Observed counts of the event happening."""
30+
observed_denominator: int
31+
"""Total counts of opportunities for the event to happen."""
32+
target_lower_bound: float
33+
"""Lower bound of the target proportion range."""
34+
target_upper_bound: float
35+
"""Upper bound of the target proportion range."""
36+
bayes_factor: float
37+
"""Calculated Bayes factor from the test for the observed proportion."""
38+
reject_null: bool
39+
"""Whether the null hypothesis was rejected."""
40+
bug_issue_distribution: tuple[float, float]
41+
"""The bug/issue distribution used in the test."""
42+
no_bug_issue_distribution: rv_discrete_frozen
43+
"""The no-bug/issue distribution used in the test."""
44+
45+
1746
class FuzzyChecker:
1847
"""
1948
This class manages "fuzzy" checks -- that is, checks of values that are
@@ -47,7 +76,7 @@ def fuzzy_checker(output_directory) -> FuzzyChecker:
4776
"""
4877

4978
def __init__(self) -> None:
50-
self.proportion_test_diagnostics: list[dict[str, Any]] = []
79+
self.proportion_test_diagnostics: list[TestResult] = []
5180

5281
def fuzzy_assert_proportion(
5382
self,
@@ -114,6 +143,70 @@ def fuzzy_assert_proportion(
114143
Useful for e.g. specifying the timestep when an assertion happened.
115144
116145
"""
146+
test_proportion = self.test_proportion(
147+
name=name,
148+
name_additional=name_additional,
149+
target_proportion=target_proportion,
150+
observed_numerator=observed_numerator,
151+
observed_denominator=observed_denominator,
152+
bug_issue_beta_distribution_parameters=bug_issue_beta_distribution_parameters,
153+
fail_bayes_factor_cutoff=fail_bayes_factor_cutoff,
154+
)
155+
self.proportion_test_diagnostics.append(test_proportion)
156+
157+
if test_proportion.reject_null:
158+
if test_proportion.observed_proportion < test_proportion.target_lower_bound:
159+
raise AssertionError(
160+
f"{name} value {test_proportion.observed_proportion:g} is significantly less than expected, bayes factor = {test_proportion.bayes_factor:g}"
161+
)
162+
else:
163+
raise AssertionError(
164+
f"{name} value {test_proportion.observed_proportion:g} is significantly greater than expected, bayes factor = {test_proportion.bayes_factor:g}"
165+
)
166+
167+
if (
168+
test_proportion.target_lower_bound > 0
169+
and self._calculate_bayes_factor(
170+
0,
171+
test_proportion.bug_issue_distribution,
172+
test_proportion.no_bug_issue_distribution,
173+
)
174+
< fail_bayes_factor_cutoff
175+
):
176+
logger.warning(
177+
f"Sample size too small to ever find that the simulation's '{name}' value is less than expected."
178+
)
179+
180+
if test_proportion.target_upper_bound < 1 and (
181+
self._calculate_bayes_factor(
182+
observed_denominator,
183+
test_proportion.bug_issue_distribution,
184+
test_proportion.no_bug_issue_distribution,
185+
)
186+
< fail_bayes_factor_cutoff
187+
):
188+
logger.warning(
189+
f"Sample size too small to ever find that the simulation's '{name}' value is greater than expected."
190+
)
191+
192+
if (
193+
fail_bayes_factor_cutoff
194+
> test_proportion.bayes_factor
195+
> inconclusive_bayes_factor_cutoff
196+
):
197+
logger.warning(f"Bayes factor for '{name}' is not conclusive.")
198+
199+
def test_proportion(
200+
self,
201+
name: str = "",
202+
name_additional: str = "",
203+
target_proportion: float | tuple[float, float] = 0.1,
204+
observed_numerator: int = 0,
205+
observed_denominator: int = 0,
206+
bug_issue_beta_distribution_parameters: tuple[float, float] = (0.5, 0.5),
207+
fail_bayes_factor_cutoff: float = 100.0,
208+
) -> TestResult:
209+
"""Convert a dictionary representation of a test result to a TestResult object."""
117210
if isinstance(target_proportion, tuple):
118211
target_lower_bound, target_upper_bound = target_proportion
119212
else:
@@ -124,7 +217,7 @@ def fuzzy_assert_proportion(
124217
), f"There cannot be more events ({observed_numerator}) than opportunities for events ({observed_denominator})"
125218
assert (
126219
target_upper_bound >= target_lower_bound
127-
), f"The lower bound of the V&V target ({target_lower_bound}) cannot be greater than the upper bound ({target_upper_bound})"
220+
), f"The lower bound of the V& V target ({target_lower_bound}) cannot be greater than the upper bound ({target_upper_bound})"
128221

129222
bug_issue_alpha, bug_issue_beta = bug_issue_beta_distribution_parameters
130223
bug_issue_distribution = scipy.stats.betabinom(
@@ -150,54 +243,20 @@ def fuzzy_assert_proportion(
150243

151244
observed_proportion = observed_numerator / observed_denominator
152245
reject_null = bayes_factor > fail_bayes_factor_cutoff
153-
self.proportion_test_diagnostics.append(
154-
{
155-
"name": name,
156-
"name_addl": name_additional,
157-
"observed_proportion": observed_proportion,
158-
"observed_numerator": observed_numerator,
159-
"observed_denominator": observed_denominator,
160-
"target_lower_bound": target_lower_bound,
161-
"target_upper_bound": target_upper_bound,
162-
"bayes_factor": bayes_factor,
163-
"reject_null": reject_null,
164-
}
246+
return TestResult(
247+
name=name,
248+
name_additional=name_additional,
249+
observed_proportion=observed_proportion,
250+
observed_numerator=observed_numerator,
251+
observed_denominator=observed_denominator,
252+
target_lower_bound=target_lower_bound,
253+
target_upper_bound=target_upper_bound,
254+
bayes_factor=bayes_factor,
255+
reject_null=reject_null,
256+
bug_issue_distribution=bug_issue_distribution,
257+
no_bug_issue_distribution=no_bug_issue_distribution,
165258
)
166259

167-
if reject_null:
168-
if observed_proportion < target_lower_bound:
169-
raise AssertionError(
170-
f"{name} value {observed_proportion:g} is significantly less than expected, bayes factor = {bayes_factor:g}"
171-
)
172-
else:
173-
raise AssertionError(
174-
f"{name} value {observed_proportion:g} is significantly greater than expected, bayes factor = {bayes_factor:g}"
175-
)
176-
177-
if (
178-
target_lower_bound > 0
179-
and self._calculate_bayes_factor(
180-
0, bug_issue_distribution, no_bug_issue_distribution
181-
)
182-
< fail_bayes_factor_cutoff
183-
):
184-
logger.warning(
185-
f"Sample size too small to ever find that the simulation's '{name}' value is less than expected."
186-
)
187-
188-
if target_upper_bound < 1 and (
189-
self._calculate_bayes_factor(
190-
observed_denominator, bug_issue_distribution, no_bug_issue_distribution
191-
)
192-
< fail_bayes_factor_cutoff
193-
):
194-
logger.warning(
195-
f"Sample size too small to ever find that the simulation's '{name}' value is greater than expected."
196-
)
197-
198-
if fail_bayes_factor_cutoff > bayes_factor > inconclusive_bayes_factor_cutoff:
199-
logger.warning(f"Bayes factor for '{name}' is not conclusive.")
200-
201260
def _calculate_bayes_factor(
202261
self,
203262
numerator: int,

tests/test_fuzzy_checker.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
if TYPE_CHECKING:
1313
from py._path.local import LocalPath
1414

15-
from vivarium_testing_utils.fuzzy_checker import FuzzyChecker
15+
from vivarium_testing_utils.fuzzy_checker import FuzzyChecker, TestResult
1616

1717
OBSERVED_DENOMINATORS = [100_000, 1_000_000, 10_000_000]
1818
TARGET_PROPORTION = 0.1
@@ -231,7 +231,7 @@ def test_save_diagnostic_output(tmpdir: LocalPath) -> None:
231231
assert len(tmpdir.listdir()) == 1
232232

233233
output = pd.read_csv(tmpdir.listdir()[0])
234-
assert output.shape == (1, 9)
234+
assert output.shape == (1, 11)
235235

236236

237237
###########
@@ -248,3 +248,19 @@ def _make_beta_distribution(lower_bound: float, upper_bound: float) -> rv_contin
248248
a=mean * concentration,
249249
b=(1 - mean) * concentration,
250250
)
251+
252+
253+
def test_fuzzy_checker_test_proportion_no_assertion_error() -> None:
254+
"""Tests that FuzzyChecker.test_proportion returns a TestResult without raising an assertion."""
255+
256+
test_proportion = FuzzyChecker().test_proportion(
257+
name="test_proportion_no_assertion_error",
258+
name_additional="unit_test",
259+
target_proportion=0.9,
260+
observed_numerator=10_008,
261+
observed_denominator=100_000,
262+
bug_issue_beta_distribution_parameters=(0.5, 0.5),
263+
fail_bayes_factor_cutoff=3.0,
264+
)
265+
assert isinstance(test_proportion, TestResult)
266+
assert test_proportion.reject_null is True

0 commit comments

Comments
 (0)