Source code for probeye.definition.parameter

# standard library
from typing import Union, Optional, List
import re

# third party imports
from tabulate import tabulate
from loguru import logger
import numpy as np

# local imports
from probeye.subroutines import titled_table
from probeye.subroutines import simplified_list_string
from probeye.subroutines import len_or_one
from probeye.subroutines import translate_number_string
from probeye.subroutines import count_intervals
from probeye.subroutines import convert_to_tuple
from probeye.definition.prior import PriorBase
from probeye.definition.distribution import ProbabilityDistribution, Uninformative


[docs]class Parameters(dict): """ The main parameter 'library'. In this dictionary, all of the problem's parameters are stored. The parameter's names are the keys, and the associated values are ParameterProperties-objects, see below. """
[docs] def add_parameter( self, prm_name: str, prm_type: str = "not defined", dim: Optional[int] = 1, domain: str = "(-oo, +oo)", value: Union[int, float, tuple, np.ndarray, None] = None, prior: Optional[ProbabilityDistribution] = None, info: str = "No explanation provided", tex: Optional[str] = None, ): """ Adds a parameter ('const' or 'latent') to the Parameters-object. The main functionality of this method is to distinguish between the two types ('const' and 'latent') and in creating the prior-object and adding the prior-parameters when adding a latent param. to the problem. Parameters ---------- prm_name The name of the parameter which should be added to the problem. prm_type Either 'model' (for a model parameter), 'prior' (for a prior parameter) or 'likelihood' (for a likelihood parameter). dim The parameter's dimension. domain The parameter's domain (i.e., values it may assume). Note that this argument is only considered for latent parameter, but not for a constant. value If the added parameter is a 'const'-parameter, the corresponding value has to be specified by this argument. prior If the added parameter is a 'latent'-parameter, this argument has to be given as a 2-tuple. The first element (a string) defines the prior-type (will be referenced in inference routines). The 2nd element must be a dictionary stating the prior's parameters as keys and their numeric values as values or the name of a pre-defined parameter within the problem scope. An example for a normal prior: ('normal', {'loc': 0.0, 'scale': 1.0}). In order to define the prior's parameters, check out the prior definitions in priors.py. info Short explanation on the added parameter. tex The TeX version of the parameter's name, for example r'$\beta$' for a parameter named 'beta'. """ # make sure the parameters has not been defined yet self.confirm_that_parameter_does_not_exists(prm_name) # if neither const nor prior are given, the parameter is interpreted as being # defined as latent with an uninformative prior if value is None and prior is None: prior = Uninformative() # add the parameter to the central parameter dictionary if isinstance(prior, ProbabilityDistribution): # i.e. adding 'latent'-parameter # first, define the index of this parameter in the numeric vector theta, # which is given to self.loglike and self.logprior prm_index = self.n_latent_prms_dim # type: Union[int, None] prm_dim = dim prm_domain = domain assert dim is not None # this check has been added for the mypy-check if count_intervals(domain) == 1 and dim > 1: prm_domain *= dim # the prm_value is reserved for 'const'-parameter; hence, it is set to None # in this case, where we are adding a 'latent'-param. prm_value = None # the remaining code in this if-branch defines the prior that is associated # with this 'latent'-parameter # extract the prior's elements prior_type = prior.dist_type # e.g. 'normal', 'lognormal', etc. prior_dict = prior.prm_dict # dictionary with parameter-value pairs prior_parameter_names = [] # type: List[Union[str, dict]] for prior_parameter_name, prior_value in prior_dict.items(): # create unique name for this prior parameter new_name = f"{prior_parameter_name}_{prm_name}" if type(prior_value) in {float, int, list, tuple, np.ndarray}: # in this case, the prior-parameter is considered a 'const'- # parameter and added to the problem accordingly here default_info = f"{prior_type.capitalize()} " default_info += f"prior's parameter " default_info += f"for latent parameter '{prm_name}'" # the following call is recursive, but only with a depth of one, # since the added parameter is a constant here self.add_parameter( new_name, "prior", value=prior_value, info=default_info ) prior_parameter_names.append(new_name) elif type(prior_value) is str: # in this case the prior-parameter is defined as an already defined # parameter with the name stated in value self.confirm_that_parameter_exists(prior_value) prior_parameter_names.append({prior_value: new_name}) else: raise TypeError( f"The prior-parameter {new_name} is not assigned a " f"float, int or str, but something of type " f"{type(value)}." ) prior_name = f"{prm_name}_{prior_type}" # unique name of this prior prm_prior = PriorBase( prm_name, prior_parameter_names, prior_name, prior ) # type: Union[PriorBase, None] logger.debug( f"Adding latent {prm_type}-parameter " f"{prm_name} with {prior_type} prior to problem" ) elif prior is None: # in this case we are adding a 'const'-parameter, which means that the # prm_index and prm_prior values are not used here prm_index = None prm_dim = len_or_one(value) prm_domain = None # type: ignore prm_prior = None prm_value = convert_to_tuple(value) # type: ignore logger.debug( f"Adding constant {prm_type}-parameter " f"{prm_name} = {prm_value} to problem" ) else: raise TypeError( f"The 'prior'-flag of parameter '{prm_name}' is neither a " f"ProbabilityDistribution nor None." ) # add the parameter to the central parameter dictionary self[prm_name] = ParameterProperties( { "index": prm_index, "dim": prm_dim, "domain": prm_domain, "type": prm_type, "prior": prm_prior, "value": prm_value, "info": info, "tex": tex, } )
def __setitem__(self, key: str, value: "ParameterProperties"): """ Performs some checks before adding a parameter to the dictionary. Parameters ---------- key The key of the key-value pair to be added to self. value The value of the key-value pair to be added to self. """ if type(key) != str: raise ValueError( f"The key must be a parameters name (string), but you provided " f"something of type '{type(key)}'." ) if type(value) != ParameterProperties: raise ValueError( f"The properties of your parameter must be given in form of an " f"ParameterProperties-object. But you provided something of " f"type '{type(value)}'." ) super().__setitem__(key, value) def __delitem__(self, key: str): """ Deletes an item from itself while taking care of additional actions. For example removing prior-parameters when deleting a latent parameter or keeping the index- attributes of the latent parameters consistent. Parameters ---------- key The key of the key-value pair to be removed from self. """ # the given key is a parameter's name (renaming for easier readability) prm_name = key # check if the given parameter exists self.confirm_that_parameter_exists(prm_name) # different steps must be taken depending on whether the parameter which should # be removed is a 'const'- or a 'latent'-parameter if self[prm_name].index is None: # in this case prm_name refers to a constant parameter; hence, we can simply # remove this parameter without having to take care of other things as we # will have to do for latent parameters dict.__delitem__(self, key) else: # in this case prm_name refers to a latent parameter; hence we need to also # remove the prior-parameters; also, we have to correct the index values of # the remaining latent parameters for prior_prm in self[prm_name].prior.hyperparameters.keys(): self.__delitem__(prior_prm) # recursive call dict.__delitem__(self, prm_name) # correct the indices of the remaining 'latent'-parameters; note that the # way how the correction is done is due to the fact that the parameter.index # attribute is protected, and cannot be changed directly from outside idx_dict = {} idx = 0 for prm_name, parameter in self.items(): if parameter.is_latent: dim = parameter.dim idx_dict[prm_name] = idx idx += dim for prm_name, idx in idx_dict.items(): self[prm_name] = self[prm_name].changed_copy(index=idx)
[docs] def confirm_that_parameter_exists(self, prm_name: str): """ Checks if a parameter, given by its name, exists among the currently defined parameters. An error is raised when the given parameter does not exist yet. Parameters ---------- prm_name A global parameter name. """ if prm_name not in self: raise RuntimeError( f"A parameter with name '{prm_name}' has not been defined yet." )
[docs] def confirm_that_parameter_does_not_exists(self, prm_name: str): """ Checks if a parameter, given by its name, exists among the currently defined parameters. An error is raised when the given parameter does already exist. Parameters ---------- prm_name A global parameter name. """ if prm_name in self: raise RuntimeError( f"A parameter with name '{prm_name}' has already been defined." )
@property def prms(self) -> List[str]: """Access the names of all parameters as an attribute.""" return [*self.keys()] @property def n_prms(self) -> int: """Access the number of all parameters as an attribute.""" return len(self) @property def latent_prms(self) -> List[str]: """Access the names of all 'latent'-parameters as an attribute.""" return [name for name, prm in self.items() if prm.is_latent] @property def latent_prms_dims(self) -> List[int]: """Access the individual dimensions of the latent parameters.""" return [self[prm_name].dim for prm_name in self.latent_prms] @property def n_latent_prms(self) -> int: """Access the number of all 'latent'-parameters as an attribute.""" return len(self.latent_prms) @property def n_latent_prms_dim(self) -> int: """Access the combined dimension of all latent parameters. This number is the number of elements in the theta vector.""" return sum(self.latent_prms_dims) @property def constant_prms(self) -> List[str]: """Access the names of all 'const'-parameters as an attribute.""" return [name for name, prm in self.items() if prm.is_const] @property def constant_prms_dict(self) -> dict: """Access the names and values of all 'const'-param. as an attribute.""" return {name: prm.value for name, prm in self.items() if prm.is_const} @property def n_constant_prms(self) -> int: """Access the number of all 'const'-parameters as an attribute.""" return len(self.constant_prms) @property def model_prms(self) -> List[str]: """Access the names of all 'model'-parameters as an attribute.""" return [name for name, prm in self.items() if prm.type == "model"] @property def n_model_prms(self) -> int: """Access the number of all 'model'-parameters as an attribute.""" return len(self.model_prms) @property def prior_prms(self) -> List[str]: """Access the names of all 'prior'-parameters as an attribute.""" return [name for name, prm in self.items() if prm.type == "prior"] @property def n_prior_prms(self) -> int: """Access the number of all 'prior'-parameters as an attribute.""" return len(self.prior_prms) @property def likelihood_prms(self) -> List[str]: """Access the names of all 'likelihood'-parameters as an attribute.""" return [name for name, prm in self.items() if prm.type == "likelihood"] @property def n_likelihood_prms(self) -> int: """Access the number of all 'likelihood'-parameters as an attribute.""" return len(self.likelihood_prms) @property def value_dict(self) -> dict: """ Returns a dict with the parameter names as keys and their numeric values as values. A parameter will only have a value if it is a constant. For latent parameters the dictionary-value will be None """ return {name: props.value for name, props in self.items()}
[docs] def overview(self, tablefmt: str = "presto") -> str: """ Returns a string providing an overview of the defined parameters. Parameters ---------- tablefmt An argument for the tabulate function defining the style of the generated table. Check out tabulate's documentation for more info. Returns ------- prm_string This string describes a nice table with some essential information on the parameters of the problem. """ # each element describes one row in the table to be generated rows = [ ( "Model parameters", simplified_list_string(self.model_prms), self.n_model_prms, ), ( "Prior parameters", simplified_list_string(self.prior_prms), self.n_prior_prms, ), ( "Likelihood parameters", simplified_list_string(self.likelihood_prms), self.n_likelihood_prms, ), ( "Const parameters", simplified_list_string(self.constant_prms), self.n_constant_prms, ), ( "Latent parameters", simplified_list_string(self.latent_prms), self.n_latent_prms, ), ] # these are the strings appearing in the column headers headers = ["Parameter type/role", "Parameter names", "Count"] prm_table = tabulate(rows, headers=headers, tablefmt=tablefmt) prm_string = titled_table("Parameter overview", prm_table) return prm_string
[docs] def parameter_explanations(self, tablefmt: str = "presto") -> str: """ Returns a string providing short explanations on the defined parameters. Parameters ---------- tablefmt An argument for the tabulate function defining the style of the generated table. Check out tabulate's documentation for more info. Returns ------- prm_string This string describes a nice table with short explanations on the parameters of the problem. """ rows = [(name, prm.info) for name, prm in self.items()] headers = ["Name", "Short explanation"] prm_table = tabulate(rows, headers=headers, tablefmt=tablefmt) prm_string = titled_table("Parameter explanations", prm_table) return prm_string
[docs] def const_parameter_values(self, tablefmt: str = "presto") -> str: """ Returns a string providing the values of the defined 'const'-parameters. Parameters ---------- tablefmt An argument for the tabulate function defining the style of the generated table. Check out tabulate's documentation for more info. Returns ------- prm_string This string describes a nice table with the names and values of the constant parameters of the problem. """ rows = [ (name, prm.value) for name, prm in self.items() if prm.value is not None ] headers = ["Name", "Value"] prm_table = tabulate(rows, headers=headers, tablefmt=tablefmt) prm_string = titled_table("Constant parameters", prm_table) return prm_string
[docs]class ParameterProperties: """ Describes relevant properties of a ('latent' or 'const') parameter. Objects from this class are associated with the parameter's name in the dictionary class 'Parameters', see above. The use of this class as opposed to a standard dictionary allows convenient auto-completion while coding. Parameters ---------- prm_dict The keys are 'index', 'dim', 'type', 'role', 'prior', 'value', 'info' and 'tex', while the values are the corresponding values of these properties. See also the explanations in InverseProblem.__init__() for more detailed information. """ def __init__(self, prm_dict: dict): # write attributes self._index = prm_dict["index"] self._type = prm_dict["type"] self._domain = prm_dict["domain"] self._prior = prm_dict["prior"] self._value = prm_dict["value"] self.info = prm_dict["info"] self.tex = prm_dict["tex"] # the dimension (dim) attribute is only expected to be contained in the given # prm_dict, if an index is specified; if no index is specified, a constant is # given, which defines its dimension based on its value if self._index is None: # constant parameter self._dim = len_or_one(self._value) else: # latent parameter self._dim = prm_dict["dim"] if type(prm_dict["domain"]) == str: self._domain = self.translate_domain_string(prm_dict["domain"]) # check if each component has its domain if self._dim != len(self._domain): raise RuntimeError( f"The dimension of the parameter ({self._dim}) is not identical " f"with the number of provided domains ({len(self._domain)})!" ) # whitespace in the tex strings is a problem for some plotting routines, so they # are replaced here by a math-command for whitespace that does not contain # actual whitespace if self.tex: self.tex = self.tex.replace(" ", r"$\enspace$") # check the given values self.check_consistency()
[docs] @staticmethod def translate_domain_string(domain_string: str) -> list: """ Translate a domain string like "(0, 1]" into a list of ScalarInterval objects. Parameters ---------- domain_string A string like "(0, 1]" or "[0, 1] [0, 1]" defining the domain of a (possibly vector-valued) parameter. Returns ------- intervals List of ScalarInterval objects derived form 'domain_string'. """ # perform simple check on the given domain string _ = count_intervals(domain_string) # extract the intervals p_number = r"[-+]?[0-9]*\.?[0-9]*" p_infinity = r"[+-]?oo" p_value = rf"{p_number}|{p_infinity}" pattern = re.compile(rf"([\[(])({p_value})\W*?({p_value})([])])") interval_groups = pattern.findall(domain_string) intervals = [] for interval_group in interval_groups: lower_bound_included = interval_group[0] == "[" lower_bound = translate_number_string(interval_group[1]) upper_bound = translate_number_string(interval_group[2]) upper_bound_included = interval_group[3] == "]" intervals.append( ScalarInterval( lower_bound, upper_bound, lower_bound_included, upper_bound_included ) ) return intervals
# noinspection PyShadowingBuiltins
[docs] def changed_copy( self, index: Optional[int] = None, dim: Optional[int] = None, domain: Union[tuple, List[tuple]] = None, type: Optional[str] = None, prior: Union[list, tuple, None] = None, value: Union[int, float, np.ndarray, None] = None, info: Optional[str] = None, tex: Optional[str] = None, ) -> "ParameterProperties": """ Convenience method that simplifies changing the attributes of a ParameterProperties object based on creating a new instance. The reason for this approach is that some of the attributes are private, and cannot (or at least should not) be changed directly from outside. See the explanations in InverseProblem.__init__() for more detailed information on the arguments. """ return ParameterProperties( { "index": index if index is not None else self._index, "dim": dim if dim is not None else self._dim, "domain": domain if domain is not None else self._domain, "type": type if type is not None else self._type, "prior": prior if prior is not None else self._prior, "value": value if value is not None else self._value, "info": info if info is not None else self.info, "tex": tex if tex is not None else self.tex, } )
[docs] def check_consistency(self): """ Checks the defined attributes in both isolated checks (each attribute is checked without considering others) and cross-checks, where the combination of attributes is checked on consistency. """ # ------------------------------- # # Isolated checks # # ------------------------------- # if not (type(self._index) == int or self._index is None): raise TypeError( f"Found invalid ParameterProperties._index attribute! It must be of " f"type int or None, but found {type(self._index)}." ) if (self._index is not None) and (self._index < 0): raise ValueError( f"Found negative value for ParameterProperties._index! This attribute " f"must be a non-negative integer, but found a value of {self._index}." ) if not (type(self._dim) == int or self._dim is None): raise TypeError( f"Found invalid ParameterProperties._dim attribute! It must be of type " f"int or None, but found {type(self._dim)}." ) if self._type not in ["model", "prior", "likelihood", "not defined"]: raise RuntimeError( f"Found invalid ParameterProperties._type attribute! It can only " f"assume the three values 'model', 'prior', 'likelihood' (or " f"'not defined' when automatic type-assignment is used) but found " f"'{self._type}'." ) if not (isinstance(self._prior, PriorBase) or self._prior is None): raise TypeError( f"Found invalid ParameterProperties._prior attribute! It must be of " f"type PriorBase or None, but found {type(self._prior)}." ) # -------------------------------- # # Cross checks # # -------------------------------- # if self._index is not None: # in this case, we have a latent parameter if self._value is not None: raise RuntimeError( f"ParameterProperties._index and ParameterProperties._value are " f"both given (_index={self._index} and _value={self._value}), but " f"one of them must be None!" ) if self._prior is None: raise RuntimeError( f"When ParameterProperties._index is not None " f"ParameterProperties._prior cannot be None!" ) else: # in this case, we have a constant parameter if self._value is None: raise RuntimeError( f"ParameterProperties._index and ParameterProperties._value are " f"both None, but one of them must be not None!" ) if self._prior is not None: raise RuntimeError( f"ParameterProperties._index is None while Parameter" f"Properties._prior is given ({self._prior}). This combination is " f"not valid. Either the index must also be given, or the prior " f"must also be None." )
@property def index(self) -> int: """Access self._index from outside via self.index.""" return self._index @index.setter def index(self, value: Union[int, float]): """Raise a specific error when trying to directly set self.index.""" raise AttributeError("Changing a parameter's index directly is prohibited!") @property def dim(self) -> int: """Access self._dim from outside via self.dim.""" return self._dim @dim.setter def dim(self, value: int): """Raise a specific error when trying to directly set self.dim.""" raise AttributeError( "Changing a parameter's dimension (dim) directly is prohibited!" ) @property def domain(self) -> Union[tuple, list]: """Access self._domain from outside via self.domain.""" return self._domain @domain.setter def domain(self, value: Union[tuple, list]): """Raise a specific error when trying to directly set self.domain.""" raise AttributeError("Changing a parameter's domain directly is prohibited!") @property def index_end(self) -> int: """Adds a pseudo-attribute self.index_end, which allows a convenient access to the (not-inclusive) end index in the parameter vector.""" return self._index + self._dim @property def type(self) -> str: """Access self._type from outside via self.type.""" return self._type @type.setter def type(self, value: str): """Raise a specific error when trying to directly set self.type.""" raise AttributeError("Changing a parameter's type directly is prohibited!") @property def role(self) -> str: """Adds a pseudo-attribute self.role, which allows a convenient check on whether a parameter is latent or not.""" return "latent" if self._index is not None else "const" @role.setter def role(self, value: str): """Raise a specific error when trying to directly set self.role.""" raise AttributeError( "You cannot change a parameter's role directly! Use " "InverseProblem.change_parameter_role instead." ) @property def is_latent(self) -> bool: """Adds a pseudo-attribute self.is_latent, which allows a convenient check on whether a parameter is latent or not.""" return self._index is not None @property def is_const(self) -> bool: """Adds a pseudo-attribute self.is_const, which allows a convenient check on whether a parameter is constant or not.""" return not self.is_latent @property def prior(self) -> Union[tuple, list, None]: """Access self._prior from outside via self.prior.""" return self._prior @prior.setter def prior(self, value: Union[tuple, list, None]): """Raise a specific error when trying to directly set self.prior.""" raise AttributeError("Changing a parameter's prior directly is prohibited!") @property def value(self) -> Union[int, float]: """Access self._value from outside via self.value.""" return self._value @value.setter def value(self, value: Union[int, float]): """Raise a specific error when trying to directly set self.value.""" raise AttributeError("Changing a parameter's value directly is prohibited!")
[docs]class ScalarInterval: """ Describes a one-dimensional interval. Used for the domain-definition of parameters. Parameters ---------- lower_bound The lower bound of the interval (if the interval is [a, b], this here is a). upper_bound The upper bound of the interval (if the interval is [a, b], this here is b). lower_bound_included Defines if the lower bound is included in the interval. upper_bound_included Defines if the upper bound is included in the interval. """ def __init__( self, lower_bound: float, upper_bound: float, lower_bound_included: bool, upper_bound_included: bool, ): # write arguments to attributes self.lower_bound = lower_bound self.upper_bound = upper_bound self.lower_bound_included = lower_bound_included self.upper_bound_included = upper_bound_included # set the check_bounds-method; the distinction with respect to the inclusion of # bounds is done here and not in the check_bounds-method so that this check is # not repeated each time this method is called if lower_bound_included and upper_bound_included: self.check_bounds = self.check_bounds_inc_inc elif (not lower_bound_included) and upper_bound_included: self.check_bounds = self.check_bounds_ninc_inc elif lower_bound_included and (not upper_bound_included): self.check_bounds = self.check_bounds_inc_ninc else: self.check_bounds = self.check_bounds_ninc_ninc
[docs] def check_bounds_inc_inc(self, value: Union[int, float]) -> bool: """ Checks if a given value is within the specified bounds (where both bounds are included). Parameters ---------- value The given scalar value. Returns ------- True, if the value is within its bounds, otherwise False is returned. """ if self.lower_bound <= value <= self.upper_bound: return True else: return False
[docs] def check_bounds_ninc_inc(self, value: Union[int, float]) -> bool: """ Checks if a given value is within the specified bounds (where only the upper bound is included). Parameters ---------- value The given scalar value. Returns ------- True, if the value is within its bounds, otherwise False is returned. """ if self.lower_bound < value <= self.upper_bound: return True else: return False
[docs] def check_bounds_inc_ninc(self, value: Union[int, float]) -> bool: """ Checks if a given value is within the specified bounds (where only the lower bound is included). Parameters ---------- value The given scalar value. Returns ------- True, if the value is within its bounds, otherwise False is returned. """ if self.lower_bound <= value < self.upper_bound: return True else: return False
[docs] def check_bounds_ninc_ninc(self, value: Union[int, float]) -> bool: """ Checks if a given value is within the specified bounds (where only the upper bound is included). Parameters ---------- value The given scalar value. Returns ------- True, if the value is within its bounds, otherwise False is returned. """ if self.lower_bound < value < self.upper_bound: return True else: return False
def __str__(self): s1 = "[" if self.lower_bound_included else "(" s2 = "-oo" if self.lower_bound == -np.infty else self.lower_bound s3 = "+oo" if self.upper_bound == np.infty else self.upper_bound s4 = "]" if self.upper_bound_included else ")" return f"{s1}{s2}, {s3}{s4}"