Files
ortools-clone/ortools/math_opt/python/variables.py

1408 lines
49 KiB
Python
Raw Normal View History

2025-08-22 14:24:22 +02:00
#!/usr/bin/env python3
# Copyright 2010-2025 Google LLC
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Define Variables and Linear Expressions."""
import abc
import collections
import dataclasses
import math
import typing
from typing import (
Any,
DefaultDict,
Deque,
Generic,
Iterable,
Mapping,
NoReturn,
Optional,
Protocol,
Tuple,
Type,
TypeVar,
Union,
)
import immutabledict
from ortools.math_opt.elemental.python import enums
from ortools.math_opt.python import bounded_expressions
from ortools.math_opt.python import from_model
from ortools.math_opt.python.elemental import elemental
LinearTypes = Union[int, float, "LinearBase"]
QuadraticTypes = Union[int, float, "LinearBase", "QuadraticBase"]
LinearTypesExceptVariable = Union[
float, int, "LinearTerm", "LinearExpression", "LinearSum", "LinearProduct"
]
_EXPRESSION_COMP_EXPRESSION_MESSAGE = (
"This error can occur when adding "
"inequalities of the form `(a <= b) <= "
"c` where (a, b, c) includes two or more"
" non-constant linear expressions"
)
def _raise_binary_operator_type_error(
operator: str,
lhs: Type[Any],
rhs: Type[Any],
extra_message: Optional[str] = None,
) -> NoReturn:
"""Raises TypeError on unsupported operators."""
message = (
f"unsupported operand type(s) for {operator}: {lhs.__name__!r} and"
f" {rhs.__name__!r}"
)
if extra_message is not None:
message += "\n" + extra_message
raise TypeError(message)
def _raise_ne_not_supported() -> NoReturn:
raise TypeError("!= constraints are not supported")
LowerBoundedLinearExpression = bounded_expressions.LowerBoundedExpression["LinearBase"]
UpperBoundedLinearExpression = bounded_expressions.UpperBoundedExpression["LinearBase"]
BoundedLinearExpression = bounded_expressions.BoundedExpression["LinearBase"]
class VarEqVar:
"""The result of the equality comparison between two Variable.
We use an object here to delay the evaluation of equality so that we can use
the operator== in two use-cases:
1. when the user want to test that two Variable values references the same
variable. This is supported by having this object support implicit
conversion to bool.
2. when the user want to use the equality to create a constraint of equality
between two variables.
"""
__slots__ = "_first_variable", "_second_variable"
def __init__(
self,
first_variable: "Variable",
second_variable: "Variable",
) -> None:
self._first_variable: "Variable" = first_variable
self._second_variable: "Variable" = second_variable
@property
def first_variable(self) -> "Variable":
return self._first_variable
@property
def second_variable(self) -> "Variable":
return self._second_variable
def __bool__(self) -> bool:
return (
self._first_variable.elemental is self._second_variable.elemental
and self._first_variable.id == self._second_variable.id
)
def __str__(self):
return f"{self.first_variable!s} == {self._second_variable!s}"
def __repr__(self):
return f"{self.first_variable!r} == {self._second_variable!r}"
BoundedLinearTypesList = (
LowerBoundedLinearExpression,
UpperBoundedLinearExpression,
BoundedLinearExpression,
VarEqVar,
)
BoundedLinearTypes = Union[BoundedLinearTypesList]
LowerBoundedQuadraticExpression = bounded_expressions.LowerBoundedExpression[
"QuadraticBase"
]
UpperBoundedQuadraticExpression = bounded_expressions.UpperBoundedExpression[
"QuadraticBase"
]
BoundedQuadraticExpression = bounded_expressions.BoundedExpression["QuadraticBase"]
BoundedQuadraticTypesList = (
LowerBoundedQuadraticExpression,
UpperBoundedQuadraticExpression,
BoundedQuadraticExpression,
)
BoundedQuadraticTypes = Union[BoundedQuadraticTypesList]
# TODO(b/231426528): consider using a frozen dataclass.
class QuadraticTermKey:
"""An id-ordered pair of variables used as a key for quadratic terms."""
__slots__ = "_first_var", "_second_var"
def __init__(self, a: "Variable", b: "Variable"):
"""Variables a and b will be ordered internally by their ids."""
self._first_var: "Variable" = a
self._second_var: "Variable" = b
if self._first_var.id > self._second_var.id:
self._first_var, self._second_var = self._second_var, self._first_var
@property
def first_var(self) -> "Variable":
return self._first_var
@property
def second_var(self) -> "Variable":
return self._second_var
def __eq__(self, other: "QuadraticTermKey") -> bool:
return bool(
self._first_var == other._first_var
and self._second_var == other._second_var
)
def __hash__(self) -> int:
return hash((self._first_var, self._second_var))
def __str__(self):
return f"{self._first_var!s} * {self._second_var!s}"
def __repr__(self):
return f"QuadraticTermKey({self._first_var!r}, {self._second_var!r})"
@dataclasses.dataclass
class _ProcessedElements:
"""Auxiliary data class for LinearBase._flatten_once_and_add_to()."""
terms: DefaultDict["Variable", float] = dataclasses.field(
default_factory=lambda: collections.defaultdict(float)
)
offset: float = 0.0
@dataclasses.dataclass
class _QuadraticProcessedElements(_ProcessedElements):
"""Auxiliary data class for QuadraticBase._quadratic_flatten_once_and_add_to()."""
quadratic_terms: DefaultDict["QuadraticTermKey", float] = dataclasses.field(
default_factory=lambda: collections.defaultdict(float)
)
class _ToProcessElements(Protocol):
"""Auxiliary to-process stack interface for LinearBase._flatten_once_and_add_to() and QuadraticBase._quadratic_flatten_once_and_add_to()."""
__slots__ = ()
def append(self, term: "LinearBase", scale: float) -> None:
"""Add a linear object and scale to the to-process stack."""
_T = TypeVar("_T", "LinearBase", Union["LinearBase", "QuadraticBase"])
class _ToProcessElementsImplementation(Generic[_T]):
"""Auxiliary data class for LinearBase._flatten_once_and_add_to()."""
__slots__ = ("_queue",)
def __init__(self, term: _T, scale: float) -> None:
self._queue: Deque[Tuple[_T, float]] = collections.deque([(term, scale)])
def append(self, term: _T, scale: float) -> None:
self._queue.append((term, scale))
def pop(self) -> Tuple[_T, float]:
return self._queue.popleft()
def __bool__(self) -> bool:
return bool(self._queue)
_LinearToProcessElements = _ToProcessElementsImplementation["LinearBase"]
_QuadraticToProcessElements = _ToProcessElementsImplementation[
Union["LinearBase", "QuadraticBase"]
]
class LinearBase(metaclass=abc.ABCMeta):
"""Interface for types that can build linear expressions with +, -, * and /.
Classes derived from LinearBase (plus float and int scalars) are used to
build expression trees describing a linear expression. Operations nodes of the
expression tree include:
* LinearSum: describes a deferred sum of LinearTypes objects.
* LinearProduct: describes a deferred product of a scalar and a
LinearTypes object.
Leaf nodes of the expression tree include:
* float and int scalars.
* Variable: a single variable.
* LinearTerm: the product of a scalar and a Variable object.
* LinearExpression: the sum of a scalar and LinearTerm objects.
LinearBase objects/expression-trees can be used directly to create
constraints or objective functions. However, to facilitate their inspection,
any LinearTypes object can be flattened to a LinearExpression
through:
as_flat_linear_expression(value: LinearTypes) -> LinearExpression:
In addition, all LinearBase objects are immutable.
Performance notes:
Using an expression tree representation instead of an eager construction of
LinearExpression objects reduces known inefficiencies associated with the
use of operator overloading to construct linear expressions. In particular, we
expect the runtime of as_flat_linear_expression() to be linear in the size of
the expression tree. Additional performance can gained by using LinearSum(c)
instead of sum(c) for a container c, as the latter creates len(c) LinearSum
objects.
"""
__slots__ = ()
# TODO(b/216492143): explore requirements for this function so calculation of
# coefficients and offsets follow expected associativity rules (so approximate
# float calculations are as expected).
# TODO(b/216492143): add more details of what subclasses need to do in
# developers guide.
@abc.abstractmethod
def _flatten_once_and_add_to(
self,
scale: float,
processed_elements: _ProcessedElements,
target_stack: _ToProcessElements,
) -> None:
"""Flatten one level of tree if needed and add to targets.
Classes derived from LinearBase only need to implement this function
to enable transformation to LinearExpression through
as_flat_linear_expression().
Args:
scale: multiply elements by this number when processing or adding to
stack.
processed_elements: where to add LinearTerms and scalars that can be
processed immediately.
target_stack: where to add LinearBase elements that are not scalars or
LinearTerms (i.e. elements that need further flattening).
Implementations should append() to this stack to avoid being recursive.
"""
def __eq__(
self, rhs: LinearTypes
2025-08-22 14:24:22 +02:00
) -> BoundedLinearExpression: # overriding-return-type-checks
# Note: when rhs is a QuadraticBase, this will cause rhs.__eq__(self) to be
# invoked, which is defined.
if isinstance(rhs, QuadraticBase):
return NotImplemented
if isinstance(rhs, (int, float)):
return BoundedLinearExpression(rhs, self, rhs)
if not isinstance(rhs, LinearBase):
_raise_binary_operator_type_error("==", type(self), type(rhs))
return BoundedLinearExpression(0.0, self - rhs, 0.0)
2025-08-22 14:24:22 +02:00
def __ne__(self, rhs: LinearTypes) -> NoReturn: # overriding-return-type-checks
_raise_ne_not_supported()
@typing.overload
def __le__(self, rhs: float) -> "UpperBoundedLinearExpression": ...
@typing.overload
def __le__(self, rhs: "LinearBase") -> "BoundedLinearExpression": ...
@typing.overload
def __le__(self, rhs: "BoundedLinearExpression") -> NoReturn: ...
def __le__(self, rhs):
# Note: when rhs is a QuadraticBase, this will cause rhs.__ge__(self) to be
# invoked, which is defined.
if isinstance(rhs, QuadraticBase):
return NotImplemented
if isinstance(rhs, (int, float)):
return UpperBoundedLinearExpression(self, rhs)
if isinstance(rhs, LinearBase):
return BoundedLinearExpression(-math.inf, self - rhs, 0.0)
if isinstance(rhs, bounded_expressions.BoundedExpression):
_raise_binary_operator_type_error(
"<=", type(self), type(rhs), _EXPRESSION_COMP_EXPRESSION_MESSAGE
)
_raise_binary_operator_type_error("<=", type(self), type(rhs))
@typing.overload
def __ge__(self, lhs: float) -> "LowerBoundedLinearExpression": ...
@typing.overload
def __ge__(self, lhs: "LinearBase") -> "BoundedLinearExpression": ...
@typing.overload
def __ge__(self, lhs: "BoundedLinearExpression") -> NoReturn: ...
def __ge__(self, lhs):
# Note: when lhs is a QuadraticBase, this will cause lhs.__le__(self) to be
# invoked, which is defined.
if isinstance(lhs, QuadraticBase):
return NotImplemented
if isinstance(lhs, (int, float)):
return LowerBoundedLinearExpression(self, lhs)
if isinstance(lhs, LinearBase):
return BoundedLinearExpression(0.0, self - lhs, math.inf)
if isinstance(lhs, bounded_expressions.BoundedExpression):
_raise_binary_operator_type_error(
">=", type(self), type(lhs), _EXPRESSION_COMP_EXPRESSION_MESSAGE
)
_raise_binary_operator_type_error(">=", type(self), type(lhs))
def __add__(self, expr: LinearTypes) -> "LinearSum":
if not isinstance(expr, (int, float, LinearBase)):
return NotImplemented
return LinearSum((self, expr))
def __radd__(self, expr: LinearTypes) -> "LinearSum":
if not isinstance(expr, (int, float, LinearBase)):
return NotImplemented
return LinearSum((expr, self))
def __sub__(self, expr: LinearTypes) -> "LinearSum":
if not isinstance(expr, (int, float, LinearBase)):
return NotImplemented
return LinearSum((self, -expr))
def __rsub__(self, expr: LinearTypes) -> "LinearSum":
if not isinstance(expr, (int, float, LinearBase, QuadraticBase)):
return NotImplemented
return LinearSum((expr, -self))
@typing.overload
def __mul__(self, other: float) -> "LinearProduct": ...
@typing.overload
def __mul__(self, other: "LinearBase") -> "LinearLinearProduct": ...
def __mul__(self, other):
if not isinstance(other, (int, float, LinearBase)):
return NotImplemented
if isinstance(other, LinearBase):
return LinearLinearProduct(self, other)
return LinearProduct(other, self)
def __rmul__(self, constant: float) -> "LinearProduct":
if not isinstance(constant, (int, float)):
return NotImplemented
return LinearProduct(constant, self)
# TODO(b/216492143): explore numerical consequences of 1.0 / constant below.
def __truediv__(self, constant: float) -> "LinearProduct":
if not isinstance(constant, (int, float)):
return NotImplemented
return LinearProduct(1.0 / constant, self)
def __neg__(self) -> "LinearProduct":
return LinearProduct(-1.0, self)
class QuadraticBase(metaclass=abc.ABCMeta):
"""Interface for types that can build quadratic expressions with +, -, * and /.
Classes derived from QuadraticBase and LinearBase (plus float and int scalars)
are used to build expression trees describing a quadratic expression.
Operations nodes of the expression tree include:
* QuadraticSum: describes a deferred sum of QuadraticTypes objects.
* QuadraticProduct: describes a deferred product of a scalar and a
QuadraticTypes object.
* LinearLinearProduct: describes a deferred product of two LinearTypes
objects.
Leaf nodes of the expression tree include:
* float and int scalars.
* Variable: a single variable.
* LinearTerm: the product of a scalar and a Variable object.
* LinearExpression: the sum of a scalar and LinearTerm objects.
* QuadraticTerm: the product of a scalar and two Variable objects.
* QuadraticExpression: the sum of a scalar, LinearTerm objects and
QuadraticTerm objects.
QuadraticBase objects/expression-trees can be used directly to create
objective functions. However, to facilitate their inspection, any
QuadraticTypes object can be flattened to a QuadraticExpression
through:
as_flat_quadratic_expression(value: QuadraticTypes) -> QuadraticExpression:
In addition, all QuadraticBase objects are immutable.
Performance notes:
Using an expression tree representation instead of an eager construction of
QuadraticExpression objects reduces known inefficiencies associated with the
use of operator overloading to construct quadratic expressions. In particular,
we expect the runtime of as_flat_quadratic_expression() to be linear in the
size of the expression tree. Additional performance can gained by using
QuadraticSum(c) instead of sum(c) for a container c, as the latter creates
len(c) QuadraticSum objects.
"""
__slots__ = ()
# TODO(b/216492143): explore requirements for this function so calculation of
# coefficients and offsets follow expected associativity rules (so approximate
# float calculations are as expected).
# TODO(b/216492143): add more details of what subclasses need to do in
# developers guide.
@abc.abstractmethod
def _quadratic_flatten_once_and_add_to(
self,
scale: float,
processed_elements: _QuadraticProcessedElements,
target_stack: _QuadraticToProcessElements,
) -> None:
"""Flatten one level of tree if needed and add to targets.
Classes derived from QuadraticBase only need to implement this function
to enable transformation to QuadraticExpression through
as_flat_quadratic_expression().
Args:
scale: multiply elements by this number when processing or adding to
stack.
processed_elements: where to add linear terms, quadratic terms and scalars
that can be processed immediately.
target_stack: where to add LinearBase and QuadraticBase elements that are
not scalars or linear terms or quadratic terms (i.e. elements that need
further flattening). Implementations should append() to this stack to
avoid being recursive.
"""
def __eq__(
self, rhs: QuadraticTypes
2025-08-22 14:24:22 +02:00
) -> BoundedQuadraticExpression: # overriding-return-type-checks
if isinstance(rhs, (int, float)):
return BoundedQuadraticExpression(rhs, self, rhs)
if not isinstance(rhs, (LinearBase, QuadraticBase)):
_raise_binary_operator_type_error("==", type(self), type(rhs))
return BoundedQuadraticExpression(0.0, self - rhs, 0.0)
2025-08-22 14:24:22 +02:00
def __ne__(self, rhs: QuadraticTypes) -> NoReturn: # overriding-return-type-checks
_raise_ne_not_supported()
@typing.overload
def __le__(self, rhs: float) -> UpperBoundedQuadraticExpression: ...
@typing.overload
def __le__(
self, rhs: Union[LinearBase, "QuadraticBase"]
) -> BoundedQuadraticExpression: ...
@typing.overload
def __le__(self, rhs: BoundedQuadraticExpression) -> NoReturn: ...
def __le__(self, rhs):
if isinstance(rhs, (int, float)):
return UpperBoundedQuadraticExpression(self, rhs)
if isinstance(rhs, (LinearBase, QuadraticBase)):
return BoundedQuadraticExpression(-math.inf, self - rhs, 0.0)
if isinstance(rhs, bounded_expressions.BoundedExpression):
_raise_binary_operator_type_error(
"<=", type(self), type(rhs), _EXPRESSION_COMP_EXPRESSION_MESSAGE
)
_raise_binary_operator_type_error("<=", type(self), type(rhs))
@typing.overload
def __ge__(self, lhs: float) -> LowerBoundedQuadraticExpression: ...
@typing.overload
def __ge__(
self, lhs: Union[LinearBase, "QuadraticBase"]
) -> BoundedQuadraticExpression: ...
@typing.overload
def __ge__(self, lhs: BoundedQuadraticExpression) -> NoReturn: ...
def __ge__(self, lhs):
if isinstance(lhs, (int, float)):
return LowerBoundedQuadraticExpression(self, lhs)
if isinstance(lhs, (LinearBase, QuadraticBase)):
return BoundedQuadraticExpression(0.0, self - lhs, math.inf)
if isinstance(lhs, bounded_expressions.BoundedExpression):
_raise_binary_operator_type_error(
">=", type(self), type(lhs), _EXPRESSION_COMP_EXPRESSION_MESSAGE
)
_raise_binary_operator_type_error(">=", type(self), type(lhs))
def __add__(self, expr: QuadraticTypes) -> "QuadraticSum":
if not isinstance(expr, (int, float, LinearBase, QuadraticBase)):
return NotImplemented
return QuadraticSum((self, expr))
def __radd__(self, expr: QuadraticTypes) -> "QuadraticSum":
if not isinstance(expr, (int, float, LinearBase, QuadraticBase)):
return NotImplemented
return QuadraticSum((expr, self))
def __sub__(self, expr: QuadraticTypes) -> "QuadraticSum":
if not isinstance(expr, (int, float, LinearBase, QuadraticBase)):
return NotImplemented
return QuadraticSum((self, -expr))
def __rsub__(self, expr: QuadraticTypes) -> "QuadraticSum":
if not isinstance(expr, (int, float, LinearBase, QuadraticBase)):
return NotImplemented
return QuadraticSum((expr, -self))
def __mul__(self, other: float) -> "QuadraticProduct":
if not isinstance(other, (int, float)):
return NotImplemented
return QuadraticProduct(other, self)
def __rmul__(self, other: float) -> "QuadraticProduct":
if not isinstance(other, (int, float)):
return NotImplemented
return QuadraticProduct(other, self)
# TODO(b/216492143): explore numerical consequences of 1.0 / constant below.
def __truediv__(self, constant: float) -> "QuadraticProduct":
if not isinstance(constant, (int, float)):
return NotImplemented
return QuadraticProduct(1.0 / constant, self)
def __neg__(self) -> "QuadraticProduct":
return QuadraticProduct(-1.0, self)
class Variable(LinearBase, from_model.FromModel):
"""A decision variable for an optimization model.
A decision variable takes a value from a domain, either the real numbers or
the integers, and restricted to be in some interval [lb, ub] (where lb and ub
can be infinite). The case of lb == ub is allowed, this means the variable
must take a single value. The case of lb > ub is also allowed, this implies
that the problem is infeasible.
A Variable is configured as follows:
* lower_bound: a float property, lb above. Should not be NaN nor +inf.
* upper_bound: a float property, ub above. Should not be NaN nor -inf.
* integer: a bool property, if the domain is integer or continuous.
The name is optional, read only, and used only for debugging. Non-empty names
should be distinct.
Every Variable is associated with a Model (defined below). Note that data
describing the variable (e.g. lower_bound) is owned by Model.storage, this
class is simply a reference to that data. Do not create a Variable directly,
use Model.add_variable() instead.
"""
__slots__ = "_elemental", "_id"
def __init__(self, elem: elemental.Elemental, vid: int) -> None:
"""Internal only, prefer Model functions (add_variable() and get_variable())."""
if not isinstance(vid, int):
raise TypeError(f"vid type should be int, was:{type(vid)}")
self._elemental: elemental.Elemental = elem
self._id: int = vid
@property
def lower_bound(self) -> float:
return self._elemental.get_attr(
enums.DoubleAttr1.VARIABLE_LOWER_BOUND, (self._id,)
)
@lower_bound.setter
def lower_bound(self, value: float) -> None:
self._elemental.set_attr(
enums.DoubleAttr1.VARIABLE_LOWER_BOUND,
(self._id,),
value,
)
@property
def upper_bound(self) -> float:
return self._elemental.get_attr(
enums.DoubleAttr1.VARIABLE_UPPER_BOUND, (self._id,)
)
@upper_bound.setter
def upper_bound(self, value: float) -> None:
self._elemental.set_attr(
enums.DoubleAttr1.VARIABLE_UPPER_BOUND,
(self._id,),
value,
)
@property
def integer(self) -> bool:
return self._elemental.get_attr(enums.BoolAttr1.VARIABLE_INTEGER, (self._id,))
@integer.setter
def integer(self, value: bool) -> None:
self._elemental.set_attr(enums.BoolAttr1.VARIABLE_INTEGER, (self._id,), value)
@property
def name(self) -> str:
return self._elemental.get_element_name(enums.ElementType.VARIABLE, self._id)
@property
def id(self) -> int:
return self._id
@property
def elemental(self) -> elemental.Elemental:
"""Internal use only."""
return self._elemental
def __str__(self):
"""Returns the name, or a string containing the id if the name is empty."""
return self.name if self.name else f"variable_{self.id}"
def __repr__(self):
return f"<Variable id: {self.id}, name: {self.name!r}>"
@typing.overload
def __eq__(self, rhs: "Variable") -> "VarEqVar": ...
@typing.overload
def __eq__(self, rhs: LinearTypesExceptVariable) -> "BoundedLinearExpression": ...
def __eq__(self, rhs):
if isinstance(rhs, Variable):
return VarEqVar(self, rhs)
return super().__eq__(rhs)
@typing.overload
def __ne__(self, rhs: "Variable") -> bool: ...
@typing.overload
def __ne__(self, rhs: LinearTypesExceptVariable) -> NoReturn: ...
def __ne__(self, rhs):
if isinstance(rhs, Variable):
return not self == rhs
_raise_ne_not_supported()
def __hash__(self) -> int:
return hash(self._id)
@typing.overload
def __mul__(self, other: float) -> "LinearTerm": ...
@typing.overload
def __mul__(self, other: Union["Variable", "LinearTerm"]) -> "QuadraticTerm": ...
@typing.overload
def __mul__(self, other: "LinearBase") -> "LinearLinearProduct": ...
def __mul__(self, other):
if not isinstance(other, (int, float, LinearBase)):
return NotImplemented
if isinstance(other, Variable):
return QuadraticTerm(QuadraticTermKey(self, other), 1.0)
if isinstance(other, LinearTerm):
return QuadraticTerm(
QuadraticTermKey(self, other.variable), other.coefficient
)
if isinstance(other, LinearBase):
return LinearLinearProduct(self, other) # pytype: disable=bad-return-type
return LinearTerm(self, other)
def __rmul__(self, constant: float) -> "LinearTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return LinearTerm(self, constant)
# TODO(b/216492143): explore numerical consequences of 1.0 / constant below.
def __truediv__(self, constant: float) -> "LinearTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return LinearTerm(self, 1.0 / constant)
def __neg__(self) -> "LinearTerm":
return LinearTerm(self, -1.0)
def _flatten_once_and_add_to(
self,
scale: float,
processed_elements: _ProcessedElements,
target_stack: _ToProcessElements,
) -> None:
processed_elements.terms[self] += scale
class LinearTerm(LinearBase):
"""The product of a scalar and a variable.
This class is immutable.
"""
__slots__ = "_variable", "_coefficient"
def __init__(self, variable: Variable, coefficient: float) -> None:
self._variable: Variable = variable
self._coefficient: float = coefficient
@property
def variable(self) -> Variable:
return self._variable
@property
def coefficient(self) -> float:
return self._coefficient
def _flatten_once_and_add_to(
self,
scale: float,
processed_elements: _ProcessedElements,
target_stack: _ToProcessElements,
) -> None:
processed_elements.terms[self._variable] += self._coefficient * scale
@typing.overload
def __mul__(self, other: float) -> "LinearTerm": ...
@typing.overload
def __mul__(self, other: Union["Variable", "LinearTerm"]) -> "QuadraticTerm": ...
@typing.overload
def __mul__(self, other: "LinearBase") -> "LinearLinearProduct": ...
def __mul__(self, other):
if not isinstance(other, (int, float, LinearBase)):
return NotImplemented
if isinstance(other, Variable):
return QuadraticTerm(
QuadraticTermKey(self._variable, other), self._coefficient
)
if isinstance(other, LinearTerm):
return QuadraticTerm(
QuadraticTermKey(self.variable, other.variable),
self._coefficient * other.coefficient,
)
if isinstance(other, LinearBase):
return LinearLinearProduct(self, other) # pytype: disable=bad-return-type
return LinearTerm(self._variable, self._coefficient * other)
def __rmul__(self, constant: float) -> "LinearTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return LinearTerm(self._variable, self._coefficient * constant)
def __truediv__(self, constant: float) -> "LinearTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return LinearTerm(self._variable, self._coefficient / constant)
def __neg__(self) -> "LinearTerm":
return LinearTerm(self._variable, -self._coefficient)
def __str__(self):
return f"{self._coefficient} * {self._variable}"
def __repr__(self):
return f"LinearTerm({self._variable!r}, {self._coefficient!r})"
class QuadraticTerm(QuadraticBase):
"""The product of a scalar and two variables.
This class is immutable.
"""
__slots__ = "_key", "_coefficient"
def __init__(self, key: QuadraticTermKey, coefficient: float) -> None:
self._key: QuadraticTermKey = key
self._coefficient: float = coefficient
@property
def key(self) -> QuadraticTermKey:
return self._key
@property
def coefficient(self) -> float:
return self._coefficient
def _quadratic_flatten_once_and_add_to(
self,
scale: float,
processed_elements: _QuadraticProcessedElements,
target_stack: _ToProcessElements,
) -> None:
processed_elements.quadratic_terms[self._key] += self._coefficient * scale
def __mul__(self, constant: float) -> "QuadraticTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return QuadraticTerm(self._key, self._coefficient * constant)
def __rmul__(self, constant: float) -> "QuadraticTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return QuadraticTerm(self._key, self._coefficient * constant)
def __truediv__(self, constant: float) -> "QuadraticTerm":
if not isinstance(constant, (int, float)):
return NotImplemented
return QuadraticTerm(self._key, self._coefficient / constant)
def __neg__(self) -> "QuadraticTerm":
return QuadraticTerm(self._key, -self._coefficient)
def __str__(self):
return f"{self._coefficient} * {self._key!s}"
def __repr__(self):
return f"QuadraticTerm({self._key!r}, {self._coefficient})"
class LinearExpression(LinearBase):
"""For variables x, an expression: b + sum_{i in I} a_i * x_i.
This class is immutable.
"""
__slots__ = "__weakref__", "_terms", "_offset"
# TODO(b/216492143): consider initializing from a dictionary.
def __init__(self, /, other: LinearTypes = 0) -> None:
self._offset: float = 0.0
if isinstance(other, (int, float)):
self._offset = float(other)
self._terms: Mapping[Variable, float] = immutabledict.immutabledict()
return
to_process: _LinearToProcessElements = _LinearToProcessElements(other, 1.0)
processed_elements = _ProcessedElements()
while to_process:
linear, coef = to_process.pop()
linear._flatten_once_and_add_to(coef, processed_elements, to_process)
# TODO(b/216492143): explore avoiding this copy.
self._terms: Mapping[Variable, float] = immutabledict.immutabledict(
processed_elements.terms
)
self._offset = processed_elements.offset
@property
def terms(self) -> Mapping[Variable, float]:
return self._terms
@property
def offset(self) -> float:
return self._offset
def evaluate(self, variable_values: Mapping[Variable, float]) -> float:
"""Returns the value of this expression for given variable values.
E.g. if this is 3 * x + 4 and variable_values = {x: 2.0}, then
evaluate(variable_values) equals 10.0.
See also mathopt.evaluate_expression(), which works on any type in
QuadraticTypes.
Args:
variable_values: Must contain a value for every variable in expression.
Returns:
The value of this expression when replacing variables by their value.
"""
result = self._offset
for var, coef in sorted(
self._terms.items(), key=lambda var_coef_pair: var_coef_pair[0].id
):
result += coef * variable_values[var]
return result
def _flatten_once_and_add_to(
self,
scale: float,
processed_elements: _ProcessedElements,
target_stack: _ToProcessElements,
) -> None:
for var, val in self._terms.items():
processed_elements.terms[var] += val * scale
processed_elements.offset += scale * self.offset
# TODO(b/216492143): change __str__ to match C++ implementation in
# cl/421649402.
def __str__(self):
"""Returns the name, or a string containing the id if the name is empty."""
result = str(self.offset)
sorted_keys = sorted(self._terms.keys(), key=str)
for var in sorted_keys:
# TODO(b/216492143): consider how to better deal with `NaN` and try to
# match C++ implementation in cl/421649402. See TODO for StrAndReprTest in
# linear_expression_test.py.
coefficient = self._terms[var]
if coefficient == 0.0:
continue
if coefficient > 0:
result += " + "
else:
result += " - "
result += str(abs(coefficient)) + " * " + str(var)
return result
def __repr__(self):
result = f"LinearExpression({self.offset}, " + "{"
result += ", ".join(
f"{var!r}: {coefficient}" for var, coefficient in self._terms.items()
)
result += "})"
return result
class QuadraticExpression(QuadraticBase):
"""For variables x, an expression: b + sum_{i in I} a_i * x_i + sum_{i,j in I, i<=j} a_i,j * x_i * x_j.
This class is immutable.
"""
__slots__ = "__weakref__", "_linear_terms", "_quadratic_terms", "_offset"
# TODO(b/216492143): consider initializing from a dictionary.
def __init__(self, other: QuadraticTypes) -> None:
self._offset: float = 0.0
if isinstance(other, (int, float)):
self._offset = float(other)
self._linear_terms: Mapping[Variable, float] = immutabledict.immutabledict()
self._quadratic_terms: Mapping[QuadraticTermKey, float] = (
immutabledict.immutabledict()
)
return
to_process: _QuadraticToProcessElements = _QuadraticToProcessElements(
other, 1.0
)
processed_elements = _QuadraticProcessedElements()
while to_process:
linear_or_quadratic, coef = to_process.pop()
if isinstance(linear_or_quadratic, LinearBase):
linear_or_quadratic._flatten_once_and_add_to(
coef, processed_elements, to_process
)
else:
linear_or_quadratic._quadratic_flatten_once_and_add_to(
coef, processed_elements, to_process
)
# TODO(b/216492143): explore avoiding this copy.
self._linear_terms: Mapping[Variable, float] = immutabledict.immutabledict(
processed_elements.terms
)
self._quadratic_terms: Mapping[QuadraticTermKey, float] = (
immutabledict.immutabledict(processed_elements.quadratic_terms)
)
self._offset = processed_elements.offset
@property
def linear_terms(self) -> Mapping[Variable, float]:
return self._linear_terms
@property
def quadratic_terms(self) -> Mapping[QuadraticTermKey, float]:
return self._quadratic_terms
@property
def offset(self) -> float:
return self._offset
def evaluate(self, variable_values: Mapping[Variable, float]) -> float:
"""Returns the value of this expression for given variable values.
E.g. if this is 3 * x * x + 4 and variable_values = {x: 2.0}, then
evaluate(variable_values) equals 16.0.
See also mathopt.evaluate_expression(), which works on any type in
QuadraticTypes.
Args:
variable_values: Must contain a value for every variable in expression.
Returns:
The value of this expression when replacing variables by their value.
"""
result = self._offset
for var, coef in sorted(
self._linear_terms.items(),
key=lambda var_coef_pair: var_coef_pair[0].id,
):
result += coef * variable_values[var]
for key, coef in sorted(
self._quadratic_terms.items(),
key=lambda quad_coef_pair: (
quad_coef_pair[0].first_var.id,
quad_coef_pair[0].second_var.id,
),
):
result += (
coef * variable_values[key.first_var] * variable_values[key.second_var]
)
return result
def _quadratic_flatten_once_and_add_to(
self,
scale: float,
processed_elements: _QuadraticProcessedElements,
target_stack: _QuadraticToProcessElements,
) -> None:
for var, val in self._linear_terms.items():
processed_elements.terms[var] += val * scale
for key, val in self._quadratic_terms.items():
processed_elements.quadratic_terms[key] += val * scale
processed_elements.offset += scale * self.offset
# TODO(b/216492143): change __str__ to match C++ implementation in
# cl/421649402.
def __str__(self):
result = str(self.offset)
sorted_linear_keys = sorted(self._linear_terms.keys(), key=str)
for var in sorted_linear_keys:
# TODO(b/216492143): consider how to better deal with `NaN` and try to
# match C++ implementation in cl/421649402. See TODO for StrAndReprTest in
# linear_expression_test.py.
coefficient = self._linear_terms[var]
if coefficient == 0.0:
continue
if coefficient > 0:
result += " + "
else:
result += " - "
result += str(abs(coefficient)) + " * " + str(var)
sorted_quadratic_keys = sorted(self._quadratic_terms.keys(), key=str)
for key in sorted_quadratic_keys:
# TODO(b/216492143): consider how to better deal with `NaN` and try to
# match C++ implementation in cl/421649402. See TODO for StrAndReprTest in
# linear_expression_test.py.
coefficient = self._quadratic_terms[key]
if coefficient == 0.0:
continue
if coefficient > 0:
result += " + "
else:
result += " - "
result += str(abs(coefficient)) + " * " + str(key)
return result
def __repr__(self):
result = f"QuadraticExpression({self.offset}, " + "{"
result += ", ".join(
f"{var!r}: {coefficient}" for var, coefficient in self._linear_terms.items()
)
result += "}, {"
result += ", ".join(
f"{key!r}: {coefficient}"
for key, coefficient in self._quadratic_terms.items()
)
result += "})"
return result
class LinearSum(LinearBase):
# TODO(b/216492143): consider what details to move elsewhere and/or replace
# by examples, and do complexity analysis.
"""A deferred sum of LinearBase objects.
LinearSum objects are automatically created when two linear objects are added
and, as noted in the documentation for Linear, can reduce the inefficiencies.
In particular, they are created when calling sum(iterable) when iterable is
an Iterable[LinearTypes]. However, using LinearSum(iterable) instead
can result in additional performance improvements:
* sum(iterable): creates a nested set of LinearSum objects (e.g.
`sum([a, b, c])` is `LinearSum(0, LinearSum(a, LinearSum(b, c)))`).
* LinearSum(iterable): creates a single LinearSum that saves a tuple with
all the LinearTypes objects in iterable (e.g.
`LinearSum([a, b, c])` does not create additional objects).
This class is immutable.
"""
__slots__ = "__weakref__", "_elements"
# Potentially unsafe use of Iterable argument is handled by immediate local
# storage as tuple.
def __init__(self, iterable: Iterable[LinearTypes]) -> None:
"""Creates a LinearSum object. A copy of iterable is saved as a tuple."""
self._elements = tuple(iterable)
for item in self._elements:
if not isinstance(item, (LinearBase, int, float)):
raise TypeError(
"unsupported type in iterable argument for "
f"LinearSum: {type(item).__name__!r}"
)
@property
def elements(self) -> Tuple[LinearTypes, ...]:
return self._elements
def _flatten_once_and_add_to(
self,
scale: float,
processed_elements: _ProcessedElements,
target_stack: _ToProcessElements,
) -> None:
for term in self._elements:
if isinstance(term, (int, float)):
processed_elements.offset += scale * float(term)
else:
target_stack.append(term, scale)
def __str__(self):
return str(as_flat_linear_expression(self))
def __repr__(self):
result = "LinearSum(("
result += ", ".join(repr(linear) for linear in self._elements)
result += "))"
return result
class QuadraticSum(QuadraticBase):
# TODO(b/216492143): consider what details to move elsewhere and/or replace
# by examples, and do complexity analysis.
"""A deferred sum of QuadraticTypes objects.
QuadraticSum objects are automatically created when a quadratic object is
added to quadratic or linear objects and, as has performance optimizations
similar to LinearSum.
This class is immutable.
"""
__slots__ = "__weakref__", "_elements"
# Potentially unsafe use of Iterable argument is handled by immediate local
# storage as tuple.
def __init__(self, iterable: Iterable[QuadraticTypes]) -> None:
"""Creates a QuadraticSum object. A copy of iterable is saved as a tuple."""
self._elements = tuple(iterable)
for item in self._elements:
if not isinstance(item, (LinearBase, QuadraticBase, int, float)):
raise TypeError(
"unsupported type in iterable argument for "
f"QuadraticSum: {type(item).__name__!r}"
)
@property
def elements(self) -> Tuple[QuadraticTypes, ...]:
return self._elements
def _quadratic_flatten_once_and_add_to(
self,
scale: float,
processed_elements: _QuadraticProcessedElements,
target_stack: _QuadraticToProcessElements,
) -> None:
for term in self._elements:
if isinstance(term, (int, float)):
processed_elements.offset += scale * float(term)
else:
target_stack.append(term, scale)
def __str__(self):
return str(as_flat_quadratic_expression(self))
def __repr__(self):
result = "QuadraticSum(("
result += ", ".join(repr(element) for element in self._elements)
result += "))"
return result
class LinearProduct(LinearBase):
"""A deferred multiplication computation for linear expressions.
This class is immutable.
"""
__slots__ = "_scalar", "_linear"
def __init__(self, scalar: float, linear: LinearBase) -> None:
if not isinstance(scalar, (float, int)):
raise TypeError(
"unsupported type for scalar argument in "
f"LinearProduct: {type(scalar).__name__!r}"
)
if not isinstance(linear, LinearBase):
raise TypeError(
"unsupported type for linear argument in "
f"LinearProduct: {type(linear).__name__!r}"
)
self._scalar: float = float(scalar)
self._linear: LinearBase = linear
@property
def scalar(self) -> float:
return self._scalar
@property
def linear(self) -> LinearBase:
return self._linear
def _flatten_once_and_add_to(
self,
scale: float,
processed_elements: _ProcessedElements,
target_stack: _ToProcessElements,
) -> None:
target_stack.append(self._linear, self._scalar * scale)
def __str__(self):
return str(as_flat_linear_expression(self))
def __repr__(self):
result = f"LinearProduct({self._scalar!r}, "
result += f"{self._linear!r})"
return result
class QuadraticProduct(QuadraticBase):
"""A deferred multiplication computation for quadratic expressions.
This class is immutable.
"""
__slots__ = "_scalar", "_quadratic"
def __init__(self, scalar: float, quadratic: QuadraticBase) -> None:
if not isinstance(scalar, (float, int)):
raise TypeError(
"unsupported type for scalar argument in "
f"QuadraticProduct: {type(scalar).__name__!r}"
)
if not isinstance(quadratic, QuadraticBase):
raise TypeError(
"unsupported type for linear argument in "
f"QuadraticProduct: {type(quadratic).__name__!r}"
)
self._scalar: float = float(scalar)
self._quadratic: QuadraticBase = quadratic
@property
def scalar(self) -> float:
return self._scalar
@property
def quadratic(self) -> QuadraticBase:
return self._quadratic
def _quadratic_flatten_once_and_add_to(
self,
scale: float,
processed_elements: _QuadraticProcessedElements,
target_stack: _QuadraticToProcessElements,
) -> None:
target_stack.append(self._quadratic, self._scalar * scale)
def __str__(self):
return str(as_flat_quadratic_expression(self))
def __repr__(self):
return f"QuadraticProduct({self._scalar}, {self._quadratic!r})"
class LinearLinearProduct(QuadraticBase):
"""A deferred multiplication of two linear expressions.
This class is immutable.
"""
__slots__ = "_first_linear", "_second_linear"
def __init__(self, first_linear: LinearBase, second_linear: LinearBase) -> None:
if not isinstance(first_linear, LinearBase):
raise TypeError(
"unsupported type for first_linear argument in "
f"LinearLinearProduct: {type(first_linear).__name__!r}"
)
if not isinstance(second_linear, LinearBase):
raise TypeError(
"unsupported type for second_linear argument in "
f"LinearLinearProduct: {type(second_linear).__name__!r}"
)
self._first_linear: LinearBase = first_linear
self._second_linear: LinearBase = second_linear
@property
def first_linear(self) -> LinearBase:
return self._first_linear
@property
def second_linear(self) -> LinearBase:
return self._second_linear
def _quadratic_flatten_once_and_add_to(
self,
scale: float,
processed_elements: _QuadraticProcessedElements,
target_stack: _QuadraticToProcessElements,
) -> None:
# A recursion is avoided here because as_flat_linear_expression() must never
# call _quadratic_flatten_once_and_add_to().
first_expression = as_flat_linear_expression(self._first_linear)
second_expression = as_flat_linear_expression(self._second_linear)
processed_elements.offset += (
first_expression.offset * second_expression.offset * scale
)
for first_var, first_val in first_expression.terms.items():
processed_elements.terms[first_var] += (
second_expression.offset * first_val * scale
)
for second_var, second_val in second_expression.terms.items():
processed_elements.terms[second_var] += (
first_expression.offset * second_val * scale
)
for first_var, first_val in first_expression.terms.items():
for second_var, second_val in second_expression.terms.items():
processed_elements.quadratic_terms[
QuadraticTermKey(first_var, second_var)
] += (first_val * second_val * scale)
def __str__(self):
return str(as_flat_quadratic_expression(self))
def __repr__(self):
result = "LinearLinearProduct("
result += f"{self._first_linear!r}, "
result += f"{self._second_linear!r})"
return result
def as_flat_linear_expression(value: LinearTypes) -> LinearExpression:
"""Converts floats, ints and Linear objects to a LinearExpression."""
if isinstance(value, LinearExpression):
return value
return LinearExpression(value)
def as_flat_quadratic_expression(value: QuadraticTypes) -> QuadraticExpression:
"""Converts floats, ints, LinearBase and QuadraticBase objects to a QuadraticExpression."""
if isinstance(value, QuadraticExpression):
return value
return QuadraticExpression(value)