From 9bd60f8eac40e4df2edc72e99413f687c15d1c67 Mon Sep 17 00:00:00 2001 From: LarsKue Date: Tue, 25 Feb 2025 15:24:49 +0100 Subject: [PATCH 1/5] allow inclusive bounds in adapter constrain --- bayesflow/adapters/adapter.py | 7 ++- bayesflow/adapters/transforms/constrain.py | 57 +++++++++++++++++++--- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/bayesflow/adapters/adapter.py b/bayesflow/adapters/adapter.py index d39f03fa8..9e252ade5 100644 --- a/bayesflow/adapters/adapter.py +++ b/bayesflow/adapters/adapter.py @@ -233,12 +233,17 @@ def constrain( lower: int | float | np.ndarray = None, upper: int | float | np.ndarray = None, method: str = "default", + inclusive: str = "default", + epsilon: float = 1e-16, ): if isinstance(keys, str): keys = [keys] transform = MapTransform( - transform_map={key: Constrain(lower=lower, upper=upper, method=method) for key in keys} + transform_map={ + key: Constrain(lower=lower, upper=upper, method=method, inclusive=inclusive, epsilon=epsilon) + for key in keys + } ) self.transforms.append(transform) return self diff --git a/bayesflow/adapters/transforms/constrain.py b/bayesflow/adapters/transforms/constrain.py index 7de854c6d..1abcb1b10 100644 --- a/bayesflow/adapters/transforms/constrain.py +++ b/bayesflow/adapters/transforms/constrain.py @@ -16,7 +16,7 @@ @serializable(package="bayesflow.adapters") class Constrain(ElementwiseTransform): """ - Constrains neural network predictions of a data variable to specificied bounds. + Constrains neural network predictions of a data variable to specified bounds. Parameters: String containing the name of the data variable to be transformed e.g. "sigma". See examples below. @@ -28,14 +28,22 @@ class Constrain(ElementwiseTransform): - Double bounded methods: sigmoid, expit, (default = sigmoid) - Lower bound only methods: softplus, exp, (default = softplus) - Upper bound only methods: softplus, exp, (default = softplus) - + inclusive: Indicates which bounds are inclusive (or exclusive). + - "lower": Lower bound is inclusive, upper bound is exclusive. + - "upper": Lower bound is exclusive, upper bound is inclusive. + - "both": Lower and upper bounds are inclusive. + - "none": Lower and upper bounds are exclusive. + - "default": Inclusive bounds are determined by the method. + - Double bounded methods are lower inclusive and upper exclusive. + - Single bounded methods are inclusive at the specified bound. + epsilon: Small value to ensure inclusive bounds are not violated. Examples: 1) Let sigma be the standard deviation of a normal distribution, then sigma should always be greater than zero. - Useage: + Usage: adapter = ( bf.Adapter() .constrain("sigma", lower=0) @@ -45,14 +53,19 @@ class Constrain(ElementwiseTransform): [0,1] then we would constrain the neural network to estimate p in the following way. Usage: - adapter = ( - bf.Adapter() - .constrain("p", lower=0, upper=1, method = "sigmoid") - ) + >>> import bayesflow as bf + >>> adapter = bf.Adapter() + >>> adapter.constrain("p", lower=0, upper=1, method="sigmoid", inclusive="both") """ def __init__( - self, *, lower: int | float | np.ndarray = None, upper: int | float | np.ndarray = None, method: str = "default" + self, + *, + lower: int | float | np.ndarray = None, + upper: int | float | np.ndarray = None, + method: str = "default", + inclusive: str = "default", + epsilon: float = 1e-16, ): super().__init__() @@ -64,6 +77,9 @@ def __init__( if np.any(lower >= upper): raise ValueError("The lower bound must be strictly less than the upper bound.") + if inclusive == "default": + inclusive = "lower" + match method: case "default" | "sigmoid" | "expit" | "logit": @@ -78,6 +94,9 @@ def unconstrain(x): raise TypeError(f"Expected a method name, got {other!r}.") elif lower is not None: # lower bounded case + if inclusive == "default": + inclusive = "lower" + match method: case "default" | "softplus": @@ -99,6 +118,9 @@ def unconstrain(x): raise TypeError(f"Expected a method name, got {other!r}.") else: # upper bounded case + if inclusive == "default": + inclusive = "upper" + match method: case "default" | "softplus": @@ -119,6 +141,25 @@ def unconstrain(x): case other: raise TypeError(f"Expected a method name, got {other!r}.") + match inclusive: + case "lower": + if lower is None: + raise ValueError("Inclusive bounds must be specified.") + lower -= epsilon + case "upper": + if upper is None: + raise ValueError("Inclusive bounds must be specified.") + upper += epsilon + case True | "both": + if lower is None or upper is None: + raise ValueError("Inclusive bounds must be specified.") + lower -= epsilon + upper += epsilon + case False | None | "none": + pass + case other: + raise ValueError(f"Unsupported value for 'inclusive': {other!r}.") + self.lower = lower self.upper = upper From 478c9125b0ef253e6eeee2560ffe8f6015d5bc66 Mon Sep 17 00:00:00 2001 From: LarsKue Date: Tue, 25 Feb 2025 16:38:47 +0100 Subject: [PATCH 2/5] fix serialization issues --- bayesflow/adapters/transforms/constrain.py | 28 ++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/bayesflow/adapters/transforms/constrain.py b/bayesflow/adapters/transforms/constrain.py index 1abcb1b10..c2c9d8f5e 100644 --- a/bayesflow/adapters/transforms/constrain.py +++ b/bayesflow/adapters/transforms/constrain.py @@ -141,33 +141,35 @@ def unconstrain(x): case other: raise TypeError(f"Expected a method name, got {other!r}.") + self.lower = lower + self.upper = upper + self.method = method + self.inclusive = inclusive + self.epsilon = epsilon + + self.constrain = constrain + self.unconstrain = unconstrain + + # do this last to avoid serialization issues match inclusive: case "lower": if lower is None: raise ValueError("Inclusive bounds must be specified.") - lower -= epsilon + lower = lower - epsilon case "upper": if upper is None: raise ValueError("Inclusive bounds must be specified.") - upper += epsilon + upper = upper + epsilon case True | "both": if lower is None or upper is None: raise ValueError("Inclusive bounds must be specified.") - lower -= epsilon - upper += epsilon + lower = lower - epsilon + upper = upper + epsilon case False | None | "none": pass case other: raise ValueError(f"Unsupported value for 'inclusive': {other!r}.") - self.lower = lower - self.upper = upper - - self.method = method - - self.constrain = constrain - self.unconstrain = unconstrain - @classmethod def from_config(cls, config: dict, custom_objects=None) -> "Constrain": return cls(**config) @@ -177,6 +179,8 @@ def get_config(self) -> dict: "lower": self.lower, "upper": self.upper, "method": self.method, + "inclusive": self.inclusive, + "epsilon": self.epsilon, } def forward(self, data: np.ndarray, **kwargs) -> np.ndarray: From e280e4949a0260019912837983bcca0200899aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul-Christian=20B=C3=BCrkner?= Date: Thu, 27 Feb 2025 11:30:40 +0100 Subject: [PATCH 3/5] make inclusive = "both" the default --- bayesflow/adapters/adapter.py | 4 +-- bayesflow/adapters/transforms/constrain.py | 40 ++++++++-------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/bayesflow/adapters/adapter.py b/bayesflow/adapters/adapter.py index 9e252ade5..c964033c7 100644 --- a/bayesflow/adapters/adapter.py +++ b/bayesflow/adapters/adapter.py @@ -233,8 +233,8 @@ def constrain( lower: int | float | np.ndarray = None, upper: int | float | np.ndarray = None, method: str = "default", - inclusive: str = "default", - epsilon: float = 1e-16, + inclusive: str = "both", + epsilon: float = 1e-15, ): if isinstance(keys, str): keys = [keys] diff --git a/bayesflow/adapters/transforms/constrain.py b/bayesflow/adapters/transforms/constrain.py index c2c9d8f5e..1451f5618 100644 --- a/bayesflow/adapters/transforms/constrain.py +++ b/bayesflow/adapters/transforms/constrain.py @@ -29,14 +29,13 @@ class Constrain(ElementwiseTransform): - Lower bound only methods: softplus, exp, (default = softplus) - Upper bound only methods: softplus, exp, (default = softplus) inclusive: Indicates which bounds are inclusive (or exclusive). + - "both" (default): Both lower and upper bounds are inclusive. - "lower": Lower bound is inclusive, upper bound is exclusive. - "upper": Lower bound is exclusive, upper bound is inclusive. - - "both": Lower and upper bounds are inclusive. - - "none": Lower and upper bounds are exclusive. - - "default": Inclusive bounds are determined by the method. - - Double bounded methods are lower inclusive and upper exclusive. - - Single bounded methods are inclusive at the specified bound. + - "none": Both lower and upper bounds are exclusive. epsilon: Small value to ensure inclusive bounds are not violated. + Current default is 1e-15 as this ensures finite outcomes + with the default transformations applied to data exactly at the boundaries. Examples: @@ -64,8 +63,8 @@ def __init__( lower: int | float | np.ndarray = None, upper: int | float | np.ndarray = None, method: str = "default", - inclusive: str = "default", - epsilon: float = 1e-16, + inclusive: str = "both", + epsilon: float = 1e-15, ): super().__init__() @@ -77,9 +76,6 @@ def __init__( if np.any(lower >= upper): raise ValueError("The lower bound must be strictly less than the upper bound.") - if inclusive == "default": - inclusive = "lower" - match method: case "default" | "sigmoid" | "expit" | "logit": @@ -94,9 +90,6 @@ def unconstrain(x): raise TypeError(f"Expected a method name, got {other!r}.") elif lower is not None: # lower bounded case - if inclusive == "default": - inclusive = "lower" - match method: case "default" | "softplus": @@ -118,9 +111,6 @@ def unconstrain(x): raise TypeError(f"Expected a method name, got {other!r}.") else: # upper bounded case - if inclusive == "default": - inclusive = "upper" - match method: case "default" | "softplus": @@ -153,18 +143,16 @@ def unconstrain(x): # do this last to avoid serialization issues match inclusive: case "lower": - if lower is None: - raise ValueError("Inclusive bounds must be specified.") - lower = lower - epsilon + if lower is not None: + lower = lower - epsilon case "upper": - if upper is None: - raise ValueError("Inclusive bounds must be specified.") - upper = upper + epsilon + if upper is not None: + upper = upper + epsilon case True | "both": - if lower is None or upper is None: - raise ValueError("Inclusive bounds must be specified.") - lower = lower - epsilon - upper = upper + epsilon + if lower is not None: + lower = lower - epsilon + if upper is not None: + upper = upper + epsilon case False | None | "none": pass case other: From 5befe351a811605a85119a0f3434cb8b3893de73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul-Christian=20B=C3=BCrkner?= Date: Thu, 27 Feb 2025 11:30:51 +0100 Subject: [PATCH 4/5] add tests for adapter.constrain --- tests/test_adapters/test_adapters.py | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_adapters/test_adapters.py b/tests/test_adapters/test_adapters.py index 41e8c2bb3..9dab225bb 100644 --- a/tests/test_adapters/test_adapters.py +++ b/tests/test_adapters/test_adapters.py @@ -31,3 +31,42 @@ def test_serialize_deserialize(adapter, custom_objects, random_data): deserialized_processed = deserialized(random_data) for key, value in processed.items(): assert np.allclose(value, deserialized_processed[key]) + + +def test_constrain(): + import numpy as np + import warnings + from bayesflow.adapters import Adapter + + data = { + "x1": np.random.exponential(1, size=(32, 1)), + "x2": -np.random.exponential(1, size=(32, 1)), + "x3": np.random.beta(0.5, 0.5, size=(32, 1)), + "x4": np.vstack((np.zeros(shape=(16, 1)), np.ones(shape=(16, 1)))), + "x5": np.zeros(shape=(32, 1)), + "x6": np.zeros(shape=(32, 1)), + } + + adapter = ( + Adapter() + .constrain("x1", lower=0) + .constrain("x2", upper=0) + .constrain("x3", lower=0, upper=1) + .constrain("x4", lower=0, upper=1, inclusive="both") + .constrain("x5", lower=0, inclusive="none") + .constrain("x6", upper=0, inclusive="none") + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + result = adapter(data) + + # checks if transformations indeed have been applied + assert result["x1"].min() < 0.0 + assert result["x2"].max() > 0.0 + assert result["x3"].min() < 0.0 + assert result["x3"].max() > 1.0 + assert np.isfinite(result["x4"].min()) + assert np.isfinite(result["x4"].max()) + assert np.isneginf(result["x5"][0]) + assert np.isinf(result["x6"][0]) From 97f7a72c2c81ebfbf204b29b2b5238ba265947e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul-Christian=20B=C3=BCrkner?= Date: Thu, 27 Feb 2025 13:21:47 +0100 Subject: [PATCH 5/5] improve adapter.constrain tests --- tests/test_adapters/test_adapters.py | 59 ++++++++++++++++++---------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/tests/test_adapters/test_adapters.py b/tests/test_adapters/test_adapters.py index 9dab225bb..7247869d7 100644 --- a/tests/test_adapters/test_adapters.py +++ b/tests/test_adapters/test_adapters.py @@ -34,39 +34,56 @@ def test_serialize_deserialize(adapter, custom_objects, random_data): def test_constrain(): + # check if constraint-implied transforms are applied correctly import numpy as np import warnings from bayesflow.adapters import Adapter data = { - "x1": np.random.exponential(1, size=(32, 1)), - "x2": -np.random.exponential(1, size=(32, 1)), - "x3": np.random.beta(0.5, 0.5, size=(32, 1)), - "x4": np.vstack((np.zeros(shape=(16, 1)), np.ones(shape=(16, 1)))), - "x5": np.zeros(shape=(32, 1)), - "x6": np.zeros(shape=(32, 1)), + "x_lower_cont": np.random.exponential(1, size=(32, 1)), + "x_upper_cont": -np.random.exponential(1, size=(32, 1)), + "x_both_cont": np.random.beta(0.5, 0.5, size=(32, 1)), + "x_lower_disc1": np.zeros(shape=(32, 1)), + "x_lower_disc2": np.zeros(shape=(32, 1)), + "x_upper_disc1": np.ones(shape=(32, 1)), + "x_upper_disc2": np.ones(shape=(32, 1)), + "x_both_disc1": np.vstack((np.zeros(shape=(16, 1)), np.ones(shape=(16, 1)))), + "x_both_disc2": np.vstack((np.zeros(shape=(16, 1)), np.ones(shape=(16, 1)))), } adapter = ( Adapter() - .constrain("x1", lower=0) - .constrain("x2", upper=0) - .constrain("x3", lower=0, upper=1) - .constrain("x4", lower=0, upper=1, inclusive="both") - .constrain("x5", lower=0, inclusive="none") - .constrain("x6", upper=0, inclusive="none") + .constrain("x_lower_cont", lower=0) + .constrain("x_upper_cont", upper=0) + .constrain("x_both_cont", lower=0, upper=1) + .constrain("x_lower_disc1", lower=0, inclusive="lower") + .constrain("x_lower_disc2", lower=0, inclusive="none") + .constrain("x_upper_disc1", upper=1, inclusive="upper") + .constrain("x_upper_disc2", upper=1, inclusive="none") + .constrain("x_both_disc1", lower=0, upper=1, inclusive="both") + .constrain("x_both_disc2", lower=0, upper=1, inclusive="none") ) with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) result = adapter(data) - # checks if transformations indeed have been applied - assert result["x1"].min() < 0.0 - assert result["x2"].max() > 0.0 - assert result["x3"].min() < 0.0 - assert result["x3"].max() > 1.0 - assert np.isfinite(result["x4"].min()) - assert np.isfinite(result["x4"].max()) - assert np.isneginf(result["x5"][0]) - assert np.isinf(result["x6"][0]) + # continuous variables should not have boundary issues + assert result["x_lower_cont"].min() < 0.0 + assert result["x_upper_cont"].max() > 0.0 + assert result["x_both_cont"].min() < 0.0 + assert result["x_both_cont"].max() > 1.0 + + # discrete variables at the boundaries should not have issues + # if inclusive is set properly + assert np.isfinite(result["x_lower_disc1"].min()) + assert np.isfinite(result["x_upper_disc1"].max()) + assert np.isfinite(result["x_both_disc1"].min()) + assert np.isfinite(result["x_both_disc1"].max()) + + # discrete variables at the boundaries should have issues + # if inclusive is not set properly + assert np.isneginf(result["x_lower_disc2"][0]) + assert np.isinf(result["x_upper_disc2"][0]) + assert np.isneginf(result["x_both_disc2"][0]) + assert np.isinf(result["x_both_disc2"][-1])