Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document/Unit Test gempyor.parameters #277

Merged
merged 36 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
95e0437
Preliminary documentation for `gempyor.parameters`
TimothyWillard Jul 31, 2024
b914481
Create unit testing utils for `confuse` package
TimothyWillard Jul 31, 2024
500b212
Unit test `Parameters` exceptions in init
TimothyWillard Jul 31, 2024
d21d808
Test fixture for `Parameters` attributes
TimothyWillard Jul 31, 2024
b3e58d0
Added `partials_are_similar` convenience function
TimothyWillard Aug 1, 2024
a375df3
`partials_are_similar` in `Parameters` unit tests
TimothyWillard Aug 1, 2024
f2be834
Test fixtures for `picklable_lamda_alpha/sigma`
TimothyWillard Aug 1, 2024
25f583c
Added test fixture for `get_pnames2pindex`
TimothyWillard Aug 1, 2024
5dec9dd
Test fixture for `parameters_quick_draw`
TimothyWillard Aug 1, 2024
896023f
Test fixture for `Parameters.parameters_load`
TimothyWillard Aug 1, 2024
0fec2da
Test fixture for `Parameters.getParameterDF`
TimothyWillard Aug 2, 2024
893fac2
Add note for `parameters_reduce` test fixture
TimothyWillard Aug 2, 2024
fe830b1
Parameterized insufficient dates test fixutre
TimothyWillard Aug 5, 2024
c9a64be
Add `partials_are_similar` to `__all__`
TimothyWillard Aug 5, 2024
2aa7267
Added `random_distribution_sampler` to `utils`
TimothyWillard Aug 5, 2024
7822ce9
Parameterized `Parameters` attributes test fixture
TimothyWillard Aug 5, 2024
cb95001
Parametrize accessor methods of `Parameters`
TimothyWillard Aug 5, 2024
7e88353
Added `sample_fits_distribution` testing util
TimothyWillard Aug 6, 2024
706f5f3
Parametrize `parameters_quick_draw` test fixture
TimothyWillard Aug 6, 2024
1571710
Parameterize `parameters_load` test fixture
TimothyWillard Aug 6, 2024
0284e56
Parameterize `getParameterDF` test fixture
TimothyWillard Aug 6, 2024
5ccd9cd
`Parameters` attrs fixture `MockParametersInputs`
TimothyWillard Aug 6, 2024
50583bc
Expanded parameters for test fixtures
TimothyWillard Aug 6, 2024
9a381d5
Changed parameterization style on test fixtures
TimothyWillard Aug 6, 2024
33dbf17
Parametrize exception test fixtures
TimothyWillard Aug 6, 2024
e19c0b3
Remove `MockData`, expand `parameters_load` test
TimothyWillard Aug 6, 2024
01c5329
Loosen guarantees on `pnames2pindex` attribute
TimothyWillard Aug 6, 2024
8d38d0f
Types for `Parameters` instance attrs, formatting
TimothyWillard Aug 6, 2024
ad87669
Merge branch 'main' into unit-test-gempyor-parameters
TimothyWillard Aug 6, 2024
a82596d
Consolidated confuse testing helpers
TimothyWillard Aug 7, 2024
2ec9695
Update `Parameters` unit tests to use new helper
TimothyWillard Aug 7, 2024
a01c4ce
Merge main into unit-test-gempyor-parameters
TimothyWillard Aug 12, 2024
db0e025
Merge main into unit-test-gempyor-parameters
TimothyWillard Aug 15, 2024
d00dffc
Merge main into unit-test-gempyor-parameters
TimothyWillard Sep 9, 2024
6cbd803
Merge main into unit-test-gempyor-parameters
TimothyWillard Sep 11, 2024
5b4d062
Added brief desc of example for testing util
TimothyWillard Sep 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 201 additions & 58 deletions flepimop/gempyor_pkg/src/gempyor/parameters.py

Large diffs are not rendered by default.

191 changes: 191 additions & 0 deletions flepimop/gempyor_pkg/src/gempyor/testing.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimothyWillard checking my understanding here: this file effectively establishes a module within gempyor, corresponding to testing support functions, yes?

if so, what are the benefits of making those functions part of the public interface of gempyor? i'm familiar with the downsides, and am more accustomed to seeing this sort of thing as internal to the test suite, not exported. But I can imagine possible benefits?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that understanding is correct.

The unit tests are defined outside of the package so they need to be able to import these functions from gempyor. I could set __all__ = [] anyways to exclude these from the "public interface", there are ways to easily get around the __all__ restrictions. Other options would be either 1) moving these functions into an appropriate place in the tests folder or 2) moving the tests into gempyor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it - this should be a next-issue-problem and seems like the right answer is 2).

if we're thinking of gempyor as its own little capability / package, it should have its own internal tests.

Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@
the optional test dependencies must be installed.
"""

__all__ = [
TimothyWillard marked this conversation as resolved.
Show resolved Hide resolved
"change_directory_to_temp_directory",
"create_confuse_configview_from_dict",
"partials_are_similar",
"sample_fits_distribution",
]

from collections.abc import Generator
import functools
import os
from tempfile import TemporaryDirectory
from typing import Any, Literal

import confuse
import numpy as np
import pytest


Expand All @@ -30,3 +41,183 @@ def change_directory_to_temp_directory() -> Generator[None, None, None]:
yield
os.chdir(current_dir)
temp_dir.cleanup()


def create_confuse_configview_from_dict(
data: dict[str, Any], name: None | str = None
) -> confuse.ConfigView:
"""
Create a ConfigView from a dictionary for unit testing confuse parameters.

Args:
data: The data to populate the confuse ConfigView with.
name: The name of the Subview being created or if is `None` a RootView is
created instead.

Returns:
Either a confuse Subview or RootView depending on the value of `name`.

Examples:
This example gives a brief demonstration of how to represent this yaml:
```yaml
foo: bar
fizz: 123
alphabet: [a, b, c]
mapping:
x: 1
y: 2
```
with this function as a python dict for unit testing purposes.
>>> data = {
TimothyWillard marked this conversation as resolved.
Show resolved Hide resolved
... "foo": "bar",
... "fizz": 123,
... "alphabet": ["a", "b", "c"],
... "mapping": {"x": 1, "y": 2},
... }
>>> rv = create_confuse_configview_from_dict(data)
>>> rv
<RootView: root>
>>> rv.keys()
['foo', 'fizz', 'alphabet', 'mapping']
>>> rv.get()
{'foo': 'bar', 'fizz': 123, 'alphabet': ['a', 'b', 'c'], 'mapping': {'x': 1, 'y': 2}}
>>> rv == rv.root()
True
>>> rv.name
'root'
>>> sv = create_confuse_configview_from_dict(data, "params")
>>> sv
<Subview: params>
>>> sv.keys()
['foo', 'fizz', 'alphabet', 'mapping']
>>> sv.get()
{'foo': 'bar', 'fizz': 123, 'alphabet': ['a', 'b', 'c'], 'mapping': {'x': 1, 'y': 2}}
>>> sv == sv.root()
False
>>> sv.name
'params'
"""
data = {name: data} if name is not None else data
cv = confuse.RootView([confuse.ConfigSource.of(data)])
cv = cv[name] if name is not None else cv
return cv


def partials_are_similar(
f: functools.partial,
g: functools.partial,
check_func: bool = True,
check_args: bool = True,
check_keywords: bool = True,
) -> bool:
"""
Check if two partials are 'similar' enough to be equal.

For most unit testing purposes python's default `__eq__` method does not have the
desired behavior for `functools.partial`. For unit testing purposes it is usually
sufficient that two partials are similar enough. See python/cpython#47814 for more
details on why `__eq__` is tricky for `functools.partial`.

Args:
f: A partial function to test.
g: A partial function to test.
check_func: If the `func` attributes of `f` and `g` should be checked for
equality.
check_args: If the `args` attributes of `f` and `g` should be checked for
equality.
check_keywords: If the `keywords` attributes of `f` and `g` should be checked
for equality.

Returns:
A boolean indicating if `f` and `g` are similar.

Examples:
>>> from functools import partial
>>> a = lambda x, y: x + y
>>> b = partial(a, 1)
>>> c = partial(a, 1.)
>>> b == c
False
>>> partials_are_similar(b, c)
True
"""
if check_func and f.func != g.func:
return False
elif check_args and f.args != g.args:
return False
elif check_keywords and f.keywords != g.keywords:
return False
return True


def sample_fits_distribution(
TimothyWillard marked this conversation as resolved.
Show resolved Hide resolved
sample: float | int,
distribution: Literal[
"fixed", "uniform", "poisson", "binomial", "truncnorm", "lognorm"
],
**kwargs: dict[str, Any],
) -> bool:
"""
Test if a sample fits a distribution with a given set of parameters.

This function tests if the given `sample` could possibly be drawn from the
distribution given with its parameters, but it does not test if it could reasonably
be drawn from that distribution.

Args:
sample: The value to test.
distribution: The name of the distribution to test against.
**kwargs: Further arguments to specify the parameters of a distribution.

Returns:
A boolean indicating if the sample given could be from the distribution.

See Also:
gempyor.utils.random_distribution_sampler

Examples:
>>> sample_fits_distribution(0.0, "fixed", value=0.0)
True
>>> sample_fits_distribution(0.0, "fixed", value=0.5)
False
>>> sample_fits_distribution(0.5, "poisson", lam=3.0)
False
>>> sample_fits_distribution(
... -3.5, "truncnorm", a=-5.5, b=3.4, mean=-1.4, sd=1.1
... )
True
>>> sample_fits_distribution(100000000, "lognorm", meanlog=1.0, sdlog=1.0)
True
"""
# Poisson and binomial only have support on a subset of the integers
if distribution in ["poisson", "binomial"] and not (
isinstance(sample, int) or (isinstance(sample, float) and sample.is_integer())
):
return False
# Now check distribution constraints
if distribution == "fixed":
return bool(np.isclose(sample, kwargs.get("value")))
elif distribution == "uniform":
# Uniform is on [low,high), but want uniform to match fixed when low == high.
return bool(
(
np.isclose(kwargs.get("high"), kwargs.get("low"))
and np.isclose(sample, kwargs.get("low"))
)
or (
np.greater_equal(sample, kwargs.get("low"))
and np.less(sample, kwargs.get("high"))
)
)
elif distribution == "poisson":
return bool(np.greater_equal(sample, 0.0))
elif distribution == "binomial":
return bool(
np.greater_equal(sample, 0.0) and np.less_equal(sample, kwargs.get("n"))
)
elif distribution == "truncnorm":
return bool(
np.greater(sample, kwargs.get("a")) and np.less(sample, kwargs.get("b"))
)
elif distribution == "lognorm":
return bool(np.greater(sample, 0.0))
80 changes: 77 additions & 3 deletions flepimop/gempyor_pkg/src/gempyor/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
import shutil
import subprocess
import time
import typing
from typing import List, Dict, Literal
from typing import Any, Callable, Literal

import confuse
import numpy as np
Expand Down Expand Up @@ -160,7 +159,7 @@ def wrapper(*args, **kwargs):
return decorator


def search_and_import_plugins_class(plugin_file_path: str, path_prefix: str, class_name: str, **kwargs: dict[str, typing.Any]) -> typing.Any:
def search_and_import_plugins_class(plugin_file_path: str, path_prefix: str, class_name: str, **kwargs: dict[str, Any]) -> Any:
"""
Function serving to create a class that finds and imports the necessary modules.

Expand Down Expand Up @@ -413,6 +412,81 @@ def get_log_normal(
return scipy.stats.lognorm(s=sdlog, scale=np.exp(meanlog), loc=0)


def random_distribution_sampler(
TimothyWillard marked this conversation as resolved.
Show resolved Hide resolved
distribution: Literal[
"fixed", "uniform", "poisson", "binomial", "truncnorm", "lognorm"
],
**kwargs: dict[str, Any]
) -> Callable[[], float | int]:
"""
Create function to sample from a random distribution.

Args:
distribution: The type of distribution to generate a sampling function for.
**kwargs: Further parameters that are passed to the underlying function for the
given distribution.

Notes:
The further args expected by each distribution type are:
- fixed: value,
- uniform: low, high,
- poisson: lam,
- binomial: n, p,
- truncnorm: mean, sd, a, b,
- lognorm: meanlog, sdlog.

Returns:
A function that can be called to sample from that distribution.

Raises:
ValueError: If `distribution` is 'binomial' the given `p` must be in (0,1).
NotImplementedError: If `distribution` is not one of the type hinted options.

Examples:
>>> import numpy as np
>>> np.random.seed(123)
>>> uniform_sampler = random_distribution_sampler("uniform", low=0.0, high=3.0)
>>> uniform_sampler()
2.089407556793585
>>> uniform_sampler()
0.8584180048511384
"""
if distribution == "fixed":
# Fixed value is the same as uniform on [a, a)
return functools.partial(
np.random.uniform,
kwargs.get("value"),
kwargs.get("value"),
)
elif distribution == "uniform":
# Uniform on [low, high)
return functools.partial(
np.random.uniform,
kwargs.get("low"),
kwargs.get("high"),
)
elif distribution == "poisson":
# Poisson with mean lambda
return functools.partial(np.random.poisson, kwargs.get("lam"))
elif distribution == "binomial":
p = kwargs.get("p")
if not (0 < p < 1):
raise ValueError(f"p value {p} is out of range [0,1]")
return functools.partial(np.random.binomial, kwargs.get("n"), p)
elif distribution == "truncnorm":
# Truncated normal with mean, sd on interval [a, b]
return get_truncated_normal(
mean=kwargs.get("mean"),
sd=kwargs.get("sd"),
a=kwargs.get("a"),
b=kwargs.get("b"),
).rvs
elif distribution == "lognorm":
# Lognormal distribution with meanlog, sdlog
return get_log_normal(kwargs.get("meanlog"), kwargs.get("sdlog")).rvs
raise NotImplementedError(f"unknown distribution [got: {distribution}]")


@add_method(confuse.ConfigView)
def as_random_distribution(self):
"""
Expand Down
Loading
Loading