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

547 lines
21 KiB
Python
Raw Permalink 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.
"""An Objective for a MathOpt optimization model."""
import abc
from typing import Any, Iterator
from ortools.math_opt.elemental.python import enums
from ortools.math_opt.python import from_model
from ortools.math_opt.python import variables
from ortools.math_opt.python.elemental import elemental
class Objective(from_model.FromModel, metaclass=abc.ABCMeta):
"""The objective for an optimization model.
An objective is either of the form:
min o + sum_{i in I} c_i * x_i + sum_{i, j in I, i <= j} q_i,j * x_i * x_j
or
max o + sum_{i in I} c_i * x_i + sum_{(i, j) in Q} q_i,j * x_i * x_j
where x_i are the decision variables of the problem and where all pairs (i, j)
in Q satisfy i <= j. The values of o, c_i and q_i,j should be finite and not
NaN.
The objective can be configured as follows:
* offset: a float property, o above. Should be finite and not NaN.
* is_maximize: a bool property, if the objective is to maximize or minimize.
* set_linear_coefficient and get_linear_coefficient control the c_i * x_i
terms. The variables must be from the same model as this objective, and
the c_i must be finite and not NaN. The coefficient for any variable not
set is 0.0, and setting a coefficient to 0.0 removes it from I above.
* set_quadratic_coefficient and get_quadratic_coefficient control the
q_i,j * x_i * x_j terms. The variables must be from the same model as this
objective, and the q_i,j must be finite and not NaN. The coefficient for
any pair of variables not set is 0.0, and setting a coefficient to 0.0
removes the associated (i,j) from Q above.
Do not create an Objective directly, use Model.objective to access the
objective instead (or Model.add_auxiliary_objective()). Two Objective objects
can represent the same objective (for the same model). They will have the same
underlying Objective.elemental for storing the data. The Objective class is
simply a reference to an Elemental.
The objective is linear if only linear coefficients are set. This can be
useful to avoid solve-time errors with solvers that do not accept quadratic
objectives. To facilitate this linear objective guarantee we provide three
functions to add to the objective:
* add(), which accepts linear or quadratic expressions,
* add_quadratic(), which also accepts linear or quadratic expressions and
can be used to signal a quadratic objective is possible, and
* add_linear(), which only accepts linear expressions and can be used to
guarantee the objective remains linear.
For quadratic terms, the order that variables are provided does not matter,
we always canonicalize to first_var <= second_var. So if you set (x1, x2) to 7
then:
* getting (x2, x1) returns 7
* setting (x2, x1) to 10 overwrites the value of 7.
Likewise, when we return nonzero quadratic coefficients, we always use the
form first_var <= second_var.
Most problems have only a single objective, but hierarchical objectives are
supported (see Model.add_auxiliary_objective()). Note that quadratic Auxiliary
objectives are not supported.
"""
__slots__ = ("_elemental",)
def __init__(self, elem: elemental.Elemental) -> None:
"""Do not invoke directly, prefer Model.objective."""
self._elemental: elemental.Elemental = elem
@property
def elemental(self) -> elemental.Elemental:
"""The underlying data structure for the model, for internal use only."""
return self._elemental
@property
@abc.abstractmethod
def name(self) -> str:
"""The immutable name of this objective, for display only."""
@property
@abc.abstractmethod
def is_maximize(self) -> bool:
"""If true, the direction is maximization, otherwise minimization."""
@is_maximize.setter
@abc.abstractmethod
def is_maximize(self, is_maximize: bool) -> None: ...
@property
@abc.abstractmethod
def offset(self) -> float:
"""A constant added to the objective."""
@offset.setter
@abc.abstractmethod
def offset(self, value: float) -> None: ...
@property
@abc.abstractmethod
def priority(self) -> int:
"""For hierarchical problems, determines the order to apply objectives.
The objectives are applied from lowest priority to highest.
The default priority for the primary objective is zero, and auxiliary
objectives must specific a priority at creation time.
Priority has no effect for problems with only one objective.
"""
@priority.setter
@abc.abstractmethod
def priority(self, value: int) -> None: ...
@abc.abstractmethod
def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None:
"""Sets the coefficient of `var` to `coef` in the objective."""
@abc.abstractmethod
def get_linear_coefficient(self, var: variables.Variable) -> float:
"""Returns the coefficinet of `var` (or zero if unset)."""
@abc.abstractmethod
def linear_terms(self) -> Iterator[variables.LinearTerm]:
"""Yields variable coefficient pairs for variables with nonzero objective coefficient in undefined order."""
@abc.abstractmethod
def set_quadratic_coefficient(
self,
first_variable: variables.Variable,
second_variable: variables.Variable,
coef: float,
) -> None:
"""Sets the coefficient for product of variables (see class description)."""
@abc.abstractmethod
def get_quadratic_coefficient(
self,
first_variable: variables.Variable,
second_variable: variables.Variable,
) -> float:
"""Gets the coefficient for product of variables (see class description)."""
@abc.abstractmethod
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
"""Yields quadratic terms with nonzero objective coefficient in undefined order."""
@abc.abstractmethod
def clear(self) -> None:
"""Clears objective coefficients and offset. Does not change direction."""
def as_linear_expression(self) -> variables.LinearExpression:
"""Returns an equivalent LinearExpression, or errors if quadratic."""
if any(self.quadratic_terms()):
raise TypeError("Cannot get a quadratic objective as a linear expression")
return variables.as_flat_linear_expression(
self.offset + variables.LinearSum(self.linear_terms())
)
def as_quadratic_expression(self) -> variables.QuadraticExpression:
"""Returns an equivalent QuadraticExpression to this objetive."""
return variables.as_flat_quadratic_expression(
self.offset
+ variables.LinearSum(self.linear_terms())
+ variables.QuadraticSum(self.quadratic_terms())
)
def add(self, objective: variables.QuadraticTypes) -> None:
"""Adds the provided expression `objective` to the objective function.
For a compile time guarantee that the objective remains linear, use
add_linear() instead.
Args:
objective: the expression to add to the objective function.
"""
if isinstance(objective, (variables.LinearBase, int, float)):
self.add_linear(objective)
elif isinstance(objective, variables.QuadraticBase):
self.add_quadratic(objective)
else:
raise TypeError(
"unsupported type in objective argument for "
f"Objective.add(): {type(objective).__name__!r}"
)
def add_linear(self, objective: variables.LinearTypes) -> None:
"""Adds the provided linear expression `objective` to the objective function."""
if not isinstance(objective, (variables.LinearBase, int, float)):
raise TypeError(
"unsupported type in objective argument for "
f"Objective.add_linear(): {type(objective).__name__!r}"
)
objective_expr = variables.as_flat_linear_expression(objective)
self.offset += objective_expr.offset
for var, coefficient in objective_expr.terms.items():
self.set_linear_coefficient(
var, self.get_linear_coefficient(var) + coefficient
)
def add_quadratic(self, objective: variables.QuadraticTypes) -> None:
"""Adds the provided quadratic expression `objective` to the objective function."""
if not isinstance(
objective, (variables.QuadraticBase, variables.LinearBase, int, float)
):
raise TypeError(
"unsupported type in objective argument for "
f"Objective.add(): {type(objective).__name__!r}"
)
objective_expr = variables.as_flat_quadratic_expression(objective)
self.offset += objective_expr.offset
for var, coefficient in objective_expr.linear_terms.items():
self.set_linear_coefficient(
var, self.get_linear_coefficient(var) + coefficient
)
for key, coefficient in objective_expr.quadratic_terms.items():
self.set_quadratic_coefficient(
key.first_var,
key.second_var,
self.get_quadratic_coefficient(key.first_var, key.second_var)
+ coefficient,
)
def set_to_linear_expression(self, linear_expr: variables.LinearTypes) -> None:
"""Sets the objective to optimize to `linear_expr`."""
if not isinstance(linear_expr, (variables.LinearBase, int, float)):
raise TypeError(
"unsupported type in objective argument for "
f"set_to_linear_expression: {type(linear_expr).__name__!r}"
)
self.clear()
objective_expr = variables.as_flat_linear_expression(linear_expr)
self.offset = objective_expr.offset
for var, coefficient in objective_expr.terms.items():
self.set_linear_coefficient(var, coefficient)
def set_to_quadratic_expression(
self, quadratic_expr: variables.QuadraticTypes
) -> None:
"""Sets the objective to optimize the `quadratic_expr`."""
if not isinstance(
quadratic_expr,
(variables.QuadraticBase, variables.LinearBase, int, float),
):
raise TypeError(
"unsupported type in objective argument for "
f"set_to_quadratic_expression: {type(quadratic_expr).__name__!r}"
)
self.clear()
objective_expr = variables.as_flat_quadratic_expression(quadratic_expr)
self.offset = objective_expr.offset
for var, coefficient in objective_expr.linear_terms.items():
self.set_linear_coefficient(var, coefficient)
for quad_key, coefficient in objective_expr.quadratic_terms.items():
self.set_quadratic_coefficient(
quad_key.first_var, quad_key.second_var, coefficient
)
def set_to_expression(self, expr: variables.QuadraticTypes) -> None:
"""Sets the objective to optimize the `expr`."""
if isinstance(expr, (variables.LinearBase, int, float)):
self.set_to_linear_expression(expr)
elif isinstance(expr, variables.QuadraticBase):
self.set_to_quadratic_expression(expr)
else:
raise TypeError(
"unsupported type in objective argument for "
f"set_to_expression: {type(expr).__name__!r}"
)
class PrimaryObjective(Objective):
"""The main objective, but users should program against Objective directly."""
__slots__ = ()
@property
def name(self) -> str:
return self._elemental.primary_objective_name
@property
def is_maximize(self) -> bool:
return self._elemental.get_attr(enums.BoolAttr0.MAXIMIZE, ())
@is_maximize.setter
def is_maximize(self, is_maximize: bool) -> None:
self._elemental.set_attr(enums.BoolAttr0.MAXIMIZE, (), is_maximize)
@property
def offset(self) -> float:
return self._elemental.get_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET, ())
@offset.setter
def offset(self, value: float) -> None:
self._elemental.set_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET, (), value)
@property
def priority(self) -> int:
return self._elemental.get_attr(enums.IntAttr0.OBJECTIVE_PRIORITY, ())
@priority.setter
def priority(self, value: int) -> None:
self._elemental.set_attr(enums.IntAttr0.OBJECTIVE_PRIORITY, (), value)
def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None:
from_model.model_is_same(self, var)
self._elemental.set_attr(
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, (var.id,), coef
)
def get_linear_coefficient(self, var: variables.Variable) -> float:
from_model.model_is_same(self, var)
return self._elemental.get_attr(
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, (var.id,)
)
def linear_terms(self) -> Iterator[variables.LinearTerm]:
keys = self._elemental.get_attr_non_defaults(
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT
)
var_index = 0
coefs = self._elemental.get_attrs(
enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, keys
)
for i in range(len(keys)):
yield variables.LinearTerm(
variable=variables.Variable(self._elemental, int(keys[i, var_index])),
coefficient=float(coefs[i]),
)
def set_quadratic_coefficient(
self,
first_variable: variables.Variable,
second_variable: variables.Variable,
coef: float,
) -> None:
from_model.model_is_same(self, first_variable)
from_model.model_is_same(self, second_variable)
self._elemental.set_attr(
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT,
(first_variable.id, second_variable.id),
coef,
)
def get_quadratic_coefficient(
self,
first_variable: variables.Variable,
second_variable: variables.Variable,
) -> float:
from_model.model_is_same(self, first_variable)
from_model.model_is_same(self, second_variable)
return self._elemental.get_attr(
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT,
(first_variable.id, second_variable.id),
)
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
keys = self._elemental.get_attr_non_defaults(
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT
)
coefs = self._elemental.get_attrs(
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT, keys
)
for i in range(len(keys)):
yield variables.QuadraticTerm(
variables.QuadraticTermKey(
variables.Variable(self._elemental, int(keys[i, 0])),
variables.Variable(self._elemental, int(keys[i, 1])),
),
coefficient=float(coefs[i]),
)
def clear(self) -> None:
self._elemental.clear_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET)
self._elemental.clear_attr(enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT)
self._elemental.clear_attr(
enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT
)
def __eq__(self, other: Any) -> bool:
if isinstance(other, PrimaryObjective):
return self._elemental is other._elemental
return False
def __hash__(self) -> int:
return hash(self._elemental)
class AuxiliaryObjective(Objective):
"""An additional objective that can be optimized after objectives."""
__slots__ = ("_id",)
def __init__(self, elem: elemental.Elemental, obj_id: int) -> None:
"""Internal only, prefer Model functions (add_auxiliary_objective() and get_auxiliary_objective())."""
super().__init__(elem)
if not isinstance(obj_id, int):
raise TypeError(
f"obj_id type should be int, was: {type(obj_id).__name__!r}"
)
self._id: int = obj_id
@property
def name(self) -> str:
return self._elemental.get_element_name(
enums.ElementType.AUXILIARY_OBJECTIVE, self._id
)
@property
def id(self) -> int:
"""Returns the id of this objective."""
return self._id
@property
def is_maximize(self) -> bool:
return self._elemental.get_attr(
enums.BoolAttr1.AUXILIARY_OBJECTIVE_MAXIMIZE, (self._id,)
)
@is_maximize.setter
def is_maximize(self, is_maximize: bool) -> None:
self._elemental.set_attr(
enums.BoolAttr1.AUXILIARY_OBJECTIVE_MAXIMIZE,
(self._id,),
is_maximize,
)
@property
def offset(self) -> float:
return self._elemental.get_attr(
enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET, (self._id,)
)
@offset.setter
def offset(self, value: float) -> None:
self._elemental.set_attr(
enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET,
(self._id,),
value,
)
@property
def priority(self) -> int:
return self._elemental.get_attr(
enums.IntAttr1.AUXILIARY_OBJECTIVE_PRIORITY, (self._id,)
)
@priority.setter
def priority(self, value: int) -> None:
self._elemental.set_attr(
enums.IntAttr1.AUXILIARY_OBJECTIVE_PRIORITY,
(self._id,),
value,
)
def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None:
from_model.model_is_same(self, var)
self._elemental.set_attr(
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT,
(self._id, var.id),
coef,
)
def get_linear_coefficient(self, var: variables.Variable) -> float:
from_model.model_is_same(self, var)
return self._elemental.get_attr(
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT,
(
self._id,
var.id,
),
)
def linear_terms(self) -> Iterator[variables.LinearTerm]:
keys = self._elemental.slice_attr(
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT,
0,
self._id,
)
var_index = 1
coefs = self._elemental.get_attrs(
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT, keys
)
for i in range(len(keys)):
yield variables.LinearTerm(
variable=variables.Variable(self._elemental, int(keys[i, var_index])),
coefficient=float(coefs[i]),
)
def set_quadratic_coefficient(
self,
first_variable: variables.Variable,
second_variable: variables.Variable,
coef: float,
) -> None:
raise ValueError("Quadratic auxiliary objectives are not supported.")
def get_quadratic_coefficient(
self,
first_variable: variables.Variable,
second_variable: variables.Variable,
) -> float:
from_model.model_is_same(self, first_variable)
from_model.model_is_same(self, second_variable)
if not self._elemental.element_exists(
enums.ElementType.VARIABLE, first_variable.id
):
raise ValueError(f"Variable {first_variable} does not exist")
if not self._elemental.element_exists(
enums.ElementType.VARIABLE, second_variable.id
):
raise ValueError(f"Variable {second_variable} does not exist")
return 0.0
def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]:
return iter(())
def clear(self) -> None:
"""Clears objective coefficients and offset. Does not change direction."""
self._elemental.clear_attr(enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET)
self._elemental.clear_attr(
enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT
)
def __eq__(self, other: Any) -> bool:
if isinstance(other, AuxiliaryObjective):
return self._elemental is other._elemental and self._id == other._id
return False
def __hash__(self) -> int:
return hash((self._elemental, self._id))