Coverage for picos/modeling/objective.py: 66.86%
169 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-15 14:21 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-15 14:21 +0000
1# ------------------------------------------------------------------------------
2# Copyright (C) 2019-2021 Maximilian Stahlberg
3#
4# This file is part of PICOS.
5#
6# PICOS is free software: you can redistribute it and/or modify it under the
7# terms of the GNU General Public License as published by the Free Software
8# Foundation, either version 3 of the License, or (at your option) any later
9# version.
10#
11# PICOS is distributed in the hope that it will be useful, but WITHOUT ANY
12# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
13# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License along with
16# this program. If not, see <http://www.gnu.org/licenses/>.
17# ------------------------------------------------------------------------------
19"""Implementation of :class:`Objective`."""
21import cvxopt
23from .. import expressions, glyphs
24from ..apidoc import api_end, api_start
25from ..caching import cached_property
26from ..expressions.uncertain import IntractableWorstCase, UncertainExpression
27from ..valuable import NotValued, Valuable
29_API_START = api_start(globals())
30# -------------------------------
33class Objective(Valuable):
34 """An optimization objective composed of search direction and function.
36 :Example:
38 >>> from picos import Objective, RealVariable
39 >>> x = RealVariable("x")
40 >>> obj = Objective("min", x); obj
41 <Objective: minimize x>
42 >>> obj + x**2 # Add a term to the objective function.
43 <Objective: minimize x + x²>
44 >>> obj/2 + 2*obj # Scale and combine two objectives.
45 <Objective: minimize x/2 + 2·x>
46 >>> -obj # Flip the optimization direction.
47 <Objective: maximize -x>
48 """
50 #: Short string denoting a feasibility problem.
51 FIND = "find"
53 #: Short string denoting a minimization problem.
54 MIN = "min"
56 #: Short string denoting a maximization problem.
57 MAX = "max"
59 def __init__(self, direction=None, function=None):
60 """Construct an optimization objective.
62 :param str direction:
63 Case insensitive search direction string. One of
65 - ``"min"`` or ``"minimize"``,
66 - ``"max"`` or ``"maximize"``,
67 - ``"find"`` or :obj:`None` (for a feasibility problem).
69 :param ~picos.expressions.Expression function:
70 The objective function. Must be :obj:`None` for a feasibility
71 problem.
72 """
73 if direction is None:
74 direction = self.FIND
75 else:
76 if not isinstance(direction, str):
77 raise TypeError("Search direction must be given as a string.")
79 lower = direction.lower()
80 if lower == "find":
81 direction = self.FIND
82 elif lower.startswith("min"):
83 direction = self.MIN
84 elif lower.startswith("max"):
85 direction = self.MAX
86 else:
87 raise ValueError(
88 "Invalid search direction '{}'.".format(direction))
90 if function is None:
91 if direction != self.FIND:
92 raise ValueError("Missing an objective function.")
93 else:
94 if direction == self.FIND:
95 raise ValueError("May not specify an objective function for a "
96 "feasiblity problem.")
98 if not isinstance(function, expressions.Expression):
99 raise TypeError(
100 "Objective function must be a PICOS expression.")
102 if len(function) != 1:
103 raise TypeError("Objective function must be scalar.")
105 function = function.refined
107 if isinstance(function, expressions.ComplexAffineExpression) \
108 and function.complex:
109 raise TypeError("Objective function may not be complex.")
111 self._direction = direction
112 self._function = function
114 def __str__(self):
115 if self._function is None:
116 return "find an assignment"
117 else:
118 minimize = self._direction == self.MIN
119 dir_str = "minimize" if minimize else "maximize"
121 if self._function.uncertain:
122 obj_str = self._function.worst_case_string(
123 "max" if minimize else "min")
124 else:
125 obj_str = self._function.string
127 return "{} {}".format(dir_str, obj_str)
129 def __repr__(self):
130 return glyphs.repr1("Objective: {}".format(self))
132 def __iter__(self):
133 yield self._direction
134 yield self._function
136 def __eq__(self, other):
137 """Report whether two objectives are the same."""
138 if not isinstance(other, Objective):
139 return False
141 if self._direction != other._direction:
142 return False
144 if self._direction == self.FIND:
145 return True
147 try:
148 return self._function.equals(other._function)
149 except AttributeError:
150 # TODO: Allow all expressions to be equality-checked?
151 return self._function is other._function
153 def __pos__(self):
154 """Return the objective as-is."""
155 return self
157 def __neg__(self):
158 """Return the negated objective with the search direction flipped."""
159 if self._direction == self.FIND:
160 return self
161 elif self._direction == self.MIN:
162 return Objective(self.MAX, -self._function)
163 else:
164 return Objective(self.MIN, -self._function)
166 def __add__(self, other):
167 """Denote the sum of two compatible objectives."""
168 if self.feasibility:
169 if isinstance(other, Objective):
170 return other
171 else:
172 raise TypeError(
173 "May only add another objective to a feasiblity objective.")
174 elif isinstance(other, Objective):
175 if other.feasibility:
176 return self
177 elif self._direction == other._direction:
178 return self + other._function
179 else:
180 return self - (-other._function)
181 else:
182 try:
183 function = self._function + other
184 except TypeError as error:
185 raise TypeError("Failed to add to objective.") from error
186 else:
187 return Objective(self._direction, function)
189 def __sub__(self, other):
190 """Denote the difference of two compatible objectives."""
191 if self.feasibility:
192 if isinstance(other, Objective):
193 return -other
194 else:
195 raise TypeError("May only subtract another objective from a "
196 "feasiblity objective.")
197 elif isinstance(other, Objective):
198 if other.feasibility:
199 return self
200 elif self._direction == other._direction:
201 return self - other._function
202 else:
203 return self + (-other._function)
204 else:
205 try:
206 function = self._function - other
207 except TypeError as error:
208 raise TypeError("Failed to subtract from objective.") from error
209 else:
210 return Objective(self._direction, function)
212 def _mul(self, other, reverse):
213 if self.feasibility:
214 return self
215 elif isinstance(other, Objective):
216 raise TypeError("You may only add or subtract two objectives, not "
217 "multiply or divide them.")
218 else:
219 try:
220 if reverse:
221 function = other * self._function
222 else:
223 function = self._function * other
224 except TypeError as error:
225 raise TypeError("Failed to multiply objective.") from error
226 else:
227 return Objective(self._direction, function)
229 def __mul__(self, other):
230 """Denote the product of the objective with an expression."""
231 return self._mul(other, False)
233 def __rmul__(self, other):
234 """Denote the product of the objective with an expression."""
235 return self._mul(other, True)
237 def __truediv__(self, other):
238 """Denote division of the objective by an expression."""
239 if self.feasibility:
240 return self
241 elif isinstance(other, Objective):
242 raise TypeError("You may only add or subtract two objectives, not "
243 "multiply or divide them.")
244 else:
245 try:
246 function = self._function / other
247 except TypeError as error:
248 raise TypeError("Failed to divide objective.") from error
249 else:
250 return Objective(self._direction, function)
252 @property
253 def feasibility(self):
254 """Whether the objective is "find an assignment"."""
255 return self._function is None
257 @property
258 def pair(self):
259 """Search direction and objective function as a pair."""
260 return self._direction, self._objective
262 @property
263 def direction(self):
264 """Search direction as a short string."""
265 return self._direction
267 @property
268 def function(self):
269 """Objective function."""
270 return self._function
272 @cached_property
273 def normalized(self):
274 """The objective but with feasiblity posed as "minimize 0".
276 >>> from picos import Objective
277 >>> obj = Objective(); obj
278 <Objective: find an assignment>
279 >>> obj.normalized
280 <Objective: minimize 0>
281 """
282 if self._function is None:
283 return Objective(self.MIN, expressions.AffineExpression.zero())
284 else:
285 return self
287 # --------------------------------------------------------------------------
288 # Abstract method implementations for the Valuable base class.
289 # --------------------------------------------------------------------------
291 def _get_valuable_string(self):
292 return "objective {}".format(self)
294 def _get_value(self):
295 if self._function is None:
296 raise NotValued("A feasibility objective has no value.")
297 elif isinstance(self._function, UncertainExpression):
298 if self._direction == self.MIN:
299 bad_direction = self.MAX
300 elif self._direction == self.MAX:
301 bad_direction = self.MIN
302 else:
303 bad_direction = self.FIND
305 try:
306 value = self._function.worst_case_value(bad_direction)
307 except IntractableWorstCase as error:
308 raise IntractableWorstCase("Failed to compute the worst-case "
309 "value of the objective function {}: {} Maybe evaluate the "
310 "nominal objective function instead?"
311 .format(self._function.string, error)) from None
312 else:
313 return cvxopt.matrix(value)
314 else:
315 return self._function._get_value()
317 def _set_value(self, value):
318 if self._function is None:
319 raise TypeError("Cannot set the value of a feasibility objective.")
320 else:
321 self._function.value = value
324# --------------------------------------
325__all__ = api_end(_API_START, globals())