Source code for picos.valuable

# ------------------------------------------------------------------------------
# Copyright (C) 2021 Maximilian Stahlberg
#
# This file is part of PICOS.
#
# PICOS is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# PICOS is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program.  If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------

"""Common interface for objects that can have a numeric value."""

from abc import ABC, abstractmethod

import cvxopt
import numpy

from .apidoc import api_end, api_start

_API_START = api_start(globals())
# -------------------------------


[docs]class NotValued(RuntimeError): """The operation cannot be performed due to a mutable without a value. Note that the :attr:`~Valuable.value`, :attr:`~Valuable.value_as_matrix`, :attr:`~Valuable.np`, and :attr:`~Valuable.np2d` attributes do not raise this exception, but return :obj:`None` instead. """ pass
[docs]class Valuable(ABC): """Abstract base class for objects that can have a numeric value. This is used by all algebraic expressions through their :class:`~picos.expressions.expression.Expression` base class as well as by :class:`~picos.modeling.objective.Objective` and, referencing the latter, by :class:`~picos.modeling.problem.Problem` instances. """ # -------------------------------------------------------------------------- # Abstract and default-implementation methods. # -------------------------------------------------------------------------- @abstractmethod def _get_valuable_string(self): """Return a short string defining the valuable object.""" pass @abstractmethod def _get_value(self): """Return the numeric value of the object as a CVXOPT matrix. :raises NotValued: When the value is not fully defined. Method implementations need to return an independent copy of the value that the user is allowed to change without affecting the object. """ pass def _set_value(self, value): raise NotImplementedError("Setting the value on an instance of {} is " "not supported, but you can value any mutables involved instead." .format(type(self).__name__)) # -------------------------------------------------------------------------- # Provided interface. # -------------------------------------------------------------------------- def _wrap_get_value(self, asMatrix, staySafe): """Enhance the implementation of :attr:`_get_value`. Checks the type of any value returned and offers conversion options. :param bool asMatrix: Whether scalar values are returned as matrices. :param bool staySafe: Whether :exc:`NotValued` exceptions are raised. Otherwise missing values are returned as :obj:`None`. """ try: value = self._get_value() except NotValued: if staySafe: raise else: return None assert isinstance(value, (cvxopt.matrix, cvxopt.spmatrix)), \ "Expression._get_value implementations must return a CVXOPT matrix." if value.size == (1, 1) and not asMatrix: return value[0] else: return value value = property( lambda self: self._wrap_get_value(asMatrix=False, staySafe=False), lambda self, x: self._set_value(x), lambda self: self._set_value(None), r"""Value of the object, or :obj:`None`. For an expression, it is defined if the expression is constant or if all mutables involved in the expression are valued. Mutables can be valued directly by writing to their :attr:`value` attribute. Variables are also valued by PICOS when an optimization solution is found. Some expressions can also be valued directly if PICOS can find a minimal norm mutable assignment that makes the expression have the desired value. In particular, this works with affine expressions whose linear part has an under- or well-determined coefficient matrix. If you prefer the value as a NumPy, use :attr:`np` instead. :returns: The value as a Python scalar or CVXOPT matrix, or :obj:`None` if it is not defined. :Distinction: - Unlike :attr:`safe_value` and :attr:`safe_value_as_matrix`, an undefined value is returned as :obj:`None`. - Unlike :attr:`value_as_matrix` and :attr:`safe_value_as_matrix`, scalars are returned as scalar types. - For uncertain expressions, see also :meth:`~.uexpression.UncertainExpression.worst_case_value`. :Example: >>> from picos import RealVariable >>> x = RealVariable("x", (1,3)) >>> y = RealVariable("y", (1,3)) >>> e = x - 2*y + 3 >>> print("e:", e) e: x - 2·y + [3] >>> e.value = [4, 5, 6] >>> print("e: ", e, "\nx: ", x, "\ny: ", y, sep = "") e: [ 4.00e+00 5.00e+00 6.00e+00] x: [ 2.00e-01 4.00e-01 6.00e-01] y: [-4.00e-01 -8.00e-01 -1.20e+00] """) safe_value = property( lambda self: self._wrap_get_value(asMatrix=False, staySafe=True), lambda self, x: self._set_value(x), lambda self: self._set_value(None), """Value of the object, if defined. Refer to :attr:`value` for when it is defined. :returns: The value as a Python scalar or CVXOPT matrix. :raises ~picos.NotValued: If the value is not defined. :Distinction: - Unlike :attr:`value`, an undefined value raises an exception. - Like :attr:`value`, scalars are returned as scalar types. """) value_as_matrix = property( lambda self: self._wrap_get_value(asMatrix=True, staySafe=False), lambda self, x: self._set_value(x), lambda self: self._set_value(None), r"""Value of the object as a CVXOPT matrix type, or :obj:`None`. Refer to :attr:`value` for when it is defined (not :obj:`None`). :returns: The value as a CVXOPT matrix, or :obj:`None` if it is not defined. :Distinction: - Like :attr:`value`, an undefined value is returned as :obj:`None`. - Unlike :attr:`value`, scalars are returned as :math:`1 \times 1` matrices. """) safe_value_as_matrix = property( lambda self: self._wrap_get_value(asMatrix=True, staySafe=True), lambda self, x: self._set_value(x), lambda self: self._set_value(None), r"""Value of the object as a CVXOPT matrix type, if defined. Refer to :attr:`value` for when it is defined. :returns: The value as a CVXOPT matrix. :raises ~picos.NotValued: If the value is not defined. :Distinction: - Unlike :attr:`value`, an undefined value raises an exception. - Unlike :attr:`value`, scalars are returned as :math:`1 \times 1` matrices. """) @property def np2d(self): """Value of the object as a 2D NumPy array, or :obj:`None`. Refer to :attr:`value` for when it is defined (not :obj:`None`). :returns: The value as a two-dimensional :class:`numpy.ndarray`, or :obj:`None`, if the value is not defined. :Distinction: - Like :attr:`np`, values are returned as NumPy types or :obj:`None`. - Unlike :attr:`np`, both scalar and vectorial values are returned as two-dimensional arrays. In particular, row and column vectors are distinguished. """ value = self.value_as_matrix if value is None: return None # Convert CVXOPT sparse to CVXOPT dense. if isinstance(value, cvxopt.spmatrix): value = cvxopt.matrix(value) assert isinstance(value, cvxopt.matrix) # Convert CVXOPT dense to a NumPy 2D array. value = numpy.array(value) assert len(value.shape) == 2 return value @np2d.setter def np2d(self, value): self._set_value(value) @np2d.deleter def np2d(self): self._set_value(None) @property def np(self): """Value of the object as a NumPy type, or :obj:`None`. Refer to :attr:`value` for when it is defined (not :obj:`None`). :returns: A one- or two-dimensional :class:`numpy.ndarray`, if the value is a vector or a matrix, respectively, or a NumPy scalar type such as :obj:`numpy.float64`, if the value is a scalar, or :obj:`None`, if the value is not defined. :Distinction: - Like :attr:`value` and :attr:`np2d`, an undefined value is returned as :obj:`None`. - Unlike :attr:`value`, scalars are returned as NumPy scalar types as opposed to Python builtin scalar types while vectors and matrices are returned as NumPy arrays as opposed to CVXOPT matrices. - Unlike :attr:`np2d`, scalars are returned as NumPy scalar types and vectors are returned as NumPy one-dimensional arrays as opposed to always returning two-dimensional arrays. :Example: >>> from picos import ComplexVariable >>> Z = ComplexVariable("Z", (3, 3)) >>> Z.value = [i + i*1j for i in range(9)] Proper matrices are return as 2D arrays: >>> Z.value # CVXOPT matrix. <3x3 matrix, tc='z'> >>> Z.np # NumPy 2D array. array([[0.+0.j, 3.+3.j, 6.+6.j], [1.+1.j, 4.+4.j, 7.+7.j], [2.+2.j, 5.+5.j, 8.+8.j]]) Both row and column vectors are returned as 1D arrays: >>> z = Z[:,0] # First column of Z. >>> z.value.size # CVXOPT column vector. (3, 1) >>> z.T.value.size # CVXOPT row vector. (1, 3) >>> z.value == z.T.value False >>> z.np.shape # NumPy 1D array. (3,) >>> z.T.np.shape # Same array. (3,) >>> from numpy import array_equal >>> array_equal(z.np, z.T.np) True Scalars are returned as NumPy types: >>> u = Z[0,0] # First element of Z. >>> type(u.value) # Python scalar. <class 'complex'> >>> type(u.np) # NumPy scalar. #doctest: +SKIP <class 'numpy.complex128'> Undefined values are returned as None: >>> del Z.value >>> Z.value is Z.np is None True """ value = self.np2d if value is None: return None elif value.shape == (1, 1): return value[0, 0] elif 1 in value.shape: return numpy.ravel(value) else: return value @np.setter def np(self, value): self._set_value(value) @np.deleter def np(self): self._set_value(None) @property def sp(self): """Value as a ScipPy sparse matrix or a NumPy 2D array or :obj:`None`. If PICOS stores the value internally as a CVXOPT sparse matrix, or equivalently if :attr:`value_as_matrix` returns an instance of :func:`cvxopt.spmatrix`, then this returns the value as a :class:`SciPy sparse matrix in CSC format <scipy.sparse.csc_matrix>`. Otherwise, this property is equivalent to :attr:`np2d` and returns a two-dimensional NumPy array, or :obj:`None`, if the value is undefined. :Example: >>> import picos, cvxopt >>> X = picos.RealVariable("X", (3, 3)) >>> X.value = cvxopt.spdiag([1, 2, 3]) # Stored as a sparse matrix. >>> type(X.value) <class 'cvxopt.base.spmatrix'> >>> type(X.sp) <class 'scipy.sparse._csc.csc_matrix'> >>> X.value = range(9) # Stored as a dense matrix. >>> type(X.value) <class 'cvxopt.base.matrix'> >>> type(X.sp) <class 'numpy.ndarray'> """ import scipy.sparse value = self.value_as_matrix if value is None: return None elif isinstance(value, cvxopt.spmatrix): return scipy.sparse.csc_matrix( tuple(list(x) for x in reversed(value.CCS)), value.size) else: return numpy.array(value) @property def valued(self): """Whether the object is valued. .. note:: Querying this attribute is *not* faster than immediately querying :attr:`value` and checking whether it is :obj:`None`. Use it only if you do not need to know the value, but only whether it is available. :Example: >>> from picos import RealVariable >>> x = RealVariable("x", 3) >>> x.valued False >>> x.value >>> print((x|1)) ∑(x) >>> x.value = [1, 2, 3] >>> (x|1).valued True >>> print((x|1)) 6.0 """ try: self._get_value() except NotValued: return False else: return True @valued.setter def valued(self, x): if x is False: self._set_value(None) else: raise ValueError("You may only assign 'False' to the 'valued' " "attribute, which is the same as setting 'value' to 'None'.")
[docs] def __index__(self): """Propose the value as an index.""" value = self.value_as_matrix if value is None: raise NotValued("Cannot use unvalued {} as an index." .format(self._get_valuable_string())) if value.size != (1, 1): raise TypeError("Cannot use multidimensional {} as an index." .format(self._get_valuable_string())) value = value[0] if value.imag: raise ValueError( "Cannot use {} as an index as its value of {} has a nonzero " "imaginary part.".format(self._get_valuable_string(), value)) value = value.real if not value.is_integer(): raise ValueError("Cannot use {} as an index as its value of {} is " "not integral.".format(self._get_valuable_string(), value)) return int(value)
def _casting_helper(self, theType): assert theType in (int, float, complex) value = self.value_as_matrix if value is None: raise NotValued("Cannot cast unvalued {} as {}." .format(self._get_valuable_string(), theType.__name__)) if value.size != (1, 1): raise TypeError( "Cannot cast multidimensional {} as {}." .format(self._get_valuable_string(), theType.__name__)) value = value[0] return theType(value)
[docs] def __int__(self): """Cast the value to an :class:`int`.""" return self._casting_helper(int)
[docs] def __float__(self): """Cast the value to a :class:`float`.""" return self._casting_helper(float)
[docs] def __complex__(self): """Cast the value to a :class:`complex`.""" return self._casting_helper(complex)
[docs] def __round__(self, ndigits=None): """Round the value to a certain precision.""" return round(float(self), ndigits)
[docs] def __array__(self, dtype=None): """Return the value as a :class:`NumPy array <numpy.ndarray>`.""" value = self.safe_value_as_matrix # Convert CVXOPT sparse to CVXOPT dense. if isinstance(value, cvxopt.spmatrix): value = cvxopt.matrix(value) assert isinstance(value, cvxopt.matrix) # Convert CVXOPT dense to a NumPy 2D array. value = numpy.array(value, dtype) assert len(value.shape) == 2 # Remove dimensions of size one. if value.shape == (1, 1): return numpy.reshape(value, ()) elif 1 in value.shape: return numpy.ravel(value) else: return value
# Prevent NumPy operators from loading PICOS expressions as arrays. __array_priority__ = float("inf") __array_ufunc__ = None
[docs]def patch_scipy_array_priority(): """Monkey-patch scipy.sparse to make it respect ``__array_priority__``. This works around https://github.com/scipy/scipy/issues/4819 and is inspired by CVXPY's scipy_wrapper.py. """ import scipy.sparse def teach_array_priority(operator): def respect_array_priority(self, other): if hasattr(other, "__array_priority__") \ and self.__array_priority__ < other.__array_priority__: return NotImplemented else: return operator(self, other) return respect_array_priority base_type = scipy.sparse.spmatrix matrix_types = (type_ for type_ in scipy.sparse.__dict__.values() if isinstance(type_, type) and issubclass(type_, base_type)) for matrix_type in matrix_types: for operator_name in ( "__add__", "__div__", "__eq__", "__ge__", "__gt__", "__le__", "__lt__", "__matmul__", "__mul__", "__ne__", "__pow__", "__sub__", "__truediv__", ): operator = getattr(matrix_type, operator_name) # Wrap all binary operators of the base class and all overrides. if matrix_type is base_type \ or operator is not getattr(base_type, operator_name): wrapped_operator = teach_array_priority(operator) setattr(matrix_type, operator_name, wrapped_operator)
# -------------------------------------- __all__ = api_end(_API_START, globals())