Source code for pyvbmc.vbmc.options

# for annotating Options as input of itself
from __future__ import annotations

import configparser
import copy
import inspect

# The values of an options file are evaluated in this module's namespace,
# where a log_file_level may name a level of the logging module.
import logging  # nopycln: import
import re
from collections.abc import MutableMapping
from math import ceil
from numbers import Real
from pathlib import Path
from textwrap import indent

import numpy as np

from pyvbmc._logging import get_logger
from pyvbmc.acquisition_functions import *
from pyvbmc.formatting import full_repr
from pyvbmc.parameter_transformer.parameter_transformer import (
    _bounded_transform_type,
)
from pyvbmc.whitening.whitening import _is_finite_real_number

#: Options declared in the ``.ini`` files that no PyVBMC module reads. They
#: are kept so that option dictionaries recorded by earlier runs still load,
#: and the value given for one of them has no effect. The ``# description``
#: line of each says why it is there.
INERT_OPTIONS = frozenset(
    {
        "acq_hedge_decay",
        "acq_hedge_iter_window",
        "active_importance_sampling_fess_thresh",
        "active_sample_fess_thresh",
        "active_variational_samples",
        "adaptive_entropy_alpha",
        "annealed_gp_mean",
        "constrained_gp_mean",
        "cov_sample_thresh",
        "diagnostics",
        "double_gp",
        "empirical_gp_prior",
        "gp_int_mean_fun",
        "gp_stochastic_step_size",
        "integrate_gp_mean",
        "noise_shaping_factor",
        "noise_shaping_threshold",
        "nonlinear_scaling",
        "optimistic_variational_bound",
        "output_fcn",
        "proposal_fcn",
        "sample_extra_vp_means",
        "scale_lower_bound",
        "search_cmaes_best",
        "separate_search_gp",
        "temperature",
        "variational_init_repo",
        "variational_sampler",
        "warmup_options",
    }
)


#: The ``.ini`` files that declare every option PyVBMC accepts. A name
#: outside them is not an option, wherever it was supplied.
SHIPPED_OPTIONS_PATHS = (
    "option_configs/basic_vbmc_options.ini",
    "option_configs/advanced_vbmc_options.ini",
)


def declared_option_names(options_paths=SHIPPED_OPTIONS_PATHS):
    """
    The option names the given ini files declare.

    Parameters
    ----------
    options_paths : iterable of str, optional
        Paths to ini files, absolute or relative to this directory.
        Default the two files PyVBMC ships.

    Returns
    -------
    names : set of str
        Every option name declared in those files.
    """
    names = set()
    for options_path in options_paths:
        names.update(_read_config_file(options_path)[:, 0].flatten())
    return names


# How the integer_vars option may be written, named in the errors raised
# for any other value.
_INTEGER_VARS_FORMS = (
    "a boolean array with one entry per variable, or an array of the "
    "0-based indices of the variables that take only integer values"
)

# How the uncertainty_handling option may be written, named in the error
# raised for any other value.
_UNCERTAINTY_HANDLING_FORMS = (
    "True or False (the integers 1 and 0 and their NumPy equivalents are "
    "also accepted), or an empty value ([], an empty array or None) to "
    "leave the choice to specify_target_noise"
)

# How the specify_target_noise option may be written, named in the error
# raised for any other value.
_SPECIFY_TARGET_NOISE_FORMS = (
    "True or False (the integers 1 and 0 and their NumPy equivalents are "
    "also accepted)"
)

# How the noise_size option may be written, named in the error raised for
# any other value.
_NOISE_SIZE_FORMS = (
    "a positive finite number (a Python or NumPy integer or floating-point "
    "number, or a 0-d array that holds one; not a boolean), or an empty "
    "value ([], an empty array or None) to leave it unset"
)


def _is_positive_integer_valued(value):
    """
    Whether a limit on iterations or evaluations is a positive integer.

    A floating value that lands on an integer counts, as MATLAB VBMC's
    ``round(x) ~= x`` test lets one through, and so does positive
    infinity, which stands for no limit.
    """
    if not isinstance(value, Real):
        return False
    if not value > 0:
        return False
    return bool(np.isinf(value)) or float(value).is_integer()


def _is_finite_non_negative_integer_valued(value):
    """
    Whether a count that may be zero is a finite non-negative integer.

    A floating value that lands on an integer counts, as for
    `_is_positive_integer_valued`; infinity and NaN do not.
    """
    if not isinstance(value, Real):
        return False
    if not value >= 0 or np.isinf(value):
        return False
    return float(value).is_integer()


def _stated_boolean(value):
    """
    The boolean that a value states, or `None` when it states none.

    A boolean, the integers 1 and 0 and their NumPy equivalents state one.
    """
    if isinstance(value, (bool, np.bool_)):
        return bool(value)
    if isinstance(value, (int, np.integer)) and int(value) in (0, 1):
        return bool(value)
    return None


def _specify_target_noise_flag(value):
    """
    Read the ``specify_target_noise`` option as a boolean.

    Parameters
    ----------
    value : object
        The value of the option.

    Returns
    -------
    flag : bool
        Whether the target returns its own noise estimate.

    Raises
    ------
    ValueError
        When the value is not a boolean.
    """
    flag = _stated_boolean(value)
    if flag is None:
        raise ValueError(
            "The option specify_target_noise must be "
            + _SPECIFY_TARGET_NOISE_FORMS
            + f"; got {value!r}."
        )
    return flag


def _is_empty_value(value):
    """Whether an option holds an empty value (``None``, ``[]``, an empty
    tuple or an empty array), which states nothing."""
    if value is None:
        return True
    return isinstance(value, (list, tuple, np.ndarray)) and np.size(value) == 0


def _noise_size_reading(value):
    """
    Read the ``noise_size`` option.

    An empty value leaves the option unset. Any other value has to be a
    positive finite number, as ``misc/setupoptions_vbmc.m:131-132``
    requires, since the GP fit starts its noise from the logarithm of the
    value. A 0-d array counts as the number it holds; an array with an
    axis, even of one entry, does not. Release 1.0.4 ran a number that is
    not positive as it ran the option unset, and the refusal of such a
    number says so.

    Parameters
    ----------
    value : object
        The value of the option.

    Returns
    -------
    noise_size : float or None
        The value as a float, or `None` when the option is empty.

    Raises
    ------
    ValueError
        When the value is neither empty nor a positive finite number.
    """
    if _is_empty_value(value):
        return None
    if _is_finite_real_number(value) and value > 0:
        return float(value)
    message = "The option noise_size must be " + _NOISE_SIZE_FORMS
    message += f"; got {value!r}."
    unset = "VBMC.load(file, new_options={'noise_size': []})"
    if _is_finite_real_number(value):
        message += (
            " Release 1.0.4 ran a value that is not positive as it ran the "
            "option unset. A saved run that carries one is continued with "
            f"the option unset, with {unset}, or with a positive noise_size "
            "given the same way."
        )
    else:
        message += (
            " A saved run that carries such a value is continued with "
            f"{unset}, which leaves the option unset."
        )
    raise ValueError(message)


def _uncertainty_handling_flag(value):
    """
    Read the ``uncertainty_handling`` option as a boolean.

    Parameters
    ----------
    value : object
        The value of the option.

    Returns
    -------
    flag : bool or None
        `True` or `False` when the value states the choice, and `None` when
        the option is empty, which leaves the choice to
        ``specify_target_noise``.

    Raises
    ------
    ValueError
        When the value is neither a boolean nor empty.
    """
    if value is None:
        return None
    flag = _stated_boolean(value)
    if flag is not None:
        return flag
    if isinstance(value, (list, tuple, np.ndarray)) and np.size(value) == 0:
        return None
    raise ValueError(
        "The option uncertainty_handling must be "
        + _UNCERTAINTY_HANDLING_FORMS
        + f"; got {value!r}."
    )


def _uncertainty_handling_level(uncertainty_handling, specify_target_noise):
    """
    The uncertainty handling level that ``uncertainty_handling`` and
    ``specify_target_noise`` select together, as construction reads them.

    Parameters
    ----------
    uncertainty_handling : object
        The value of the ``uncertainty_handling`` option.
    specify_target_noise : object
        The value of the ``specify_target_noise`` option.

    Returns
    -------
    level : int
        0 for a target without noise, 1 for a noisy target whose noise level
        is inferred, and 2 for a target that returns its own noise
        estimate. An empty ``uncertainty_handling`` follows
        ``specify_target_noise``.

    Raises
    ------
    ValueError
        When ``uncertainty_handling`` holds a value that is neither a
        boolean nor empty, when ``specify_target_noise`` holds a value that
        is not a boolean, or when ``uncertainty_handling`` is off while
        ``specify_target_noise`` is set.
    """
    requested = _uncertainty_handling_flag(uncertainty_handling)
    if _specify_target_noise_flag(specify_target_noise):
        if requested is False:
            raise ValueError(
                "A target that returns its own noise estimate is a "
                "noisy target: with specify_target_noise set, "
                "uncertainty_handling cannot be turned off. Leave it "
                "empty or set it to True."
            )
        return 2
    return 1 if requested else 0


def _integer_vars_mask(value, D):
    """
    Read a value of the ``integer_vars`` option as a mask over the
    variables; :py:meth:`Options.integer_vars_mask` describes the forms.
    """
    mask = np.full(D, False)
    if value is None:
        return mask
    array = np.asarray(value)
    if array.size == 0:
        return mask
    if array.ndim != 1 or not (
        array.dtype == bool or np.issubdtype(array.dtype, np.integer)
    ):
        raise ValueError(
            "The option integer_vars must be "
            + _INTEGER_VARS_FORMS
            + f"; got {value!r}."
        )
    if array.dtype == bool:
        if array.size != D:
            raise ValueError(
                "The option integer_vars, written as a boolean mask, "
                f"needs one entry per variable, that is {D}; got "
                f"{array.size}."
            )
        mask[array] = True
        return mask
    if array.size == D and np.all((array == 0) | (array == 1)):
        raise ValueError(
            f"The option integer_vars holds {D} integers, each of them "
            "zero or one, which reads both as a mask and as a list of "
            "indices. Write a boolean array to give a mask."
        )
    if np.any(array < 0) or np.any(array >= D):
        raise ValueError(
            "The option integer_vars, written as indices, needs "
            f"0-based indices of the {D} variables; got {value!r}."
        )
    if np.unique(array).size != array.size:
        raise ValueError(
            "The option integer_vars, written as indices, names a "
            f"variable twice; got {value!r}."
        )
    mask[array] = True
    return mask


#: The options that construction reads by their truth.
_OPTIONS_READ_BY_TRUTH = ("warmup", "entropy_switch", "fitness_shaping")


def _construction_reading(name, value, D):
    """
    The value of an option as construction reads it.

    ``uncertainty_handling`` and ``specify_target_noise`` are read as the
    choices they state, ``integer_vars`` as its mask over the `D`
    variables, ``f_vals`` as a flat array, ``bounded_transform`` as the
    number of the transform it names, which ``ParameterTransformer`` gives
    ``"probit"`` and ``"norminv"`` alike, and the options of
    :data:`_OPTIONS_READ_BY_TRUTH` by their truth; any other option is read
    as it is.

    Raises
    ------
    ValueError
        When construction refuses the value.
    """
    if name == "uncertainty_handling":
        return _uncertainty_handling_flag(value)
    if name == "specify_target_noise":
        return _specify_target_noise_flag(value)
    if name == "integer_vars":
        return _integer_vars_mask(value, D)
    if name == "f_vals":
        return np.array(value).ravel()
    if name == "bounded_transform":
        return _bounded_transform_type(value)
    if name in _OPTIONS_READ_BY_TRUTH:
        return bool(value)
    return value


def _same_reading(value, other):
    """
    Whether two readings of an option are the same.

    Two strings are the same when they are equal. Numbers, booleans and
    arrays of them are the same when their values are equal and their
    shapes agree, NaN matching NaN, so that a boolean and the number it
    equals are the same. ``None`` is the same only as ``None``, and other
    objects are compared as :func:`_equals_default` compares a value with
    its default.
    """
    if value is other:
        return True
    if isinstance(value, str) or isinstance(other, str):
        return (
            isinstance(value, str)
            and isinstance(other, str)
            and value == other
        )
    if value is None or other is None:
        return False
    try:
        value_array = np.asarray(value, dtype=np.float64)
        other_array = np.asarray(other, dtype=np.float64)
    except (TypeError, ValueError):
        return _equals_default(value, other)
    return value_array.shape == other_array.shape and bool(
        np.array_equal(value_array, other_array, equal_nan=True)
    )


def _states_the_stored_value(name, value, stored, D):
    """
    Whether a value of an option that only construction reads states what
    the value a run stores for it states.

    Both are read as construction reads the option
    (:func:`_construction_reading`) and compared by
    :func:`_same_reading`. A stored value that construction would refuse,
    which a run saved by an earlier release can hold, states nothing that a
    value given now can match.

    Parameters
    ----------
    name : str
        The name of the option.
    value : object
        The value given for it.
    stored : object
        The value the run stores for it.
    D : int
        The number of variables of the run.

    Returns
    -------
    same : bool
        Whether the two values state the same.

    Raises
    ------
    ValueError
        When construction refuses ``value``.
    """
    reading = _construction_reading(name, value, D)
    try:
        stored_reading = _construction_reading(name, stored, D)
    except ValueError:
        return False
    return _same_reading(reading, stored_reading)


#: The two options that construction reads together, as the uncertainty
#: handling level they select (:func:`_uncertainty_handling_level`).
_UNCERTAINTY_HANDLING_OPTIONS = (
    "uncertainty_handling",
    "specify_target_noise",
)


def _states_the_stored_uncertainty_handling(given, stored):
    """
    Whether values given for ``uncertainty_handling`` or
    ``specify_target_noise`` state the uncertainty handling level that the
    values a run stores for the two state.

    Construction reads the two options as a pair, an empty
    ``uncertainty_handling`` following ``specify_target_noise``, so each
    value given is read together with the one given for the other option or,
    where none is, the stored one. A pair that construction refuses states
    no level.

    Parameters
    ----------
    given : dict
        The values given for one or both of the two options, by name.
    stored : dict
        The values the run stores for both options, by name.

    Returns
    -------
    same : bool
        Whether the two pairs state the same level.
    """
    pair = {**stored, **given}
    try:
        level = _uncertainty_handling_level(
            pair["uncertainty_handling"], pair["specify_target_noise"]
        )
        stored_level = _uncertainty_handling_level(
            stored["uncertainty_handling"], stored["specify_target_noise"]
        )
    except ValueError:
        return False
    return level == stored_level


def _takes_keyword(function, name):
    """
    Whether a callable takes an argument by the keyword `name`.

    It does when it has a parameter of that name that is not
    positional-only, or ``**kwargs``. A callable whose signature
    :func:`inspect.signature` cannot read is taken not to.
    """
    try:
        parameters = inspect.signature(function).parameters.values()
    except (TypeError, ValueError):
        return False
    for parameter in parameters:
        if parameter.kind is inspect.Parameter.VAR_KEYWORD:
            return True
        if parameter.name == name and parameter.kind in (
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
            inspect.Parameter.KEYWORD_ONLY,
        ):
            return True
    return False


[docs] class Options(MutableMapping, dict): """Store and validate VBMC options. Parameters ---------- default_options_path : str The path to the default options. May be absolute or relative to this directory. evaluation_parameters : dict Parameters used to evaluate the options. user_options : dict User defined values to overwrite default options. Attributes ---------- useroptions : set Options that have been set by the user. Values named here are not overwritten by :py:meth:`load_options_file`. """ def __init__( self, default_options_path: str, evaluation_parameters: dict = None, user_options: dict = None, ): """ Initialize the options using default options and specified options from the user. """ # Flag initialization as in-progress # (completed in self.validate_option_names) self.is_initialized = False super().__init__() self.descriptions = {} self["useroptions"] = set() self.default_options_path = default_options_path self.evaluation_parameters = evaluation_parameters self.user_options = user_options # load options from file self.load_options_file(default_options_path, evaluation_parameters) # User options. They may be the options of another run, as an # `Options` object or a dict copied from one, whose set of user # options is left out: taken over, it would be shared between the # two, and the names added here would change the other run's. if user_options is not None: supplied = { k: v for k, v in user_options.items() if k != "useroptions" } self.update(supplied) self["useroptions"].update(supplied.keys())
[docs] def integer_vars_mask(self, D: int): """ Read the ``integer_vars`` option as a mask over the variables. A boolean array with one entry per variable is that mask. An array of integers holds the 0-based indices of the integer variables, which have to be distinct and within range. An array of `D` integers that are all zero or one reads as either and is refused. Parameters ---------- D : int The number of variables. Returns ------- mask : np.ndarray A boolean array of length `D`, `True` at the variables that take only integer values. Raises ------ ValueError When the value is neither a boolean mask of length `D` nor an array of distinct indices within range. """ return _integer_vars_mask(self.get("integer_vars"), D)
[docs] def validate_run_limits(self): """ Check the limits on iterations and function evaluations. ``max_fun_evals`` and ``max_iter`` have to be positive integers, as ``misc/setupoptions_vbmc.m:109-114`` requires, or infinity for no limit. ``min_iter`` has to be a finite non-negative integer, 0 for a run without a minimum: the minimum holds back every termination, the one on the budget of evaluations included unless the run accounts for evaluations made before it (``precomputed_evaluations`` or an ``initialization_cost``), so a run under an infinite one would never stop, or would stop only at its budget. A floating value that lands on an integer counts. A ``max_iter`` below ``min_iter`` is raised to it, as ``misc/setupoptions_vbmc.m:115-119`` does. Raises ------ ValueError When ``max_fun_evals`` or ``max_iter`` is not a positive integer, or ``min_iter`` is not a finite non-negative integer. """ for key in ("max_fun_evals", "max_iter"): value = self.get(key) if not _is_positive_integer_valued(value): raise ValueError( f"The option {key} needs to be a positive integer; " f"got {value!r}." ) min_iter = self.get("min_iter") if not _is_finite_non_negative_integer_valued(min_iter): raise ValueError( "The option min_iter needs to be a finite non-negative " f"integer (0 for no minimum); got {min_iter!r}. A saved run " "that carries such a value is continued with " "VBMC.load(file, new_options={'min_iter': 0}), or with " "another minimum." ) if self.get("max_iter") < min_iter: get_logger("VBMC_init").warning( "The option max_iter cannot be smaller than min_iter. " "Raising max_iter to %s.", self.get("min_iter"), ) self.__setitem__("max_iter", self.get("min_iter"), force=True)
[docs] def uncertainty_handling_on(self): """ Whether the run treats the target log-density as noisy. It does when the target returns its own noise estimate (``specify_target_noise``) or when ``uncertainty_handling`` asks for the noise level to be inferred. Returns ------- on : bool Whether uncertainty handling is on. Raises ------ ValueError When ``uncertainty_handling`` holds a value that is neither a boolean nor empty, when ``specify_target_noise`` holds a value that is not a boolean, or when ``uncertainty_handling`` is off while ``specify_target_noise`` is set. """ level = _uncertainty_handling_level( self.get("uncertainty_handling"), self.get("specify_target_noise") ) return level > 0
[docs] def update_defaults(self): """Change defaults as needed based on values of other options.""" if self.uncertainty_handling_on(): # Each default is computed only if it is going to be used: a # value the user set need not admit the computation (an # infinite budget has no ceiling). updates = { "max_fun_evals": lambda: ceil(self["max_fun_evals"] * 1.5), "tol_stable_count": lambda: ceil( self["tol_stable_count"] * 1.5 ), "active_sample_gp_update": lambda: True, "active_sample_vp_update": lambda: True, "search_acq_fcn": lambda: [AcqFcnVIQR()], } for key, default in updates.items(): if key not in self["useroptions"]: self[key] = default()
[docs] @classmethod def init_from_existing_options( cls, default_options_path: str, evaluation_parameters: dict = None, other: Options = None, ): """ Initialize an options instance using default options and another options instance. Only the user-definied options from the other object will overwrite the default options. Everything else will come from the default options. Parameters ---------- default_options_path : str The path to the default options. May be absolute or relative to this directory. evaluation_parameters : dict Parameters used to evaluate the options. other : Options User defined values to overwrite default options. Returns ------- new_options : Options The new options object with the values merged as described above. """ if other is None: user_options = None else: user_option_keys = other.get("useroptions") user_options = {k: other.get(k) for k in user_option_keys} new_options = cls( default_options_path, evaluation_parameters, user_options ) return new_options
[docs] def load_options_file( self, options_path: str, evaluation_parameters: dict = None, as_user_options: bool = False, ): """ Load options from an ini file and evaluate them using the specified ``evaluation_parameters``. Note that strings starting with # in the .ini file act as description to the option in the following line. Parameters ---------- options_path : str The path to an ``options.ini`` file that should be loaded. May be absolute or relative to this directory. evaluation_parameters : dict, optional Parameters used to evaluate the options. as_user_options : bool, optional Whether the file states the user's choices rather than the defaults PyVBMC ships. The options it sets then join ``useroptions``, so that they survive a later load and are not replaced by :py:meth:`update_defaults`. Default `False`. """ options_list = _read_config_file(options_path) loaded = set() for key, value, description in options_list: if key == "useroptions": continue if key not in self.get("useroptions"): self[key] = eval(value, globals(), evaluation_parameters) loaded.add(key) if description or key not in self.descriptions: self.descriptions[key] = description elif key not in self.descriptions: # The description belongs to the option, whoever set its # value. self.descriptions[key] = description if as_user_options: self["useroptions"].update(loaded)
[docs] def validate_supplied_option_names(self, names): """ Check that every name of ``names`` is one PyVBMC declares. Parameters ---------- names : iterable of str The option names a caller supplied. Raises ------ ValueError Raised when a name is declared by neither of the shipped ini files. """ declared = declared_option_names() for key in sorted(names): if key not in declared: raise ValueError("The option {} does not exist.".format(key))
[docs] def validate_option_names(self, options_paths: list): """ Check that ini files specified by the list of ``options_paths`` contain the option names from this options object at least once. Note that this method checks not if there are option names in files that are not in the object. After option names are validated, initialization is flagged as complete and `self.is_initialized` is set to `True` to prevent further modification of options. Parameters ---------- options_paths : list of str A list of paths to ini files can contain the allowed option names. Raises ------ ValueError Raised when an option exists in this object but not in one of the specified ini files. """ # create set of option names from all ini files file_option_names = declared_option_names(options_paths) for key in self.keys(): if key != "useroptions" and key not in file_option_names: raise ValueError("The option {} does not exist.".format(key)) self._warn_inert_options(options_paths) self._warn_ignored_noise_size() # After initialzation is complete prevent changes to options: self.is_initialized = True
def _warn_inert_options(self, options_paths: list, names=None): """ Warn about the options of :data:`INERT_OPTIONS` that the user set to a value other than the default declared in the ini files. An option whose declared default is a callable is left alone: two functions cannot be told apart by value, and repeating such a default (as an option dictionary recorded by an earlier run does) would otherwise look like a change. Parameters ---------- options_paths : list of str A list of paths to the ini files that declare the defaults. names : iterable of str, optional The names of the options to weigh. Default the options the user set (``useroptions``). """ if names is None: names = self.get("useroptions") supplied = set(names) & INERT_OPTIONS if len(supplied) == 0: return default_values = {} for options_path in options_paths: for key, value, __ in _read_config_file(options_path): if key in supplied: default_values[key] = value for key in sorted(supplied): if key not in default_values: continue try: default = eval( default_values[key], globals(), self.evaluation_parameters ) except Exception: continue if callable(default) or _equals_default(self[key], default): continue get_logger("VBMC_init").warning( "The option %s has no effect in PyVBMC: the value %s is " "accepted and ignored.", key, self[key], ) def _warn_ignored_noise_size(self, names=None): """ Warn when the user set ``noise_size`` for a target that returns its own noise estimates. With ``specify_target_noise`` on, the GP takes the noise of each observation from the target and does not read ``noise_size``, as MATLAB VBMC warns (``misc/setupoptions_vbmc.m:139-140``). An empty value states no noise size and is left alone, and so is a value that is not a noise size, which the check of the option values refuses (:func:`_noise_size_reading`). Parameters ---------- names : iterable of str, optional The names of the options to weigh. Default the options the user set (``useroptions``). """ if names is None: names = self.get("useroptions") if "noise_size" not in names: return noise_size = self.get("noise_size") try: if _noise_size_reading(noise_size) is None: return except ValueError: return if _stated_boolean(self.get("specify_target_noise")) is not True: return get_logger("VBMC_init").warning( "The option noise_size has no effect with specify_target_noise, " "because the target returns its own noise estimates: the value " "%s is accepted and ignored.", noise_size, ) def __setitem__(self, key, val, force=False): # Prevent user from attempting to modify options after initialization if ( hasattr(self, "is_initialized") and self.is_initialized and not force ): raise AttributeError( "Warning: Cannot set options after initialization. Please re-initialize with `options = {...}`" ) else: dict.__setitem__(self, key, val) def __getitem__(self, key): return dict.__getitem__(self, key) def __iter__(self): yield from sorted(dict.__iter__(self)) def __len__(self): return dict.__len__(self) def __delitem__(self, key, force=False): # Options cannot be removed after initialization, as they cannot be # set; ``pop``, ``popitem`` and ``clear`` all come through here. if ( hasattr(self, "is_initialized") and self.is_initialized and not force ): raise AttributeError( "Warning: Cannot remove options after initialization. Please re-initialize with `options = {...}`" ) else: dict.__delitem__(self, key) def __copy__(self): cls = self.__class__ result = cls.__new__(cls) # Copy class properties: result.__dict__.update(self.__dict__) # Copy options dict: for k, v in dict.items(self): result.__setitem__(k, v, force=True) return result def __deepcopy__(self, memo): cls = self.__class__ result = cls.__new__(cls) # Avoid infinite recursion in deepcopy memo[id(self)] = result # Copy class properties: for k, v in self.__dict__.items(): setattr(result, k, copy.deepcopy(v, memo)) # Copy options dict: for k, v in dict.items(self): result.__setitem__(k, copy.deepcopy(v, memo), force=True) return result
[docs] def eval(self, key: str, evaluation_parameters: dict): """ Evaluate an option using `evaluation_parameters` if it is a callable, otherwise return the value of the option. A callable evaluated with a single parameter receives its value by keyword when it takes an argument of that name (a parameter so named that is not positional-only, or ``**kwargs``), and by position otherwise, as MATLAB VBMC's ``misc/evaloption_vbmc.m`` calls ``option(N)``, so the name of a single parameter does not matter. A callable whose signature cannot be read receives it by position. With several parameters it receives them as keyword arguments, and its parameters have to carry their names. Parameters ---------- key : str The name of the option. evaluation_parameters : dict Parameters for the option in case it is a callable, by name. They are ignored if it is not a callable. Returns ------- val : object Value of the object which has been evaluated if it is a callable. """ value = self.get(key) if not callable(value): return value if len(evaluation_parameters) == 1: ((name, parameter),) = evaluation_parameters.items() if _takes_keyword(value, name): return value(**{name: parameter}) return value(parameter) return value(**evaluation_parameters)
def __str__(self): """ Return the user options in a format key: value (description). Returns ------- str The str to describe an options object. """ user_options = "\n".join( [ f"{key}: {self[key]} ({self.descriptions.get(key)})" for key in self["useroptions"] ] ) if user_options == "": user_options = ( "None (use default options).\n" + "View current defaults with `options` or `repr(options)`." ) return "User Options:\n" + indent(user_options, " ") def __repr__(self, full=False, expand=False): """ Return the options in a format key: value (description). Returns ------- string : str The str to describe the Options object. full : bool, optional If ``full`` is `False`, print only the relevant object attributes. Otherwise print all attributes. expand : bool, optional If ``expand`` is `False`, then describe any complex child attributes of the object by their name and memory location. Otherwise, recursively expand the child attributes into their own representations. Default `False`. """ if full: # Output every class attribute (for debugging) return full_repr(self, "Options", expand=expand) else: # Output relevant class attributes in meaningful format return "Options:\n" + indent( "\n".join( [ f"{key}: {value} ({self.descriptions.get(key)})" for (key, value) in self.items() ] ), " ", )
def _equals_default(value, default): """ Private helper method to compare an option value against its default, for values of any type an ini file can produce. """ if value is default: return True try: return bool( np.array_equal( np.asarray(value, dtype=object), np.asarray(default, dtype=object), ) ) except Exception: return False #: An option line of an ini file, up to the delimiter that ends its name. _OPTION_LINE = re.compile(r"\s*([^#;=:\s][^=:]*?)\s*[=:]") def _read_descriptions(path: Path): """ Private helper method to read the description of each option of an ini file, that is the comment line above it. The description is taken from the raw line, because a config parser would split it at the first ``=`` or ``:`` it contains. """ descriptions = {} description = "" with open(path, encoding="utf-8") as config_file: for line in config_file: stripped = line.strip() if stripped == "" or stripped.startswith("["): continue if stripped.startswith("#") or stripped.startswith(";"): description = stripped.lstrip("#;").strip() continue match = _OPTION_LINE.match(line) if match is not None: descriptions[match.group(1)] = description description = "" return descriptions def _read_config_file(options_path: str): """ Private helper method to read a config file and return the options as a list of tuples (key, value, description). Note that strings starting with # in the .ini file act as description to the option in the following line. """ path = Path(options_path) if not path.is_absolute(): path = Path(__file__).parent.joinpath(path) if not path.exists(): raise ValueError(f"{path.resolve()} does not exist.") conf = configparser.ConfigParser(allow_no_value=True) # do not lower() both values as well as descriptions conf.optionxform = str conf.read(path) descriptions = _read_descriptions(path) option_list = [] for section in conf.sections(): for key, value in conf.items(section): option_list.append([key, value, descriptions.get(key, "")]) if len(option_list) == 0: raise ValueError( "The option file at {} does not contain options.".format( options_path ) ) return np.array(option_list)