From bd8926f24db01f3870a304220a7ec5a217b5a67a Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Thu, 8 Sep 2022 17:06:14 +1000 Subject: [PATCH 01/10] feat: CofiError and InvalidOptionError --- docs/cofi-examples | 2 +- src/cofi/CMakeLists.txt | 1 + src/cofi/base_problem.py | 16 +++++++++++----- src/cofi/exceptions.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 src/cofi/exceptions.py diff --git a/docs/cofi-examples b/docs/cofi-examples index 55bba232..443d1dda 160000 --- a/docs/cofi-examples +++ b/docs/cofi-examples @@ -1 +1 @@ -Subproject commit 55bba23257fe8f122f9ffec35deafe0cfaa1a5b6 +Subproject commit 443d1dda46d29c40527ae9851f5fb37cef0cd0ce diff --git a/src/cofi/CMakeLists.txt b/src/cofi/CMakeLists.txt index e0605747..8940ab8b 100644 --- a/src/cofi/CMakeLists.txt +++ b/src/cofi/CMakeLists.txt @@ -5,3 +5,4 @@ install(FILES _version.py DESTINATION .) install(FILES base_problem.py DESTINATION .) install(FILES inversion_options.py DESTINATION .) install(FILES inversion.py DESTINATION .) +install(FILES exceptions.py DESTINATION .) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index 3e639663..ed7ebb97 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -5,6 +5,7 @@ import numpy as np from .solvers import solvers_table +from .exceptions import InvalidOptionError class BaseProblem: @@ -922,11 +923,16 @@ def set_data_misfit( ]: self.data_misfit = _FunctionWrapper("data_misfit", self._data_misfit_l2) else: - raise ValueError( - "the data misfit method you've specified isn't supported yet," - " please report an issue here:" - " https://github.com/inlab-geo/cofi/issues if you find it valuable" - " to support it from our side" + # raise ValueError( + # "the data misfit method you've specified isn't supported yet," + # " please report an issue here:" + # " https://github.com/inlab-geo/cofi/issues if you find it valuable" + # " to support it from our side" + # ) + raise InvalidOptionError( + name="data misfit", + invalid_option=data_misfit, + valid_options=["L2"] ) else: self.data_misfit = _FunctionWrapper( diff --git a/src/cofi/exceptions.py b/src/cofi/exceptions.py new file mode 100644 index 00000000..8ad4b1d0 --- /dev/null +++ b/src/cofi/exceptions.py @@ -0,0 +1,31 @@ +from typing import Any, List, Union + + +GITHUB_ISSUE = "https://github.com/inlab-geo/cofi/issues" + +class CofiError(Exception): + """Base class for all CoFI errors""" + pass + +class InvalidOptionError(CofiError, ValueError): + r"""Raised when user passes an invalid option into our methods / functions + + This is a subclass of :exc:`CofiError` and :exc:`ValueError`. + + """ + def __init__(self, *args, name: str, invalid_option: Any, valid_options: Union[List, str]): + super().__init__(*args) + self._name = name + self._invalid_option = invalid_option + self._valid_options = valid_options + + def __str__(self) -> str: + super_msg = super().__str__() + msg = f"the {self._name} you've entered ('{self._invalid_option}') is " \ + f"invalid, please choose from the following: {self._valid_options}.\n\n" \ + f"If you find it valuable to have '{self._invalid_option}' in CoFI, "\ + f"please create an issue here: {GITHUB_ISSUE}" + if len(super_msg)>0: + return msg+"\n\n"+super_msg + else: + return msg From 5c3902709f5aa514493d625ac43555a548b93201 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Fri, 9 Sep 2022 14:45:39 +1000 Subject: [PATCH 02/10] feat: DimensionMismatchError and InsufficientInfoError --- src/cofi/base_problem.py | 56 +++++++++-------------- src/cofi/exceptions.py | 92 +++++++++++++++++++++++++++++++++++--- tests/test_base_problem.py | 5 ++- 3 files changed, 109 insertions(+), 44 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index ed7ebb97..a0aaca63 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -5,7 +5,7 @@ import numpy as np from .solvers import solvers_table -from .exceptions import InvalidOptionError +from .exceptions import DimensionMismatchError, InsufficientInfoError, InvalidOptionError class BaseProblem: @@ -907,7 +907,7 @@ def set_data_misfit( Raises ------ - ValueError + InvalidOptionError when you've passed in a string not in our supported data misfit list """ if isinstance(data_misfit, str): @@ -918,17 +918,9 @@ def set_data_misfit( "euclidean", "L2 norm", "l2 norm", - "mse", - "MSE", ]: self.data_misfit = _FunctionWrapper("data_misfit", self._data_misfit_l2) else: - # raise ValueError( - # "the data misfit method you've specified isn't supported yet," - # " please report an issue here:" - # " https://github.com/inlab-geo/cofi/issues if you find it valuable" - # " to support it from our side" - # ) raise InvalidOptionError( name="data misfit", invalid_option=data_misfit, @@ -972,7 +964,7 @@ def set_regularisation( Raises ------ - ValueError + InvalidOptionError when you've passed in a string not in our supported regularisation list Examples @@ -995,22 +987,15 @@ def set_regularisation( """ if isinstance(regularisation, (Number, str)) or not regularisation: order = regularisation - if isinstance(order, str): - if order in ["inf", "-inf"]: - order = float(order) - elif order not in ["fro", "nuc"]: - raise ValueError( - "the regularisation order you've entered is invalid, please" - " choose from the following:\n{None, 'fro', 'nuc', numpy.inf," - " -numpy.inf} or any positive number" - ) - elif isinstance(order, Number): - if order < 0: - raise ValueError( - "the regularisation order you've entered is invalid, please" - " choose from the following:\n{None, 'fro', 'nuc', numpy.inf," - " -numpy.inf} or any positive number" - ) + if isinstance(order, str) and order not in ["fro", "nuc", "inf", "-inf"] \ + or isinstance(order, Number) and order < 0: + raise InvalidOptionError( + name="regularisation order", + invalid_option=order, + valid_options="[None, 'fro', 'nuc', numpy.inf, -numpy.inf] or any positive number" + ) + elif isinstance(order, str) and order in ["inf", "-inf"]: + order = float(order) _reg = lambda x: np.linalg.norm(x, ord=order) else: _reg = _FunctionWrapper("regularisation_none_lamda", regularisation, args, kwargs) @@ -1139,18 +1124,19 @@ def set_model_shape(self, model_shape: Tuple): Raises ------ - ValueError + DimensionMismatchError when you've defined an initial_model through :func:`BaseProblem.set_initial_model` but their shapes don't match """ if self.initial_model_defined and self._model_shape != model_shape: try: np.reshape(self.initial_model, model_shape) - except ValueError as err: - raise ValueError( - f"the model_shape you've provided {model_shape} doesn't match the" - " initial_model you set which has the shape:" - f" {self.initial_model.shape}" + except ValueError as err: + raise DimensionMismatchError( + entered_dimenion=model_shape, + entered_name="model shape", + expected_dimension=self.initial_model.shape, + expected_source="initial model" ) from err self._model_shape = model_shape @@ -1735,9 +1721,7 @@ def _data_misfit_l2(self, model: np.ndarray) -> Number: if self.residual_defined: res = self.residual(model) return np.linalg.norm(res) / res.shape[0] - raise ValueError( - "insufficient information provided to calculate mean squared error" - ) + raise InsufficientInfoError(needs="residual", needed_for="L2 data misfit") def summary(self): r"""Helper method that prints a summary of current ``BaseProblem`` object to diff --git a/src/cofi/exceptions.py b/src/cofi/exceptions.py index 8ad4b1d0..a3cbd93b 100644 --- a/src/cofi/exceptions.py +++ b/src/cofi/exceptions.py @@ -1,17 +1,33 @@ -from typing import Any, List, Union +from typing import Any, List, Tuple, Union GITHUB_ISSUE = "https://github.com/inlab-geo/cofi/issues" + class CofiError(Exception): """Base class for all CoFI errors""" - pass + def _form_str(self, super_msg, msg): + if super_msg: + return msg + "\n\n" + super_msg + else: + return msg + class InvalidOptionError(CofiError, ValueError): r"""Raised when user passes an invalid option into our methods / functions This is a subclass of :exc:`CofiError` and :exc:`ValueError`. + Parameters + ---------- + *args : Any + passed on directly to :exc:`ValueError` + name: str + name of the item that tries to take the invalid option + invalid_option : Any + the invalid option entered + valid_options : list or str + a list of valid options to choose from, or a string describing valid options """ def __init__(self, *args, name: str, invalid_option: Any, valid_options: Union[List, str]): super().__init__(*args) @@ -25,7 +41,71 @@ def __str__(self) -> str: f"invalid, please choose from the following: {self._valid_options}.\n\n" \ f"If you find it valuable to have '{self._invalid_option}' in CoFI, "\ f"please create an issue here: {GITHUB_ISSUE}" - if len(super_msg)>0: - return msg+"\n\n"+super_msg - else: - return msg + return self._form_str(super_msg, msg) + + +class DimensionMismatchError(CofiError, ValueError): + r"""Raised when model or data shape doesn't match existing problem settings + + This is a subclass of :exc:`CofiError` and :exc:`ValueError`. + + Parameters + ---------- + *args : Any + passed on directly to :exc:`ValueError` + entered_dimension : tuple + dimension entered that conflicts with existing one + entered_name : str + name of the item, the dimension of which is entered + expected_dimension : tuple + dimension expected based on existing information + expected_source : str + name of an existing component that infers ``expected_dimension`` + """ + def __init__( + self, + *args, + entered_dimenion: Tuple, + entered_name: str, + expected_dimension: Tuple, + expected_source: str, + ) -> None: + super().__init__(*args) + self._entered_dimension = entered_dimenion + self._entered_name = entered_name + self._expected_dimension = expected_dimension + self._expected_source = expected_source + + def __str__(self) -> str: + super_msg = super().__str__() + msg = f"the {self._entered_name} you've provided {self._entered_dimension}" \ + f" doesn't match and cannot be reshaped into the dimension you've set" \ + f" for {self._expected_source} which is {self._expected_dimension}" + return self._form_str(super_msg, msg) + + +class InsufficientInfoError(CofiError, RuntimeError): + r"""Raised when insufficient information is supplied to perform operations at hand + + This is a subclass of :exc:`CofiError` and :exc:`RuntimeError`. + + Parameters + ---------- + *args : Any + passed on directly to :exc:`RuntimeError` + needs : list or str + a list of information required to perform the operation, or a string describing + them + needed_for : str + name of the operation to perform or the item to calculate + """ + def __init__(self, *args, needs: Union[List, str], needed_for: str): + super().__init__(*args) + self._needs = needs + self._needed_for = needed_for + + def __str__(self) -> str: + super_msg = super().__str__() + msg = f"insufficient information supplied to calculate {self._needed_for}, " \ + f"needs: {self._needs}" + return self._form_str(super_msg, msg) diff --git a/tests/test_base_problem.py b/tests/test_base_problem.py index 58a14fb2..b76aa887 100644 --- a/tests/test_base_problem.py +++ b/tests/test_base_problem.py @@ -4,6 +4,7 @@ import numpy as np from cofi import BaseProblem +from cofi.exceptions import InsufficientInfoError ############### TEST data loader ###################################################### @@ -321,8 +322,8 @@ def test_invalid_misfit_options(): inv_problem = BaseProblem() with pytest.raises(ValueError): inv_problem.set_data_misfit("FOO") - inv_problem.set_data_misfit("mse") - with pytest.raises(ValueError): + inv_problem.set_data_misfit("L2") + with pytest.raises(InsufficientInfoError): inv_problem.data_misfit(np.array([1, 2, 3])) From e969247bbff70b1f6f9931dad958363e25e0cec6 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Fri, 9 Sep 2022 14:53:33 +1000 Subject: [PATCH 03/10] test: update with new exceptions --- tests/test_base_problem.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_base_problem.py b/tests/test_base_problem.py index b76aa887..6f616f48 100644 --- a/tests/test_base_problem.py +++ b/tests/test_base_problem.py @@ -4,7 +4,7 @@ import numpy as np from cofi import BaseProblem -from cofi.exceptions import InsufficientInfoError +from cofi.exceptions import DimensionMismatchError, InsufficientInfoError, InvalidOptionError ############### TEST data loader ###################################################### @@ -266,9 +266,9 @@ def test_set_misfit_reg_inf(inv_problem_with_misfit): def test_invalid_reg_options(): inv_problem = BaseProblem() - with pytest.raises(ValueError): + with pytest.raises(InvalidOptionError): inv_problem.set_regularisation("FOO") - with pytest.raises(ValueError): + with pytest.raises(InvalidOptionError): inv_problem.set_regularisation(-1) @@ -320,7 +320,7 @@ def test_set_data_fwd_misfit_inbuilt_reg_inbuilt(inv_problem_with_data): def test_invalid_misfit_options(): inv_problem = BaseProblem() - with pytest.raises(ValueError): + with pytest.raises(InvalidOptionError): inv_problem.set_data_misfit("FOO") inv_problem.set_data_misfit("L2") with pytest.raises(InsufficientInfoError): @@ -368,7 +368,7 @@ def test_check_defined(): assert inv_problem.initial_model_defined assert inv_problem.model_shape_defined assert inv_problem.model_shape == (3,) - with pytest.raises(ValueError): + with pytest.raises(DimensionMismatchError): inv_problem.set_model_shape((2, 1)) inv_problem.set_model_shape((3, 1)) From d7b380dd146a4bfebdc49820c2de41496d818c5d Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Fri, 9 Sep 2022 22:44:41 +1000 Subject: [PATCH 04/10] feat: NotDefinedError --- src/cofi/base_problem.py | 259 ++++++++++++++++--------------------- src/cofi/exceptions.py | 25 ++++ tests/test_base_problem.py | 56 ++++---- 3 files changed, 164 insertions(+), 176 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index a0aaca63..ef7eb439 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -5,7 +5,12 @@ import numpy as np from .solvers import solvers_table -from .exceptions import DimensionMismatchError, InsufficientInfoError, InvalidOptionError +from .exceptions import ( + DimensionMismatchError, + InsufficientInfoError, + InvalidOptionError, + NotDefinedError +) class BaseProblem: @@ -254,13 +259,10 @@ def objective(self, model: np.ndarray, *args, **kwargs) -> Number: Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`objective` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="objective") def log_posterior(self, model: np.ndarray, *args, **kwargs) -> Number: """Method for computing the log of posterior probability density given a model @@ -276,11 +278,13 @@ def log_posterior(self, model: np.ndarray, *args, **kwargs) -> Number: ------- Number the posterior probability density value + + Raises + ------ + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`log_posterior` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="log_posterior") def log_posterior_with_blobs( self, model: np.ndarray, *args, **kwargs @@ -302,11 +306,13 @@ def log_posterior_with_blobs( Tuple[Number] the posterior probability density value, and other information you've set to return together with the former + + Raises + ------ + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`log_posterior_with_blobs` is required in the solving approach but you " - "haven't implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="log_posterior_with_blobs") def log_prior(self, model: np.ndarray, *args, **kwargs) -> Number: """Method for computing the log of prior probability density given a model @@ -322,11 +328,13 @@ def log_prior(self, model: np.ndarray, *args, **kwargs) -> Number: ------- Number the prior probability density value + + Raises + ------ + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`log_prior` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="log_prior") def log_likelihood(self, model: np.ndarray, *args, **kwargs) -> Number: """Method for computing the log of likelihood probability density given a model @@ -342,11 +350,13 @@ def log_likelihood(self, model: np.ndarray, *args, **kwargs) -> Number: ------- Number the likelihood probability density value + + Raises + ------ + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`log_likelihood` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="log_likelihood") def gradient(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: """Method for computing the gradient of objective function with respect to model, given a model @@ -363,13 +373,10 @@ def gradient(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`gradient` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="gradient") def hessian(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: """Method for computing the Hessian of objective function with respect to model, given a model @@ -386,13 +393,10 @@ def hessian(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`hessian` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="hessian") def hessian_times_vector( self, model: np.ndarray, vector: np.ndarray, *args, **kwargs @@ -413,13 +417,10 @@ def hessian_times_vector( Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`hessian_times_vector` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="hessian_times_vector") def residual(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: r"""Method for computing the residual vector given a model. @@ -436,36 +437,30 @@ def residual(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`residual` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="residual") def jacobian(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: - r"""Method for computing the Jacobian of forward function with respect to model, given a model + r"""method for computing the jacobian of forward function with respect to model, given a model - Parameters + parameters ---------- model : np.ndarray a model to evaluate - Returns + returns ------- np.ndarray - the Jacobian matrix, :math:`\frac{\partial{\text{forward}(\text{model})}}{\partial\text{model}}` + the jacobian matrix, :math:`\frac{\partial{\text{forward}(\text{model})}}{\partial\text{model}}` - Raises + raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`jacobian` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="jacobian") def jacobian_times_vector( self, model: np.ndarray, vector: np.ndarray, *args, **kwargs @@ -486,13 +481,10 @@ def jacobian_times_vector( Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`jacobian_times_vector` is required in the solving approach but you" - " haven't implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="jacobian_times_vector") def data_misfit(self, model: np.ndarray, *args, **kwargs) -> Number: """Method for computing the data misfit value given a model @@ -509,13 +501,10 @@ def data_misfit(self, model: np.ndarray, *args, **kwargs) -> Number: Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`data_misfit` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="data_misfit") def regularisation(self, model: np.ndarray, *args, **kwargs) -> Number: """Method for computing the regularisation value given a model @@ -532,13 +521,10 @@ def regularisation(self, model: np.ndarray, *args, **kwargs) -> Number: Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`regularisation` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="regularisation") def forward(self, model: np.ndarray, *args, **kwargs) -> Union[np.ndarray, Number]: """Method to perform the forward operation given a model @@ -555,13 +541,10 @@ def forward(self, model: np.ndarray, *args, **kwargs) -> Union[np.ndarray, Numbe Raises ------ - NotImplementedError - when this method is not set and cannot be deduced + NotDefinedError + when this method is not set and cannot be generated from known information """ - raise NotImplementedError( - "`forward` is required in the solving approach but you haven't" - " implemented or added it to the problem setup" - ) + raise NotDefinedError(needs="forward") # TO ADD a set method, remember to do the following: # - def set_something(self, something) @@ -1273,15 +1256,12 @@ def data(self) -> np.ndarray: Raises ------ - NameError - when it's not defined by methods above + NotDefinedError + when this property has not been defined by methods above """ if hasattr(self, "_data"): return self._data - raise NameError( - "data has not been set, please use either `set_data()` or " - "`set_data_from_file()` to add data to the problem setup" - ) + raise NotDefinedError(needs="data") @property def data_covariance(self) -> np.ndarray: @@ -1290,16 +1270,12 @@ def data_covariance(self) -> np.ndarray: Raises ------ - NameError - when it's not defined by methods above + NotDefinedError + when this property has not been defined by methods above """ if hasattr(self, "_data_covariance"): return self._data_covariance - raise NameError( - "data covariance has not been set, please use either" - " `set_data_covariance()`, `set_data()`, or `set_data_from_file()` to add" - " data covariance to the problem setup" - ) + raise NotDefinedError(needs="data covariance matrix") @property def data_covariance_inv(self) -> np.ndarray: @@ -1308,16 +1284,12 @@ def data_covariance_inv(self) -> np.ndarray: Raises ------ - NameError - when it's not defined by methods above + NotDefinedError + when this property has not been defined by methods above """ if hasattr(self, "_data_covariance_inv"): return self._data_covariance_inv - raise NameError( - "data covariance inv has not been set, please use either" - " `set_data_covariance_inv()`, `set_data()`, or `set_data_from_file()` to" - " add data covariance to the problem setup" - ) + raise NotDefinedError(needs="inverse data covariance matrix") @property def initial_model(self) -> np.ndarray: @@ -1326,15 +1298,13 @@ def initial_model(self) -> np.ndarray: Raises ------ - NameError - when it's not defined (by :func:`BaseProblem.set_initial_model`) + NotDefinedError + when this property has not been defined (by + :func:`BaseProblem.set_initial_model`) """ if hasattr(self, "_initial_model"): return self._initial_model - raise NameError( - "initial model has not been set, please use `set_initial_model()`" - " to add to the problem setup" - ) + raise NotDefinedError(needs="initial_model") @property def model_shape(self) -> Union[Tuple, np.ndarray]: @@ -1342,17 +1312,15 @@ def model_shape(self) -> Union[Tuple, np.ndarray]: Raises ------ - NameError - when it's not defined (by either :func:`BaseProblem.set_model_shape`, + NotDefinedError + when this property has not been defined (by either + :func:`BaseProblem.set_model_shape`, :func:`BaseProblem.set_model_shape`, or :func:`BaseProblem.set_walkers_starting_pos`) """ if hasattr(self, "_model_shape"): return self._model_shape - raise NameError( - "model shape has not been set, please use either `set_initial_model()`" - " or `set_model_shape() to add to the problem setup" - ) + raise NotDefinedError(needs="model_shape") @property def walkers_starting_pos(self) -> np.ndarray: @@ -1360,15 +1328,13 @@ def walkers_starting_pos(self) -> np.ndarray: Raises ------ - NameError - when it's not defined (by :func:`BaseProblem.set_walkers_starting_pos`) + NotDefinedError + when this property has not been defined (by + :func:`BaseProblem.set_walkers_starting_pos`) """ if hasattr(self, "_walkers_starting_pos"): return self._walkers_starting_pos - raise NameError( - "walkers' starting positions have not been set, please use " - "`set_walkers_starting_pos()` to add to the problem set up" - ) + raise NotDefinedError(needs="walkers' starting positions") @property def blobs_dtype(self) -> list: @@ -1377,17 +1343,14 @@ def blobs_dtype(self) -> list: Raises ------ - NameError - when it's not defined (by either :func:`BaseProblem.set_blobs_dtype` - or :func:`BaseProblem.set_log_posterior_with_blobs`) + NotDefinedError + when this property has not been defined (by either + :func:`BaseProblem.set_blobs_dtype` or + :func:`BaseProblem.set_log_posterior_with_blobs`) """ if hasattr(self, "_blobs_dtype"): return self._blobs_dtype - raise NameError( - "blobs name and type have not been set, please use either " - "`set_blobs_dtype()` or `set_log_posterior_with_blobs()` to add to the " - "problem setup" - ) + raise NotDefinedError(needs="blobs name and type") @property def bounds(self): @@ -1395,15 +1358,13 @@ def bounds(self): Raises ------ - NameError - when it's not defined (by :func:`BaseProblem.set_bounds`) + NotDefinedError + when this property has not been defined (by + :func:`BaseProblem.set_bounds`) """ if hasattr(self, "_bounds"): return self._bounds - raise NameError( - "bounds have not been set, please use `set_bounds()` to add to the " - "problem setup" - ) + raise NotDefinedError(needs="bounds") @property def constraints(self): @@ -1411,15 +1372,13 @@ def constraints(self): Raises ------ - NameError - when it's not defined (by :func:`BaseProblem.set_constraints`) + NotDefinedError + when this property has not been defined (by + :func:`BaseProblem.set_constraints`) """ if hasattr(self, "_constraints"): return self._constraints - raise NameError( - "constraints have not been set, please use `set_constraints()` to add " - "to the problem setup" - ) + raise NotDefinedError(needs="constraints") @property def objective_defined(self) -> bool: @@ -1498,7 +1457,7 @@ def data_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.data` has been defined""" try: self.data - except NameError: + except NotDefinedError: return False else: return True @@ -1508,7 +1467,7 @@ def data_covariance_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.data_covariance` has been defined""" try: self.data_covariance - except NameError: + except NotDefinedError: return False else: return True @@ -1518,7 +1477,7 @@ def data_covariance_inv_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.data_covariance_inv` has been defined""" try: self.data_covariance_inv - except NameError: + except NotDefinedError: return False else: return True @@ -1528,7 +1487,7 @@ def initial_model_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.initial_model` has been defined""" try: self.initial_model - except NameError: + except NotDefinedError: return False else: return True @@ -1538,7 +1497,7 @@ def model_shape_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.model_shape` has been defined""" try: self.model_shape - except NameError: + except NotDefinedError: return False else: return True @@ -1548,7 +1507,7 @@ def walkers_starting_pos_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.walkers_starting_pos` has been defined""" try: self.walkers_starting_pos - except NameError: + except NotDefinedError: return False else: return True @@ -1558,7 +1517,7 @@ def blobs_dtype_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.blobs_dtype` has been defined""" try: self.blobs_dtype - except NameError: + except NotDefinedError: return False else: return True @@ -1568,7 +1527,7 @@ def bounds_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.bounds` has been defined""" try: self.bounds - except NameError: + except NotDefinedError: return False else: return True @@ -1578,7 +1537,7 @@ def constraints_defined(self) -> bool: r"""indicates whether :func:`BaseProblem.constraints` has been defined""" try: self.constraints - except NameError: + except NotDefinedError: return False else: return True @@ -1589,7 +1548,7 @@ def _check_defined(func, args_num=1): return True try: func(*[np.array([])] * args_num) - except NotImplementedError: + except NotDefinedError: return False except Exception: # it's ok if there're errors caused by dummy input argument np.array([]) return True diff --git a/src/cofi/exceptions.py b/src/cofi/exceptions.py index a3cbd93b..7852ebe8 100644 --- a/src/cofi/exceptions.py +++ b/src/cofi/exceptions.py @@ -109,3 +109,28 @@ def __str__(self) -> str: msg = f"insufficient information supplied to calculate {self._needed_for}, " \ f"needs: {self._needs}" return self._form_str(super_msg, msg) + + +class NotDefinedError(CofiError, NotImplementedError): + r"""Raised when a certain property or function is not set to a :class:BaseProblem + instance but attempts are made to use it (e.g. in a solving approach) + + This is a subclass of :exc:`CofiError` and :exc:`NotImplementedError`. + + Parameters + ---------- + *args : Any + passed on directly to :exc:`NotImplementedError` + needs : list or str + a list of information required to perform the operation, or a string describing + them + """ + def __init__(self, *args, needs: Union[List, str]): + super().__init__(*args) + self._needs = needs + + def __str__(self) -> str: + super_msg = super().__str__() + msg = f"`{self._needs}` is required in the solving approach but you haven't " \ + "implemented or added it to the problem setup" + return self._form_str(super_msg, msg) diff --git a/tests/test_base_problem.py b/tests/test_base_problem.py index 6f616f48..29fc3ad0 100644 --- a/tests/test_base_problem.py +++ b/tests/test_base_problem.py @@ -4,8 +4,12 @@ import numpy as np from cofi import BaseProblem -from cofi.exceptions import DimensionMismatchError, InsufficientInfoError, InvalidOptionError - +from cofi.exceptions import ( + DimensionMismatchError, + InsufficientInfoError, + InvalidOptionError, + NotDefinedError +) ############### TEST data loader ###################################################### data_files_to_test = [ @@ -37,51 +41,51 @@ def test_set_data_from_file(data_path): ############### TEST empty problem #################################################### def test_non_set(): inv_problem = BaseProblem() - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.objective(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.gradient(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.hessian(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.hessian_times_vector(1, 2) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.residual(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.jacobian(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.jacobian_times_vector(1, 2) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.data_misfit(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.regularisation(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.forward(1) - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.data - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.data_covariance - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.data_covariance_inv - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.initial_model - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.model_shape - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.bounds - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.constraints - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.log_posterior(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.log_prior(1) - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.log_likelihood(1) - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.walkers_starting_pos - with pytest.raises(NotImplementedError): + with pytest.raises(NotDefinedError): inv_problem.log_posterior_with_blobs(1) - with pytest.raises(NameError): + with pytest.raises(NotDefinedError): inv_problem.blobs_dtype assert not inv_problem.objective_defined assert not inv_problem.gradient_defined @@ -453,7 +457,7 @@ def test_model_cov(): sigma = 1.0 Cdinv = np.eye(100)/(sigma**2) inv_problem.set_data_covariance_inv(Cdinv) - with pytest.raises(NotImplementedError, match=r".*`jacobian` is required.*"): + with pytest.raises(NotDefinedError, match=r".*`jacobian` is required.*"): inv_problem.model_covariance_inv(None) inv_problem.set_jacobian(np.array([[n**i for i in range(2)] for n in range(100)])) inv_problem.model_covariance(None) From 57f089013313c167ed3e80a49bc11a6154331f90 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Mon, 12 Sep 2022 11:48:51 +1000 Subject: [PATCH 05/10] feat: regularisation matrix --- src/cofi/base_problem.py | 214 ++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 83 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index ef7eb439..4d2eb4bd 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -536,7 +536,7 @@ def forward(self, model: np.ndarray, *args, **kwargs) -> Union[np.ndarray, Numbe Returns ------- - Union[np.ndarray, Number] + np.ndarray or Number the synthetics data Raises @@ -741,7 +741,7 @@ def set_hessian( Parameters ---------- - hess_func : Union[Callable[[np.ndarray], np.ndarray], np.ndarray] + hess_func : (function - np.ndarray -> np.ndarray) or np.ndarray the Hessian function that matches :func:`BaseProblem.hessian` in signature. Alternatively, provide a matrix if the Hessian is a constant. args : list, optional @@ -815,7 +815,7 @@ def set_jacobian( Parameters ---------- - jac_func : Union[Callable[[np.ndarray], np.ndarray], np.ndarray] + jac_func : (function - np.ndarray -> np.ndarray) or np.ndarray the Jacobian function that matches :func:`BaseProblem.residual` in signature. Alternatively, provide a matrix if the Jacobian is a constant. args : list, optional @@ -880,7 +880,7 @@ def set_data_misfit( Parameters ---------- - data_misfit : Union[str, Callable[[np.ndarray], Number]] + data_misfit : str or (function - np.ndarray -> Number) either a string from ["L2"], or a data misfit function that matches :func:`BaseProblem.data_misfit` in signature. args : list, optional @@ -919,6 +919,7 @@ def set_regularisation( self, regularisation: Union[str, Callable[[np.ndarray], Number]], lamda: Number = 1, + regularisation_matrix: np.ndarray = None, args: list = None, kwargs: dict = None, ): @@ -932,7 +933,7 @@ def set_regularisation( Parameters ---------- - regularisation : Union[str, Callable[[np.ndarray], Number]] + regularisation : str or (function - np.ndarray -> Number) either a string from pre-built functions above, or a regularisation function that matches :func:`BaseProblem.regularisation` in signature. lamda : Number, optional @@ -940,6 +941,17 @@ def set_regularisation( term over the data misfit, by default 1. If ``regularisation`` and ``data_misfit`` are set but ``objective`` isn't, then we will generate ``objective`` function as following: :math:`\text{objective}(model)=\text{data_misfit}(model)+\text{factor}\times\text{regularisation}(model)` + regularisation_matrix : np.ndarray or (function - np.ndarray -> np.ndarray) + a matrix of shape ``(model_size, model_size)``, or a function that takes in + a model and calculates the (weighting) matrix. + + - If this is None, + :math:`\text{regularisation}(model)=\lambda\times\text{regularisation}(model)` + - If this is set to be a matrix (np.ndarray, or other array like types), + :math:`\text{regularisation}(model)=\lambda\times\text{regularisation}(\text{regularisation_matrix}\cdot model)` + - If this is set to be a function that returns a matrix, + :math:`\text{regularisation}(model)=\lambda\times\text{regularisation}(\text{regularisation_matrix}(model)\cdot model)` + args : list, optional extra list of positional arguments for regularisation function kwargs : dict, optional @@ -953,21 +965,47 @@ def set_regularisation( Examples -------- + We demonstrate with a few examples on a ``BaseProblem`` instance. + >>> from cofi import BaseProblem >>> inv_problem = BaseProblem() - >>> inv_problem.set_regularisation(1) # example 1 + + 1. Example with an L1 norm + + >>> inv_problem.set_regularisation(1) >>> inv_problem.regularisation([1,1]) 2 - >>> inv_problem.set_regularisation("inf") # example 2 + + 2. Example with an inf norm + + >>> inv_problem.set_regularisation("inf") >>> inv_problem.regularisation([1,1]) 1 - >>> inv_problem.set_regularisation(lambda x: sum(x)) # example 3 + + 3. Example with a custom regularisation function + + >>> inv_problem.set_regularisation(lambda x: sum(x)) >>> inv_problem.regularisation([1,1]) 2 - >>> inv_problem.set_regularisation(2, 0.5) # example 4 + + 4. Example with an L2 norm and regularisation factor of 0.5 (by default 1) + + >>> inv_problem.set_regularisation(2, 0.5) >>> inv_problem.regularisation([1,1]) 0.7071067811865476 + + 5. Example with a regularisation matrix + + >>> inv_problem.set_regularisation(2, 0.5, np.array([[2,0], [0,1]])) + >>> inv_problem.regularisation([1,1]) + 1.118033988749895 """ + # preprocess regularisation_matrix + if np.ndim(regularisation_matrix) != 0: # convert to a function + self._regularisation_matrix = lambda _ : regularisation_matrix + else: # self._regularisation_matrix should be a function anyway + self._regularisation_matrix = regularisation_matrix + # preprocess regularisation function without lambda if isinstance(regularisation, (Number, str)) or not regularisation: order = regularisation if isinstance(order, str) and order not in ["fro", "nuc", "inf", "-inf"] \ @@ -982,9 +1020,17 @@ def set_regularisation( _reg = lambda x: np.linalg.norm(x, ord=order) else: _reg = _FunctionWrapper("regularisation_none_lamda", regularisation, args, kwargs) - self.regularisation = _FunctionWrapper( - "regularisation", lambda model: (_reg(model) * lamda), - ) + # wrapper function that calculates: lambda * raw regularisation value + if self._regularisation_matrix is None: + self.regularisation = _FunctionWrapper( + "regularisation", _regularisation_with_lamda, args=[_reg, lamda]) + else: + self.regularisation = _FunctionWrapper( + "regularisation", + _regularisation_with_lamda_n_matrix, + args = [_reg, lamda, self._regularisation_matrix] + ) + # update some autogenerated functions (as usual) self._update_autogen("regularisation") def set_forward( @@ -997,7 +1043,7 @@ def set_forward( Parameters ---------- - forward : Callable[[np.ndarray], Union[np.ndarray, Number]] + forward : function - np.ndarray -> (np.ndarray or Number) the forward function that matches :func:`BaseProblem.forward` in signature args : list, optional extra list of positional arguments for forward function @@ -1064,7 +1110,7 @@ def set_data_from_file(self, file_path, obs_idx=-1, data_cov: np.ndarray = None) ---------- file_path : str a relative/absolute file path for the data - obs_idx : Union[int,list], optional + obs_idx : int or list, optional the index/indices of observations within the data file, by default -1 data_cov : np.ndarray, optional the data covariance matrix that helps estimate uncertainty, with dimension @@ -1558,75 +1604,15 @@ def _check_defined(func, args_num=1): @property def autogen_table(self): return { - ("data_misfit", "regularisation",): ("objective", self._objective_from_dm_reg), - ("data_misfit",): ("objective", self._objective_from_dm), - ("log_likelihood", "log_prior",): ("log_posterior_with_blobs", self._log_posterior_with_blobs_from_ll_lp), - ("log_posterior_with_blobs",): ("log_posterior", self._log_posterior_from_lp_with_blobs), - ("hessian",): ("hessian_times_vector", self._hessian_times_vector_from_hess), - ("forward", "data",): ("residual", self._residual_from_fwd_dt), - ("jacobian",): ("jacobian_times_vector", self._jacobian_times_vector_from_jcb), + ("data_misfit", "regularisation",): ("objective", _objective_from_dm_reg), + ("data_misfit",): ("objective", _objective_from_dm), + ("log_likelihood", "log_prior",): ("log_posterior_with_blobs", _log_posterior_with_blobs_from_ll_lp), + ("log_posterior_with_blobs",): ("log_posterior", _log_posterior_from_lp_with_blobs), + ("hessian",): ("hessian_times_vector", _hessian_times_vector_from_hess), + ("forward", "data",): ("residual", _residual_from_fwd_dt), + ("jacobian",): ("jacobian_times_vector", _jacobian_times_vector_from_jcb), } - @staticmethod - def __exception_in_autogen_func(exception): # utils - print( - "cofi: Exception when calling auto generated function, check exception " - "details from message below. If not sure, please report this issue at " - "https://github.com/inlab-geo/cofi/issues" - ) - raise exception - - @staticmethod - def _objective_from_dm_reg(model, data_misfit, regularisation): - try: - return data_misfit(model) + regularisation(model) - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - - @staticmethod - def _objective_from_dm(model, data_misfit): - try: - return data_misfit(model) - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - - @staticmethod - def _log_posterior_with_blobs_from_ll_lp(model, log_likelihood, log_prior): - try: - ll = log_likelihood(model) - lp = log_prior(model) - return ll + lp, ll, lp - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - - @staticmethod - def _log_posterior_from_lp_with_blobs(model, log_posterior_with_blobs): - try: - return log_posterior_with_blobs(model)[0] - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - - @staticmethod - def _hessian_times_vector_from_hess(model, vector, hessian): - try: - return np.asarray(hessian(model) @ vector) - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - - @staticmethod - def _residual_from_fwd_dt(model, forward, data): - try: - return forward(model) - data - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - - @staticmethod - def _jacobian_times_vector_from_jcb(model, vector, jacobian): - try: - return np.asarray(jacobian(model) @ vector) - except Exception as exception: - BaseProblem.__exception_in_autogen_func(exception) - def _update_autogen(self, updated_item): update_dict = {k: v for k, v in self.autogen_table.items() if updated_item in k} for need_defined, (to_update, new_func) in update_dict.items(): @@ -1764,8 +1750,70 @@ def _summary(self, display_lines=True): def __repr__(self) -> str: return f"{self.name}" - - +# ---------- End of BaseProblem class ------------------------------------------------- + + +# ---------- Auto generated functions ------------------------------------------------- +def __exception_in_autogen_func(exception): # utils + print( + "cofi: Exception when calling auto generated function, check exception " + "details from message below. If not sure, please report this issue at " + "https://github.com/inlab-geo/cofi/issues" + ) + raise exception + +def _objective_from_dm_reg(model, data_misfit, regularisation): + try: + return data_misfit(model) + regularisation(model) + except Exception as exception: + __exception_in_autogen_func(exception) + +def _objective_from_dm(model, data_misfit): + try: + return data_misfit(model) + except Exception as exception: + __exception_in_autogen_func(exception) + +def _log_posterior_with_blobs_from_ll_lp(model, log_likelihood, log_prior): + try: + ll = log_likelihood(model) + lp = log_prior(model) + return ll + lp, ll, lp + except Exception as exception: + __exception_in_autogen_func(exception) + +def _log_posterior_from_lp_with_blobs(model, log_posterior_with_blobs): + try: + return log_posterior_with_blobs(model)[0] + except Exception as exception: + __exception_in_autogen_func(exception) + +def _hessian_times_vector_from_hess(model, vector, hessian): + try: + return np.asarray(hessian(model) @ vector) + except Exception as exception: + __exception_in_autogen_func(exception) + +def _residual_from_fwd_dt(model, forward, data): + try: + return forward(model) - data + except Exception as exception: + __exception_in_autogen_func(exception) + +def _jacobian_times_vector_from_jcb(model, vector, jacobian): + try: + return np.asarray(jacobian(model) @ vector) + except Exception as exception: + __exception_in_autogen_func(exception) + +def _regularisation_with_lamda(model, reg_func, lamda): + return lamda * reg_func(model) + +def _regularisation_with_lamda_n_matrix(model, reg_func, lamda, reg_matrix_func): + return lamda * reg_func(reg_matrix_func(model) @ model) + + +# ---------- function wrapper to help make things pickleable -------------------------- class _FunctionWrapper: def __init__(self, name, func, args: list = None, kwargs: dict = None, autogen=False): self.name = name From 3e7aae5c8428bc9c8fd983ecc42a6c13904dd309 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Mon, 12 Sep 2022 13:30:45 +1000 Subject: [PATCH 06/10] feat: properties for regularisation matrix and factor --- src/cofi/base_problem.py | 289 ++++++++++++++++++++----------------- tests/test_base_problem.py | 41 +++++- 2 files changed, 194 insertions(+), 136 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index 4d2eb4bd..f3dc7238 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -148,6 +148,7 @@ class BaseProblem: BaseProblem.set_jacobian_times_vector BaseProblem.set_data_misfit BaseProblem.set_regularisation + BaseProblem.set_regularisation_matrix BaseProblem.set_forward BaseProblem.set_data BaseProblem.set_data_covariance @@ -197,6 +198,8 @@ class BaseProblem: BaseProblem.jacobian_times_vector BaseProblem.data_misfit BaseProblem.regularisation + BaseProblem.regularisation_matrix + BaseProblem.regularisation_factor BaseProblem.forward BaseProblem.name BaseProblem.data @@ -229,6 +232,8 @@ class BaseProblem: "jacobian_times_vector", "data_misfit", "regularisation", + "regularisation_matrix", + "regularisation_factor", "forward", "data", "data_covariance", @@ -293,7 +298,7 @@ def log_posterior_with_blobs( information given a model The "related information" can be defined by you - (via :func:`BaseProblem.set_log_posterior_with_blobs`), but they will only be + (via :meth:`set_log_posterior_with_blobs`), but they will only be stored properly when you perform sampling with ``emcee``. Parameters @@ -526,6 +531,27 @@ def regularisation(self, model: np.ndarray, *args, **kwargs) -> Number: """ raise NotDefinedError(needs="regularisation") + def regularisation_matrix(self, model: np.ndarray, *args, **kwargs) -> np.ndarray: + """Method for computing the regularisation weighting matrix + + Parameters + ---------- + model : np.ndarray + a model that helps calculate regularisation matrix. In most cases this is + not needed, but you have the flexibility to set this as a function + + Returns + ------- + np.ndarray + the regularisation matrix of dimension ``(model_size, model_size)`` + + Raises + ------ + NotDefinedError + when this method is not set + """ + raise NotDefinedError(needs="regularisation_matrix") + def forward(self, model: np.ndarray, *args, **kwargs) -> Union[np.ndarray, Number]: """Method to perform the forward operation given a model @@ -550,7 +576,7 @@ def forward(self, model: np.ndarray, *args, **kwargs) -> Union[np.ndarray, Numbe # - def set_something(self, something) # - def something(self), this is a property / function # - def something_defined(self) -> bool - # - add checking to self.defined_components + # - add checking to self.dall_components # - add `set_something` and `something` to documentation list on top of this file # - check if there's anything to add to autogen_table # - add tests in tests/test_base_problem.py ("test_non_set", etc.) @@ -563,14 +589,14 @@ def set_objective( Alternatively, objective function can be set implicitly (computed by us) if one of the following combinations is set: - - :func:`BaseProblem.set_data_misfit` + :func:`BaseProblem.set_regularisation` - - :func:`BaseProblem.set_data_misfit` (in this case, regularisation is default + - :meth:`set_data_misfit` + :meth:`set_regularisation` + - :meth:`set_data_misfit` (in this case, regularisation is default to 0) Parameters ---------- obj_func : Callable[[np.ndarray], Number] - the objective function that matches :func:`BaseProblem.objective` in + the objective function that matches :meth:`objective` in signature args : list, optional extra list of positional arguments for the objective function @@ -594,7 +620,7 @@ def set_log_posterior( Parameters ---------- log_posterior_func : Callable[[np.ndarray], Number] - the log_posterior function that matches :func:`BaseProblem.log_posterior` + the log_posterior function that matches :meth:`log_posterior` in signature args : list, optional extra list of positional arguments for log_posterior function @@ -623,11 +649,11 @@ def set_log_posterior_with_blobs( to understand what blobs are. If you use other backend samplers, you can still set ``log_posterior`` using - this function, and we will generate :func:`BaseProblem.log_posterior` to return - only the first output from :func:`BaseProblem.log_posterior_with_blobs`. + this function, and we will generate :meth:`log_posterior` to return + only the first output from :meth:`log_posterior_with_blobs`. This method is also generated automatically by us if you've defined both - :func:`BaseProblem.log_prior` and :func:`BaseProblem.log_likelihood`. In that + :meth:`log_prior` and :meth:`log_likelihood`. In that case, the ``blobs_dtype`` is set to be ``[("log_likelihood", float), ("log_prior", float)]``. @@ -635,12 +661,12 @@ def set_log_posterior_with_blobs( ---------- log_posterior_blobs_func : Callable[[np.ndarray], Tuple[Number] the log_posterior_with_blobs function that matches - :func:`BaseProblem.log_posterior_blobs_func` in signature + :meth:`log_posterior_blobs_func` in signature blobs_dtype : list, optional a list of tuples that specify the names and type of the blobs, e.g. ``[("log_likelihood", float), ("log_prior", float)]``. If not set, the blobs will still be recorded during sampling in the order they are - returned from :func:`BaseProblem.log_posterior_blobs_func` + returned from :meth:`log_posterior_blobs_func` args : list, optional extra list of positional arguments for log_posterior function kwargs : dict, optional @@ -678,7 +704,7 @@ def set_log_prior( Parameters ---------- log_prior_func : Callable[[np.ndarray], Number] - the log_prior function that matches :func:`BaseProblem.log_prior` + the log_prior function that matches :meth:`log_prior` in signature args : list, optional extra list of positional arguments for log_prior function @@ -699,7 +725,7 @@ def set_log_likelihood( Parameters ---------- log_likelihood_func : Callable[[np.ndarray], Number] - the log_likelihood function that matches :func:`BaseProblem.log_likelihood` + the log_likelihood function that matches :meth:`log_likelihood` in signature args : list, optional extra list of positional arguments for log_likelihood function @@ -720,7 +746,7 @@ def set_gradient( Parameters ---------- obj_func : Callable[[np.ndarray], Number] - the gradient function that matches :func:`BaseProblem.gradient` in + the gradient function that matches :meth:`gradient` in signature args : list, optional extra list of positional arguments for gradient function @@ -742,7 +768,7 @@ def set_hessian( Parameters ---------- hess_func : (function - np.ndarray -> np.ndarray) or np.ndarray - the Hessian function that matches :func:`BaseProblem.hessian` in + the Hessian function that matches :meth:`hessian` in signature. Alternatively, provide a matrix if the Hessian is a constant. args : list, optional extra list of positional arguments for hessian function @@ -771,7 +797,7 @@ def set_hessian_times_vector( ---------- hess_vec_func : Callable[[np.ndarray, np.ndarray], np.ndarray] the function that computes the product of Hessian and an arbitrary vector, - in the same signature as :func:`BaseProblem.hessian_times_vector` + in the same signature as :meth:`hessian_times_vector` args : list, optional extra list of positional arguments for hessian_times_vector function kwargs : dict, optional @@ -794,7 +820,7 @@ def set_residual( Parameters ---------- res_func : Callable[[np.ndarray], np.ndarray] - the residual function that matches :func:`BaseProblem.residual` in + the residual function that matches :meth:`residual` in signature args : list, optional extra list of positional arguments for residual function @@ -816,7 +842,7 @@ def set_jacobian( Parameters ---------- jac_func : (function - np.ndarray -> np.ndarray) or np.ndarray - the Jacobian function that matches :func:`BaseProblem.residual` in + the Jacobian function that matches :meth:`residual` in signature. Alternatively, provide a matrix if the Jacobian is a constant. args : list, optional extra list of positional arguments for jacobian function @@ -845,7 +871,7 @@ def set_jacobian_times_vector( ---------- jac_vec_func : Callable[[np.ndarray, np.ndarray], np.ndarray] the function that computes the product of Jacobian and an arbitrary vector, - in the same signature as :func:`BaseProblem.jacobian_times_vector` + in the same signature as :meth:`jacobian_times_vector` args : list, optional extra list of positional arguments for jacobian_times_vector function kwargs : dict, optional @@ -871,8 +897,8 @@ def set_data_misfit( - "L2" If you choose one of the above, then you would also need to use - :func:`BaseProblem.set_data` / :func:`BaseProblem.set_data_from_file` - and :func:`BaseProblem.set_forward` so that we can generate the data misfit + :meth:`set_data` / :meth:`set_data_from_file` + and :meth:`set_forward` so that we can generate the data misfit function for you. If the data misfit function you want isn't included above, then pass your own @@ -882,7 +908,7 @@ def set_data_misfit( ---------- data_misfit : str or (function - np.ndarray -> Number) either a string from ["L2"], or a data misfit function that matches - :func:`BaseProblem.data_misfit` in signature. + :meth:`data_misfit` in signature. args : list, optional extra list of positional arguments for data_misfit function kwargs : dict, optional @@ -918,8 +944,8 @@ def set_data_misfit( def set_regularisation( self, regularisation: Union[str, Callable[[np.ndarray], Number]], - lamda: Number = 1, - regularisation_matrix: np.ndarray = None, + regularisation_factor: Number = 1, + regularisation_matrix: Union[np.ndarray, Callable[[np.ndarray], np.ndarray]] = None, args: list = None, kwargs: dict = None, ): @@ -935,9 +961,9 @@ def set_regularisation( ---------- regularisation : str or (function - np.ndarray -> Number) either a string from pre-built functions above, or a regularisation function that - matches :func:`BaseProblem.regularisation` in signature. - lamda : Number, optional - the regularisation factor that adjusts the ratio of the regularisation + matches :meth:`regularisation` in signature. + regularisation_factor : Number, optional + the regularisation factor (lamda) that adjusts the ratio of the regularisation term over the data misfit, by default 1. If ``regularisation`` and ``data_misfit`` are set but ``objective`` isn't, then we will generate ``objective`` function as following: :math:`\text{objective}(model)=\text{data_misfit}(model)+\text{factor}\times\text{regularisation}(model)` @@ -995,16 +1021,24 @@ def set_regularisation( 0.7071067811865476 5. Example with a regularisation matrix - + >>> inv_problem.set_regularisation(2, 0.5, np.array([[2,0], [0,1]])) >>> inv_problem.regularisation([1,1]) 1.118033988749895 """ # preprocess regularisation_matrix - if np.ndim(regularisation_matrix) != 0: # convert to a function - self._regularisation_matrix = lambda _ : regularisation_matrix - else: # self._regularisation_matrix should be a function anyway - self._regularisation_matrix = regularisation_matrix + if np.ndim(regularisation_matrix) != 0: + self.regularisation_matrix = _FunctionWrapper( + "regularisation_matrix", + lambda _: regularisation_matrix + ) + elif callable(regularisation_matrix): + self.regularisation_matrix = _FunctionWrapper( + "regularisation_matrix", + regularisation_matrix + ) + else: + self.regularisation_matrix = None # preprocess regularisation function without lambda if isinstance(regularisation, (Number, str)) or not regularisation: order = regularisation @@ -1017,18 +1051,19 @@ def set_regularisation( ) elif isinstance(order, str) and order in ["inf", "-inf"]: order = float(order) - _reg = lambda x: np.linalg.norm(x, ord=order) + _reg = _FunctionWrapper("regularisation_none_lamda", np.linalg.norm, args=[order]) else: _reg = _FunctionWrapper("regularisation_none_lamda", regularisation, args, kwargs) # wrapper function that calculates: lambda * raw regularisation value - if self._regularisation_matrix is None: + self._regularisation_factor = regularisation_factor + if self.regularisation_matrix is None: self.regularisation = _FunctionWrapper( - "regularisation", _regularisation_with_lamda, args=[_reg, lamda]) + "regularisation", _regularisation_with_lamda, args=[_reg, regularisation_factor]) else: self.regularisation = _FunctionWrapper( "regularisation", _regularisation_with_lamda_n_matrix, - args = [_reg, lamda, self._regularisation_matrix] + args = [_reg, regularisation_factor, self.regularisation_matrix] ) # update some autogenerated functions (as usual) self._update_autogen("regularisation") @@ -1044,7 +1079,7 @@ def set_forward( Parameters ---------- forward : function - np.ndarray -> (np.ndarray or Number) - the forward function that matches :func:`BaseProblem.forward` in signature + the forward function that matches :meth:`forward` in signature args : list, optional extra list of positional arguments for forward function kwargs : dict, optional @@ -1132,7 +1167,7 @@ def set_data_from_file(self, file_path, obs_idx=-1, data_cov: np.ndarray = None) def set_initial_model(self, init_model: np.ndarray): r"""Sets the starting point for the model - Once set, we will infer the property :func:`BaseProblem.model_shape` in + Once set, we will infer the property :meth:`model_shape` in case this is required for some inference solvers Parameters @@ -1154,7 +1189,7 @@ def set_model_shape(self, model_shape: Tuple): Raises ------ DimensionMismatchError - when you've defined an initial_model through :func:`BaseProblem.set_initial_model` + when you've defined an initial_model through :meth:`set_initial_model` but their shapes don't match """ if self.initial_model_defined and self._model_shape != model_shape: @@ -1297,8 +1332,8 @@ def suggest_solvers(self, print_to_console=True) -> dict: @property def data(self) -> np.ndarray: - r"""the observations, set by :func:`BaseProblem.set_data` or - :func:`BaseProblem.set_data_from_file` + r"""the observations, set by :meth:`set_data` or + :meth:`set_data_from_file` Raises ------ @@ -1311,8 +1346,8 @@ def data(self) -> np.ndarray: @property def data_covariance(self) -> np.ndarray: - """the data covariance matrix, set by :func:`BaseProblem.set_data_covariance`, - :func:`BaseProblem.set_data` or :func:`BaseProblem.set_data_from_file`. + """the data covariance matrix, set by :meth:`set_data_covariance`, + :meth:`set_data` or :meth:`set_data_from_file`. Raises ------ @@ -1325,8 +1360,8 @@ def data_covariance(self) -> np.ndarray: @property def data_covariance_inv(self) -> np.ndarray: - """the data covariance matrix, set by :func:`BaseProblem.set_data_covariance_inv`, - :func:`BaseProblem.set_data` or :func:`BaseProblem.set_data_from_file`. + """the data covariance matrix, set by :meth:`set_data_covariance_inv`, + :meth:`set_data` or :meth:`set_data_from_file`. Raises ------ @@ -1346,7 +1381,7 @@ def initial_model(self) -> np.ndarray: ------ NotDefinedError when this property has not been defined (by - :func:`BaseProblem.set_initial_model`) + :meth:`set_initial_model`) """ if hasattr(self, "_initial_model"): return self._initial_model @@ -1360,9 +1395,9 @@ def model_shape(self) -> Union[Tuple, np.ndarray]: ------ NotDefinedError when this property has not been defined (by either - :func:`BaseProblem.set_model_shape`, - :func:`BaseProblem.set_model_shape`, or - :func:`BaseProblem.set_walkers_starting_pos`) + :meth:`set_model_shape`, + :meth:`set_model_shape`, or + :meth:`set_walkers_starting_pos`) """ if hasattr(self, "_model_shape"): return self._model_shape @@ -1376,7 +1411,7 @@ def walkers_starting_pos(self) -> np.ndarray: ------ NotDefinedError when this property has not been defined (by - :func:`BaseProblem.set_walkers_starting_pos`) + :meth:`set_walkers_starting_pos`) """ if hasattr(self, "_walkers_starting_pos"): return self._walkers_starting_pos @@ -1385,18 +1420,33 @@ def walkers_starting_pos(self) -> np.ndarray: @property def blobs_dtype(self) -> list: r"""the name and type for the blobs that - :func:`BaseProblem.log_posterior_with_blobs` will return + :meth:`log_posterior_with_blobs` will return Raises ------ NotDefinedError when this property has not been defined (by either - :func:`BaseProblem.set_blobs_dtype` or - :func:`BaseProblem.set_log_posterior_with_blobs`) + :meth:`set_blobs_dtype` or + :meth:`set_log_posterior_with_blobs`) """ if hasattr(self, "_blobs_dtype"): return self._blobs_dtype raise NotDefinedError(needs="blobs name and type") + + @property + def regularisation_factor(self) -> Number: + r"""regularisation factor (lambda) that adjusts weights of the regularisation + term + + Raises + ------ + NotDefinedError + when this property has not been defined (by + :meth:`set_regularisation` + """ + if hasattr(self, "_regularisation_factor"): + return self._regularisation_factor + raise NotDefinedError(needs="regularisation_factor (lamda)") @property def bounds(self): @@ -1406,7 +1456,7 @@ def bounds(self): ------ NotDefinedError when this property has not been defined (by - :func:`BaseProblem.set_bounds`) + :meth:`set_bounds`) """ if hasattr(self, "_bounds"): return self._bounds @@ -1420,7 +1470,7 @@ def constraints(self): ------ NotDefinedError when this property has not been defined (by - :func:`BaseProblem.set_constraints`) + :meth:`set_constraints`) """ if hasattr(self, "_constraints"): return self._constraints @@ -1428,165 +1478,132 @@ def constraints(self): @property def objective_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.objective` has been defined""" + r"""indicates whether :meth:`objective` has been defined""" return self._check_defined(self.objective) @property def log_posterior_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.log_posterior` has been defined""" + r"""indicates whether :meth:`log_posterior` has been defined""" return self._check_defined(self.log_posterior) @property def log_posterior_with_blobs_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.log_posterior_with_blobs` has been + r"""indicates whether :meth:`log_posterior_with_blobs` has been defined """ return self._check_defined(self.log_posterior_with_blobs) @property def log_prior_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.log_prior` has been defined""" + r"""indicates whether :meth:`log_prior` has been defined""" return self._check_defined(self.log_prior) @property def log_likelihood_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.log_likelihood` has been defined""" + r"""indicates whether :meth:`log_likelihood` has been defined""" return self._check_defined(self.log_likelihood) @property def gradient_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.gradient` has been defined""" + r"""indicates whether :meth:`gradient` has been defined""" return self._check_defined(self.gradient) @property def hessian_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.hessian` has been defined""" + r"""indicates whether :meth:`hessian` has been defined""" return self._check_defined(self.hessian) @property def hessian_times_vector_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.hessian_times_vector` has been defined""" + r"""indicates whether :meth:`hessian_times_vector` has been defined""" return self._check_defined(self.hessian_times_vector, 2) @property def residual_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.residual` has been defined""" + r"""indicates whether :meth:`residual` has been defined""" return self._check_defined(self.residual) @property def jacobian_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.jacobian` has been defined""" + r"""indicates whether :meth:`jacobian` has been defined""" return self._check_defined(self.jacobian) @property def jacobian_times_vector_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.jacobian_times_vector` has been defined""" + r"""indicates whether :meth:`jacobian_times_vector` has been defined""" return self._check_defined(self.jacobian_times_vector, 2) @property def data_misfit_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.data_misfit` has been defined""" + r"""indicates whether :meth:`data_misfit` has been defined""" return self._check_defined(self.data_misfit) @property def regularisation_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.regularisation` has been defined""" + r"""indicates whether :meth:`regularisation` has been defined""" return self._check_defined(self.regularisation) + + @property + def regularisation_matrix_defined(self) -> bool: + r"""indicates whether :meth:`regularisation_matrix` has been defined""" + defined = self._check_defined(self.regularisation_matrix) + return defined and self.regularisation_matrix is not None @property def forward_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.forward` has been defined""" + r"""indicates whether :meth:`forward` has been defined""" return self._check_defined(self.forward) @property def data_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.data` has been defined""" - try: - self.data - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`data` has been defined""" + return self._check_property_defined("data") @property def data_covariance_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.data_covariance` has been defined""" - try: - self.data_covariance - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`data_covariance` has been defined""" + return self._check_property_defined("data_covariance") @property def data_covariance_inv_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.data_covariance_inv` has been defined""" - try: - self.data_covariance_inv - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`data_covariance_inv` has been defined""" + return self._check_property_defined("data_covariance_inv") + @property def initial_model_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.initial_model` has been defined""" - try: - self.initial_model - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`initial_model` has been defined""" + return self._check_property_defined("initial_model") @property def model_shape_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.model_shape` has been defined""" - try: - self.model_shape - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`model_shape` has been defined""" + return self._check_property_defined("model_shape") @property def walkers_starting_pos_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.walkers_starting_pos` has been defined""" - try: - self.walkers_starting_pos - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`walkers_starting_pos` has been defined""" + return self._check_property_defined("walkers_starting_pos") @property def blobs_dtype_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.blobs_dtype` has been defined""" - try: - self.blobs_dtype - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`blobs_dtype` has been defined""" + return self._check_property_defined("blobs_dtype") + + @property + def regularisation_factor_defined(self) -> bool: + r"""indicates whether :meth:`regularisation_factor` has been defined""" + return self._check_property_defined("regularisation_factor") @property def bounds_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.bounds` has been defined""" - try: - self.bounds - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`bounds` has been defined""" + return self._check_property_defined("bounds") @property def constraints_defined(self) -> bool: - r"""indicates whether :func:`BaseProblem.constraints` has been defined""" - try: - self.constraints - except NotDefinedError: - return False - else: - return True + r"""indicates whether :meth:`constraints` has been defined""" + return self._check_property_defined("constraints") @staticmethod def _check_defined(func, args_num=1): @@ -1598,6 +1615,14 @@ def _check_defined(func, args_num=1): return False except Exception: # it's ok if there're errors caused by dummy input argument np.array([]) return True + + def _check_property_defined(self, prop): + try: + getattr(self, prop) + except NotDefinedError: + return False + else: + return True # autogen_table: (tuple of defined things) -> # (name of deduced item, reference to new function to generate) diff --git a/tests/test_base_problem.py b/tests/test_base_problem.py index 29fc3ad0..9d669da7 100644 --- a/tests/test_base_problem.py +++ b/tests/test_base_problem.py @@ -59,6 +59,8 @@ def test_non_set(): inv_problem.data_misfit(1) with pytest.raises(NotDefinedError): inv_problem.regularisation(1) + with pytest.raises(NotDefinedError): + inv_problem.regularisation_matrix(1) with pytest.raises(NotDefinedError): inv_problem.forward(1) with pytest.raises(NotDefinedError): @@ -87,6 +89,8 @@ def test_non_set(): inv_problem.log_posterior_with_blobs(1) with pytest.raises(NotDefinedError): inv_problem.blobs_dtype + with pytest.raises(NotDefinedError): + inv_problem.regularisation_factor assert not inv_problem.objective_defined assert not inv_problem.gradient_defined assert not inv_problem.hessian_defined @@ -96,6 +100,7 @@ def test_non_set(): assert not inv_problem.jacobian_times_vector_defined assert not inv_problem.data_misfit_defined assert not inv_problem.regularisation_defined + assert not inv_problem.regularisation_matrix_defined assert not inv_problem.forward_defined assert not inv_problem.data_defined assert not inv_problem.data_covariance_defined @@ -110,6 +115,7 @@ def test_non_set(): assert not inv_problem.walkers_starting_pos_defined assert not inv_problem.log_posterior_with_blobs_defined assert not inv_problem.blobs_dtype_defined + assert not inv_problem.regularisation_factor_defined assert len(inv_problem.defined_components()) == 0 inv_problem.summary() @@ -161,6 +167,7 @@ def check_defined_misfit_reg(inv_problem): inv_problem.summary() assert inv_problem.data_misfit_defined assert inv_problem.regularisation_defined + assert inv_problem.regularisation_factor_defined assert inv_problem.objective_defined assert not inv_problem.gradient_defined assert not inv_problem.hessian_defined @@ -168,7 +175,7 @@ def check_defined_misfit_reg(inv_problem): assert not inv_problem.jacobian_defined assert not inv_problem.data_defined assert not inv_problem.forward_defined - assert len(inv_problem.defined_components()) == 3 + assert len(inv_problem.defined_components()) == 4 def test_set_misfit_reg(inv_problem_with_misfit): @@ -294,11 +301,12 @@ def check_defined_data_fwd_misfit_reg(inv_problem): assert inv_problem.data_misfit_defined assert inv_problem.residual_defined assert inv_problem.regularisation_defined + assert inv_problem.regularisation_factor_defined assert inv_problem.objective_defined assert not inv_problem.gradient_defined assert not inv_problem.hessian_defined assert not inv_problem.jacobian_defined - assert len(inv_problem.defined_components()) == 6 + assert len(inv_problem.defined_components()) == 7 def check_values_data_fwd_misfit_reg(inv_problem): @@ -376,7 +384,6 @@ def test_check_defined(): inv_problem.set_model_shape((2, 1)) inv_problem.set_model_shape((3, 1)) - def test_set_data(): inv_problem = BaseProblem() inv_problem.set_data(np.ones((2,1)), np.zeros((2,2)), np.zeros((2,2))) @@ -447,10 +454,36 @@ def test_set_reg_with_args(): inv_problem = BaseProblem() from scipy.sparse import csr_matrix A = csr_matrix([[1, 2, 0], [0, 0, 3], [4, 0, 5]]) - inv_problem.set_regularisation(lambda m, A: A @ m.T @ m, lamda=2, args=[A]) + inv_problem.set_regularisation(lambda m, A: A @ m.T @ m, 2, args=[A]) inv_problem.regularisation(np.array([1,2,3])) +############### TEST regularisation (matrix, factor) ################################## +def test_set_reg_with_matrix(): + inv_problem = BaseProblem() + # test zero info + assert not inv_problem.regularisation_defined + assert not inv_problem.regularisation_factor_defined + assert not inv_problem.regularisation_matrix_defined + # test set + inv_problem.set_regularisation(2, 0.5, np.array([[2,0],[0,1]])) + assert inv_problem.regularisation_defined + assert inv_problem.regularisation_factor_defined + assert inv_problem.regularisation_matrix_defined + assert inv_problem.regularisation(np.array([1,1])) == 1.118033988749895 + # test unset + inv_problem.set_regularisation(2) + assert inv_problem.regularisation_defined + assert inv_problem.regularisation_factor == 1 + assert not inv_problem.regularisation_matrix_defined + +def test_set_reg_with_matrix_func(): + inv_problem = BaseProblem() + inv_problem.set_regularisation(2, 0.5, lambda _: np.array([[2,0], [0,1]])) + assert inv_problem.regularisation(np.array([1,1])) == 1.118033988749895 + + + ############### TEST model covariance ################################################# def test_model_cov(): inv_problem = BaseProblem() From cc42474e9201b35c5cef4d2468d9b2d4391a6239 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Mon, 12 Sep 2022 15:12:08 +1000 Subject: [PATCH 07/10] test: bump coverage --- src/cofi/base_problem.py | 91 +++++++++++++++++++++++++------------- src/cofi/exceptions.py | 79 ++++++++++++++++++++++----------- tests/test_base_problem.py | 58 ++++++++++++++++++++++-- 3 files changed, 168 insertions(+), 60 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index f3dc7238..4c8f3457 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -7,9 +7,9 @@ from .solvers import solvers_table from .exceptions import ( DimensionMismatchError, - InsufficientInfoError, - InvalidOptionError, - NotDefinedError + InvalidOptionError, + InvocationError, + NotDefinedError, ) @@ -928,7 +928,7 @@ def set_data_misfit( "L2 norm", "l2 norm", ]: - self.data_misfit = _FunctionWrapper("data_misfit", self._data_misfit_l2) + self.data_misfit = _FunctionWrapper("data_misfit", self._data_misfit_l2, autogen=True) else: raise InvalidOptionError( name="data misfit", @@ -1629,8 +1629,8 @@ def _check_property_defined(self, prop): @property def autogen_table(self): return { - ("data_misfit", "regularisation",): ("objective", _objective_from_dm_reg), ("data_misfit",): ("objective", _objective_from_dm), + ("data_misfit", "regularisation",): ("objective", _objective_from_dm_reg), ("log_likelihood", "log_prior",): ("log_posterior_with_blobs", _log_posterior_with_blobs_from_ll_lp), ("log_posterior_with_blobs",): ("log_posterior", _log_posterior_from_lp_with_blobs), ("hessian",): ("hessian_times_vector", _hessian_times_vector_from_hess), @@ -1688,10 +1688,14 @@ def name(self, problem_name): self._name = problem_name def _data_misfit_l2(self, model: np.ndarray) -> Number: - if self.residual_defined: + try: res = self.residual(model) return np.linalg.norm(res) / res.shape[0] - raise InsufficientInfoError(needs="residual", needed_for="L2 data misfit") + except Exception as exception: + raise InvocationError( + func_name="data misfit", + autogen=True + ) from exception def summary(self): r"""Helper method that prints a summary of current ``BaseProblem`` object to @@ -1779,25 +1783,23 @@ def __repr__(self) -> str: # ---------- Auto generated functions ------------------------------------------------- -def __exception_in_autogen_func(exception): # utils - print( - "cofi: Exception when calling auto generated function, check exception " - "details from message below. If not sure, please report this issue at " - "https://github.com/inlab-geo/cofi/issues" - ) - raise exception - def _objective_from_dm_reg(model, data_misfit, regularisation): try: return data_misfit(model) + regularisation(model) except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="objective function from data misfit and regularisation", + autogen=True + ) from exception def _objective_from_dm(model, data_misfit): try: return data_misfit(model) except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="objective function from data misfit", + autogen=True + ) from exception def _log_posterior_with_blobs_from_ll_lp(model, log_likelihood, log_prior): try: @@ -1805,31 +1807,46 @@ def _log_posterior_with_blobs_from_ll_lp(model, log_likelihood, log_prior): lp = log_prior(model) return ll + lp, ll, lp except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="log posterior function from log likelihood and log prior", + autogen=True + ) from exception def _log_posterior_from_lp_with_blobs(model, log_posterior_with_blobs): try: return log_posterior_with_blobs(model)[0] except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="log posterior function from log likelihood and log prior", + autogen=True + ) from exception def _hessian_times_vector_from_hess(model, vector, hessian): try: return np.asarray(hessian(model) @ vector) except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="hessian_times_vector function from given hessian function", + autogen=True + ) from exception def _residual_from_fwd_dt(model, forward, data): try: return forward(model) - data except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="residual function from forward and data provided", + autogen=True + ) from exception def _jacobian_times_vector_from_jcb(model, vector, jacobian): try: return np.asarray(jacobian(model) @ vector) except Exception as exception: - __exception_in_autogen_func(exception) + raise InvocationError( + func_name="jacobian_times_vector from given jacobian function", + autogen=True + ) from exception def _regularisation_with_lamda(model, reg_func, lamda): return lamda * reg_func(model) @@ -1841,6 +1858,12 @@ def _regularisation_with_lamda_n_matrix(model, reg_func, lamda, reg_matrix_func) # ---------- function wrapper to help make things pickleable -------------------------- class _FunctionWrapper: def __init__(self, name, func, args: list = None, kwargs: dict = None, autogen=False): + if not callable(func): + raise InvalidOptionError( + name=f"{name} function", + invalid_option="not-callable input", + valid_options="functions that are callable" + ) self.name = name self.func = func self.args = list() if args is None else args @@ -1851,12 +1874,18 @@ def __call__(self, model, *extra_args): try: return self.func(model, *extra_args, *self.args, **self.kwargs) except Exception as exception: - import traceback - - print(f"cofi: Exception while calling your {self.name} function:") - print(" params:", model, *extra_args) - print(" args:", self.args, len(self.args)) - print(" kwargs:", self.kwargs) - print(" exception:") - traceback.print_exc() - raise exception + if self.autogen: + raise exception + else: + raise InvocationError( + func_name=self.name, autogen=self.autogen + ) from exception + + # import traceback + # print(f"cofi: Exception while calling your {self.name} function:") + # print(" params:", model, *extra_args) + # print(" args:", self.args, len(self.args)) + # print(" kwargs:", self.kwargs) + # print(" exception:") + # traceback.print_exc() + # raise exception diff --git a/src/cofi/exceptions.py b/src/cofi/exceptions.py index 7852ebe8..26d38370 100644 --- a/src/cofi/exceptions.py +++ b/src/cofi/exceptions.py @@ -39,8 +39,8 @@ def __str__(self) -> str: super_msg = super().__str__() msg = f"the {self._name} you've entered ('{self._invalid_option}') is " \ f"invalid, please choose from the following: {self._valid_options}.\n\n" \ - f"If you find it valuable to have '{self._invalid_option}' in CoFI, "\ - f"please create an issue here: {GITHUB_ISSUE}" + f"If you find it valuable to have '{self._invalid_option}' for "\ + f"{self._name} in CoFI, please create an issue here: {GITHUB_ISSUE}" return self._form_str(super_msg, msg) @@ -84,31 +84,31 @@ def __str__(self) -> str: return self._form_str(super_msg, msg) -class InsufficientInfoError(CofiError, RuntimeError): - r"""Raised when insufficient information is supplied to perform operations at hand +# class InsufficientInfoError(CofiError, RuntimeError): +# r"""Raised when insufficient information is supplied to perform operations at hand - This is a subclass of :exc:`CofiError` and :exc:`RuntimeError`. - - Parameters - ---------- - *args : Any - passed on directly to :exc:`RuntimeError` - needs : list or str - a list of information required to perform the operation, or a string describing - them - needed_for : str - name of the operation to perform or the item to calculate - """ - def __init__(self, *args, needs: Union[List, str], needed_for: str): - super().__init__(*args) - self._needs = needs - self._needed_for = needed_for +# This is a subclass of :exc:`CofiError` and :exc:`RuntimeError`. + +# Parameters +# ---------- +# *args : Any +# passed on directly to :exc:`RuntimeError` +# needs : list or str +# a list of information required to perform the operation, or a string describing +# them +# needed_for : str +# name of the operation to perform or the item to calculate +# """ +# def __init__(self, *args, needs: Union[List, str], needed_for: str): +# super().__init__(*args) +# self._needs = needs +# self._needed_for = needed_for - def __str__(self) -> str: - super_msg = super().__str__() - msg = f"insufficient information supplied to calculate {self._needed_for}, " \ - f"needs: {self._needs}" - return self._form_str(super_msg, msg) +# def __str__(self) -> str: +# super_msg = super().__str__() +# msg = f"insufficient information supplied to calculate {self._needed_for}, " \ +# f"needs: {self._needs}" +# return self._form_str(super_msg, msg) class NotDefinedError(CofiError, NotImplementedError): @@ -134,3 +134,32 @@ def __str__(self) -> str: msg = f"`{self._needs}` is required in the solving approach but you haven't " \ "implemented or added it to the problem setup" return self._form_str(super_msg, msg) + +class InvocationError(CofiError, RuntimeError): + r"""Raised when there's an error happening during excecution of a function + + This is a subclass of :exc:`CofiError` and :exc:`RuntimeError`. + + One should raise this error by ``raise InvocationError(func_name=a, autogen=b) from exception``, + where ``exception`` is the original exception caught + + Parameters + ---------- + *args : Any + passed on directly to :exc:`RuntimeError` + func_name : str + name of the function that runs into error + autogen : bool + whether this function is automatically generated or defined by users + """ + def __init__(self, *args, func_name: str, autogen: bool): + super().__init__(*args) + self._func_name = func_name + self._func_name_prefix = "auto-generated" if autogen else "your" + + def __str__(self) -> str: + super_msg = super().__str__() + msg = f"exception while calling {self._func_name_prefix} {self._func_name}. " \ + f"Check exception details from message above. If not sure, " \ + f"please report this issue at {GITHUB_ISSUE}" + return self._form_str(super_msg, msg) diff --git a/tests/test_base_problem.py b/tests/test_base_problem.py index 9d669da7..7f2c123e 100644 --- a/tests/test_base_problem.py +++ b/tests/test_base_problem.py @@ -6,8 +6,8 @@ from cofi import BaseProblem from cofi.exceptions import ( DimensionMismatchError, - InsufficientInfoError, - InvalidOptionError, + InvalidOptionError, + InvocationError, NotDefinedError ) @@ -335,7 +335,7 @@ def test_invalid_misfit_options(): with pytest.raises(InvalidOptionError): inv_problem.set_data_misfit("FOO") inv_problem.set_data_misfit("L2") - with pytest.raises(InsufficientInfoError): + with pytest.raises(InvocationError): inv_problem.data_misfit(np.array([1, 2, 3])) @@ -457,6 +457,10 @@ def test_set_reg_with_args(): inv_problem.set_regularisation(lambda m, A: A @ m.T @ m, 2, args=[A]) inv_problem.regularisation(np.array([1,2,3])) +def test_invalid_func(): + inv_problem = BaseProblem() + with pytest.raises(InvalidOptionError): + inv_problem.set_forward(1) ############### TEST regularisation (matrix, factor) ################################## def test_set_reg_with_matrix(): @@ -483,7 +487,6 @@ def test_set_reg_with_matrix_func(): assert inv_problem.regularisation(np.array([1,1])) == 1.118033988749895 - ############### TEST model covariance ################################################# def test_model_cov(): inv_problem = BaseProblem() @@ -511,5 +514,52 @@ def test_hess_times_vector(): ############### TEST auto generated methods ########################################### +def test_obj_from_dm_reg(): + inv_problem = BaseProblem() + # test valid + inv_problem.set_data_misfit(lambda x: x**2) + inv_problem.set_regularisation(lambda x: x) + assert inv_problem.objective(1) == 2 + # test invalid + inv_problem.set_data_misfit(lambda x: x[2]) + with pytest.raises(InvocationError): + inv_problem.objective(1) +def test_obj_from_dm(): + inv_problem = BaseProblem() + # test invalid + inv_problem.set_data_misfit(lambda x: x[2]) + with pytest.raises(InvocationError): + inv_problem.objective(1) +def test_lp_from_ll_lp(): + inv_problem = BaseProblem() + # test valid + inv_problem.set_log_likelihood(lambda x: x**2) + inv_problem.set_log_prior(lambda x: x) + assert inv_problem.log_posterior(1) == 2 + # test invalid + inv_problem.set_log_likelihood(lambda x: x[2]) + with pytest.raises(InvocationError): + inv_problem.log_posterior(1) + +def test_hessp_from_hess(): + inv_problem = BaseProblem() + # test invalid + inv_problem.set_hessian(np.array([1,2])) + with pytest.raises(InvocationError): + inv_problem.hessian_times_vector(0, np.array([1,2,3])) + +def test_jacp_from_jac(): + inv_problem = BaseProblem() + # test invalid + inv_problem.set_jacobian(np.array([1,2])) + with pytest.raises(InvocationError): + inv_problem.jacobian_times_vector(0, np.array([1,2,3])) + +def test_res_from_fwd_dt(): + inv_problem = BaseProblem() + inv_problem.set_forward(lambda x: x**2) + inv_problem.set_data(np.array([1,4,9])) + with pytest.raises(InvocationError): + inv_problem.residual(np.array([1,2])) From 878284303a865e6abe518ad22b9c600a949d7d25 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Mon, 12 Sep 2022 15:22:50 +1000 Subject: [PATCH 08/10] perf: hide imports where possible to improve importing speed --- src/cofi/base_problem.py | 4 ++-- src/cofi/inversion.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index 4c8f3457..26707399 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -1,10 +1,9 @@ from numbers import Number -from typing import Any, Callable, Dict, Union, Tuple, Sequence +from typing import Callable, Union, Tuple, Sequence import json import numpy as np -from .solvers import solvers_table from .exceptions import ( DimensionMismatchError, InvalidOptionError, @@ -1317,6 +1316,7 @@ def suggest_solvers(self, print_to_console=True) -> dict: """ to_suggest = dict() all_components = self.defined_components() + from .solvers import solvers_table for solving_method in solvers_table: backend_tools = solvers_table[solving_method] to_suggest[solving_method] = [] diff --git a/src/cofi/inversion.py b/src/cofi/inversion.py index bfc9bb11..b3b9e8dc 100644 --- a/src/cofi/inversion.py +++ b/src/cofi/inversion.py @@ -1,8 +1,5 @@ from typing import Type -import emcee -import arviz - from . import BaseProblem, InversionOptions from .solvers import solver_dispatch_table, BaseSolver @@ -104,6 +101,9 @@ def to_arviz(self, **kwargs): when sampling result of current type (``type(SamplingResult.sampler)``)) cannot be converted into an :class:`arviz.InferenceData` """ + import arviz + import emcee + sampler = self.sampler if sampler is None: raise ValueError( From bda58468f3eabdac561dddec84f5b0f0dad7314c Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Tue, 13 Sep 2022 13:52:30 +1000 Subject: [PATCH 09/10] test: bump coverage --- src/cofi/exceptions.py | 32 +------------------------------- tests/test_base_problem.py | 8 ++++---- tests/test_inversion_result.py | 1 - 3 files changed, 5 insertions(+), 36 deletions(-) diff --git a/src/cofi/exceptions.py b/src/cofi/exceptions.py index 26d38370..0142104e 100644 --- a/src/cofi/exceptions.py +++ b/src/cofi/exceptions.py @@ -7,10 +7,7 @@ class CofiError(Exception): """Base class for all CoFI errors""" def _form_str(self, super_msg, msg): - if super_msg: - return msg + "\n\n" + super_msg - else: - return msg + return f"{msg}\n\n{super_msg}" if super_msg else msg class InvalidOptionError(CofiError, ValueError): @@ -84,33 +81,6 @@ def __str__(self) -> str: return self._form_str(super_msg, msg) -# class InsufficientInfoError(CofiError, RuntimeError): -# r"""Raised when insufficient information is supplied to perform operations at hand - -# This is a subclass of :exc:`CofiError` and :exc:`RuntimeError`. - -# Parameters -# ---------- -# *args : Any -# passed on directly to :exc:`RuntimeError` -# needs : list or str -# a list of information required to perform the operation, or a string describing -# them -# needed_for : str -# name of the operation to perform or the item to calculate -# """ -# def __init__(self, *args, needs: Union[List, str], needed_for: str): -# super().__init__(*args) -# self._needs = needs -# self._needed_for = needed_for - -# def __str__(self) -> str: -# super_msg = super().__str__() -# msg = f"insufficient information supplied to calculate {self._needed_for}, " \ -# f"needs: {self._needs}" -# return self._form_str(super_msg, msg) - - class NotDefinedError(CofiError, NotImplementedError): r"""Raised when a certain property or function is not set to a :class:BaseProblem instance but attempts are made to use it (e.g. in a solving approach) diff --git a/tests/test_base_problem.py b/tests/test_base_problem.py index 7f2c123e..f710b6f7 100644 --- a/tests/test_base_problem.py +++ b/tests/test_base_problem.py @@ -277,9 +277,9 @@ def test_set_misfit_reg_inf(inv_problem_with_misfit): def test_invalid_reg_options(): inv_problem = BaseProblem() - with pytest.raises(InvalidOptionError): + with pytest.raises(InvalidOptionError, match=r".*the regularisation order you've entered.*"): inv_problem.set_regularisation("FOO") - with pytest.raises(InvalidOptionError): + with pytest.raises(InvalidOptionError, match=r".*is invalid, please choose from the following:.*"): inv_problem.set_regularisation(-1) @@ -380,7 +380,7 @@ def test_check_defined(): assert inv_problem.initial_model_defined assert inv_problem.model_shape_defined assert inv_problem.model_shape == (3,) - with pytest.raises(DimensionMismatchError): + with pytest.raises(DimensionMismatchError, match=r".*the model shape you've provided.*"): inv_problem.set_model_shape((2, 1)) inv_problem.set_model_shape((3, 1)) @@ -522,7 +522,7 @@ def test_obj_from_dm_reg(): assert inv_problem.objective(1) == 2 # test invalid inv_problem.set_data_misfit(lambda x: x[2]) - with pytest.raises(InvocationError): + with pytest.raises(InvocationError, match=r".*exception while calling auto-generated objective.*"): inv_problem.objective(1) def test_obj_from_dm(): diff --git a/tests/test_inversion_result.py b/tests/test_inversion_result.py index a5eddd9d..cec02c66 100644 --- a/tests/test_inversion_result.py +++ b/tests/test_inversion_result.py @@ -64,4 +64,3 @@ def log_prob(model): idata = res.to_arviz() # 5 - correct sampler (prior + likelihood + blob_groups) idata = res.to_arviz(blob_groups=["log_likelihood", "prior"]) - From c2f2a0645054f0abb8540f47fc41531509f68d87 Mon Sep 17 00:00:00 2001 From: Jiawen He Date: Tue, 13 Sep 2022 13:55:52 +1000 Subject: [PATCH 10/10] fix: remove lambda functions from base_problem to ensure pickleability --- src/cofi/base_problem.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cofi/base_problem.py b/src/cofi/base_problem.py index 26707399..1c1eb6af 100644 --- a/src/cofi/base_problem.py +++ b/src/cofi/base_problem.py @@ -775,7 +775,7 @@ def set_hessian( extra dict of keyword arguments for hessian function """ if isinstance(hess_func, np.ndarray): - self.hessian = _FunctionWrapper("hessian", lambda _: hess_func) + self.hessian = _FunctionWrapper("hessian", _matrix_to_func, args=[hess_func]) else: self.hessian = _FunctionWrapper("hessian", hess_func, args, kwargs) self._update_autogen("hessian") @@ -849,7 +849,7 @@ def set_jacobian( extra dict of keyword arguments for jacobian function """ if isinstance(jac_func, np.ndarray): - self.jacobian = _FunctionWrapper("jacobian", lambda _: jac_func) + self.jacobian = _FunctionWrapper("jacobian", _matrix_to_func, args=[jac_func]) else: self.jacobian = _FunctionWrapper("jacobian", jac_func, args, kwargs) self._update_autogen("jacobian") @@ -1029,7 +1029,7 @@ def set_regularisation( if np.ndim(regularisation_matrix) != 0: self.regularisation_matrix = _FunctionWrapper( "regularisation_matrix", - lambda _: regularisation_matrix + _matrix_to_func, args=[regularisation_matrix] ) elif callable(regularisation_matrix): self.regularisation_matrix = _FunctionWrapper( @@ -1854,6 +1854,8 @@ def _regularisation_with_lamda(model, reg_func, lamda): def _regularisation_with_lamda_n_matrix(model, reg_func, lamda, reg_matrix_func): return lamda * reg_func(reg_matrix_func(model) @ model) +def _matrix_to_func(_, matrix): + return matrix # ---------- function wrapper to help make things pickleable -------------------------- class _FunctionWrapper: