Coverage for picos/valuable.py: 72.79%

147 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-26 07:46 +0000

1# ------------------------------------------------------------------------------ 

2# Copyright (C) 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# ------------------------------------------------------------------------------ 

18 

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

20 

21from abc import ABC, abstractmethod 

22 

23import cvxopt 

24import numpy 

25 

26from .apidoc import api_end, api_start 

27 

28_API_START = api_start(globals()) 

29# ------------------------------- 

30 

31 

32class NotValued(RuntimeError): 

33 """The operation cannot be performed due to a mutable without a value. 

34 

35 Note that the :attr:`~Valuable.value`, :attr:`~Valuable.value_as_matrix`, 

36 :attr:`~Valuable.np`, and :attr:`~Valuable.np2d` attributes do not raise 

37 this exception, but return :obj:`None` instead. 

38 """ 

39 

40 pass 

41 

42 

43class Valuable(ABC): 

44 """Abstract base class for objects that can have a numeric value. 

45 

46 This is used by all algebraic expressions through their 

47 :class:`~picos.expressions.expression.Expression` base class as well as by 

48 :class:`~picos.modeling.objective.Objective` and, referencing the latter, by 

49 :class:`~picos.modeling.problem.Problem` instances. 

50 """ 

51 

52 # -------------------------------------------------------------------------- 

53 # Abstract and default-implementation methods. 

54 # -------------------------------------------------------------------------- 

55 

56 @abstractmethod 

57 def _get_valuable_string(self): 

58 """Return a short string defining the valuable object.""" 

59 pass 

60 

61 @abstractmethod 

62 def _get_value(self): 

63 """Return the numeric value of the object as a CVXOPT matrix. 

64 

65 :raises NotValued: When the value is not fully defined. 

66 

67 Method implementations need to return an independent copy of the value 

68 that the user is allowed to change without affecting the object. 

69 """ 

70 pass 

71 

72 def _set_value(self, value): 

73 raise NotImplementedError("Setting the value on an instance of {} is " 

74 "not supported, but you can value any mutables involved instead." 

75 .format(type(self).__name__)) 

76 

77 # -------------------------------------------------------------------------- 

78 # Provided interface. 

79 # -------------------------------------------------------------------------- 

80 

81 def _wrap_get_value(self, asMatrix, staySafe): 

82 """Enhance the implementation of :attr:`_get_value`. 

83 

84 Checks the type of any value returned and offers conversion options. 

85 

86 :param bool asMatrix: 

87 Whether scalar values are returned as matrices. 

88 

89 :param bool staySafe: 

90 Whether :exc:`NotValued` exceptions are raised. Otherwise missing 

91 values are returned as :obj:`None`. 

92 """ 

93 try: 

94 value = self._get_value() 

95 except NotValued: 

96 if staySafe: 

97 raise 

98 else: 

99 return None 

100 

101 assert isinstance(value, (cvxopt.matrix, cvxopt.spmatrix)), \ 

102 "Expression._get_value implementations must return a CVXOPT matrix." 

103 

104 if value.size == (1, 1) and not asMatrix: 

105 return value[0] 

106 else: 

107 return value 

108 

109 value = property( 

110 lambda self: self._wrap_get_value(asMatrix=False, staySafe=False), 

111 lambda self, x: self._set_value(x), 

112 lambda self: self._set_value(None), 

113 r"""Value of the object, or :obj:`None`. 

114 

115 For an expression, it is defined if the expression is constant or if all 

116 mutables involved in the expression are valued. Mutables can be valued 

117 directly by writing to their :attr:`value` attribute. Variables are also 

118 valued by PICOS when an optimization solution is found. 

119 

120 Some expressions can also be valued directly if PICOS can find a minimal 

121 norm mutable assignment that makes the expression have the desired 

122 value. In particular, this works with affine expressions whose linear 

123 part has an under- or well-determined coefficient matrix. 

124 

125 If you prefer the value as a NumPy, use :attr:`np` instead. 

126 

127 :returns: 

128 The value as a Python scalar or CVXOPT matrix, or :obj:`None` if it 

129 is not defined. 

130 

131 :Distinction: 

132 

133 - Unlike :attr:`safe_value` and :attr:`safe_value_as_matrix`, an 

134 undefined value is returned as :obj:`None`. 

135 - Unlike :attr:`value_as_matrix` and :attr:`safe_value_as_matrix`, 

136 scalars are returned as scalar types. 

137 - For uncertain expressions, see also 

138 :meth:`~.uexpression.UncertainExpression.worst_case_value`. 

139 

140 :Example: 

141 

142 >>> from picos import RealVariable 

143 >>> x = RealVariable("x", (1,3)) 

144 >>> y = RealVariable("y", (1,3)) 

145 >>> e = x - 2*y + 3 

146 >>> print("e:", e) 

147 e: x - 2·y + [3] 

148 >>> e.value = [4, 5, 6] 

149 >>> print("e: ", e, "\nx: ", x, "\ny: ", y, sep = "") 

150 e: [ 4.00e+00 5.00e+00 6.00e+00] 

151 x: [ 2.00e-01 4.00e-01 6.00e-01] 

152 y: [-4.00e-01 -8.00e-01 -1.20e+00] 

153 """) 

154 

155 safe_value = property( 

156 lambda self: self._wrap_get_value(asMatrix=False, staySafe=True), 

157 lambda self, x: self._set_value(x), 

158 lambda self: self._set_value(None), 

159 """Value of the object, if defined. 

160 

161 Refer to :attr:`value` for when it is defined. 

162 

163 :returns: 

164 The value as a Python scalar or CVXOPT matrix. 

165 

166 :raises ~picos.NotValued: 

167 If the value is not defined. 

168 

169 :Distinction: 

170 

171 - Unlike :attr:`value`, an undefined value raises an exception. 

172 - Like :attr:`value`, scalars are returned as scalar types. 

173 """) 

174 

175 value_as_matrix = property( 

176 lambda self: self._wrap_get_value(asMatrix=True, staySafe=False), 

177 lambda self, x: self._set_value(x), 

178 lambda self: self._set_value(None), 

179 r"""Value of the object as a CVXOPT matrix type, or :obj:`None`. 

180 

181 Refer to :attr:`value` for when it is defined (not :obj:`None`). 

182 

183 :returns: 

184 The value as a CVXOPT matrix, or :obj:`None` if it is not defined. 

185 

186 :Distinction: 

187 

188 - Like :attr:`value`, an undefined value is returned as :obj:`None`. 

189 - Unlike :attr:`value`, scalars are returned as :math:`1 \times 1` 

190 matrices. 

191 """) 

192 

193 safe_value_as_matrix = property( 

194 lambda self: self._wrap_get_value(asMatrix=True, staySafe=True), 

195 lambda self, x: self._set_value(x), 

196 lambda self: self._set_value(None), 

197 r"""Value of the object as a CVXOPT matrix type, if defined. 

198 

199 Refer to :attr:`value` for when it is defined. 

200 

201 :returns: 

202 The value as a CVXOPT matrix. 

203 

204 :raises ~picos.NotValued: 

205 If the value is not defined. 

206 

207 :Distinction: 

208 

209 - Unlike :attr:`value`, an undefined value raises an exception. 

210 - Unlike :attr:`value`, scalars are returned as :math:`1 \times 1` 

211 matrices. 

212 """) 

213 

214 @property 

215 def np2d(self): 

216 """Value of the object as a 2D NumPy array, or :obj:`None`. 

217 

218 Refer to :attr:`value` for when it is defined (not :obj:`None`). 

219 

220 :returns: 

221 The value as a two-dimensional :class:`numpy.ndarray`, or 

222 :obj:`None`, if the value is not defined. 

223 

224 :Distinction: 

225 

226 - Like :attr:`np`, values are returned as NumPy types or :obj:`None`. 

227 - Unlike :attr:`np`, both scalar and vectorial values are returned as 

228 two-dimensional arrays. In particular, row and column vectors are 

229 distinguished. 

230 """ 

231 value = self.value_as_matrix 

232 

233 if value is None: 

234 return None 

235 

236 # Convert CVXOPT sparse to CVXOPT dense. 

237 if isinstance(value, cvxopt.spmatrix): 

238 value = cvxopt.matrix(value) 

239 

240 assert isinstance(value, cvxopt.matrix) 

241 

242 # Convert CVXOPT dense to a NumPy 2D array. 

243 value = numpy.array(value) 

244 

245 assert len(value.shape) == 2 

246 

247 return value 

248 

249 @np2d.setter 

250 def np2d(self, value): 

251 self._set_value(value) 

252 

253 @np2d.deleter 

254 def np2d(self): 

255 self._set_value(None) 

256 

257 @property 

258 def np(self): 

259 """Value of the object as a NumPy type, or :obj:`None`. 

260 

261 Refer to :attr:`value` for when it is defined (not :obj:`None`). 

262 

263 :returns: 

264 A one- or two-dimensional :class:`numpy.ndarray`, if the value is a 

265 vector or a matrix, respectively, or a NumPy scalar type such as 

266 :obj:`numpy.float64`, if the value is a scalar, or :obj:`None`, 

267 if the value is not defined. 

268 

269 :Distinction: 

270 

271 - Like :attr:`value` and :attr:`np2d`, an undefined value is returned as 

272 :obj:`None`. 

273 - Unlike :attr:`value`, scalars are returned as NumPy scalar types as 

274 opposed to Python builtin scalar types while vectors and matrices are 

275 returned as NumPy arrays as opposed to CVXOPT matrices. 

276 - Unlike :attr:`np2d`, scalars are returned as NumPy scalar types and 

277 vectors are returned as NumPy one-dimensional arrays as opposed to 

278 always returning two-dimensional arrays. 

279 

280 :Example: 

281 

282 >>> from picos import ComplexVariable 

283 >>> Z = ComplexVariable("Z", (3, 3)) 

284 >>> Z.value = [i + i*1j for i in range(9)] 

285 

286 Proper matrices are return as 2D arrays: 

287 

288 >>> Z.value # CVXOPT matrix. 

289 <3x3 matrix, tc='z'> 

290 >>> Z.np # NumPy 2D array. 

291 array([[0.+0.j, 3.+3.j, 6.+6.j], 

292 [1.+1.j, 4.+4.j, 7.+7.j], 

293 [2.+2.j, 5.+5.j, 8.+8.j]]) 

294 

295 Both row and column vectors are returned as 1D arrays: 

296 

297 >>> z = Z[:,0] # First column of Z. 

298 >>> z.value.size # CVXOPT column vector. 

299 (3, 1) 

300 >>> z.T.value.size # CVXOPT row vector. 

301 (1, 3) 

302 >>> z.value == z.T.value 

303 False 

304 >>> z.np.shape # NumPy 1D array. 

305 (3,) 

306 >>> z.T.np.shape # Same array. 

307 (3,) 

308 >>> from numpy import array_equal 

309 >>> array_equal(z.np, z.T.np) 

310 True 

311 

312 Scalars are returned as NumPy types: 

313 

314 >>> u = Z[0,0] # First element of Z. 

315 >>> type(u.value) # Python scalar. 

316 <class 'complex'> 

317 >>> type(u.np) # NumPy scalar. #doctest: +SKIP 

318 <class 'numpy.complex128'> 

319 

320 Undefined values are returned as None: 

321 

322 >>> del Z.value 

323 >>> Z.value is Z.np is None 

324 True 

325 """ 

326 value = self.np2d 

327 

328 if value is None: 

329 return None 

330 elif value.shape == (1, 1): 

331 return value[0, 0] 

332 elif 1 in value.shape: 

333 return numpy.ravel(value) 

334 else: 

335 return value 

336 

337 @np.setter 

338 def np(self, value): 

339 self._set_value(value) 

340 

341 @np.deleter 

342 def np(self): 

343 self._set_value(None) 

344 

345 @property 

346 def sp(self): 

347 """Value as a ScipPy sparse matrix or a NumPy 2D array or :obj:`None`. 

348 

349 If PICOS stores the value internally as a CVXOPT sparse matrix, or 

350 equivalently if :attr:`value_as_matrix` returns an instance of 

351 :func:`cvxopt.spmatrix`, then this returns the value as a :class:`SciPy 

352 sparse matrix in CSC format <scipy.sparse.csc_matrix>`. Otherwise, this 

353 property is equivalent to :attr:`np2d` and returns a two-dimensional 

354 NumPy array, or :obj:`None`, if the value is undefined. 

355 

356 :Example: 

357 

358 >>> import picos, cvxopt 

359 >>> X = picos.RealVariable("X", (3, 3)) 

360 >>> X.value = cvxopt.spdiag([1, 2, 3]) # Stored as a sparse matrix. 

361 >>> type(X.value) 

362 <class 'cvxopt.base.spmatrix'> 

363 >>> type(X.sp) 

364 <class 'scipy.sparse._csc.csc_matrix'> 

365 >>> X.value = range(9) # Stored as a dense matrix. 

366 >>> type(X.value) 

367 <class 'cvxopt.base.matrix'> 

368 >>> type(X.sp) 

369 <class 'numpy.ndarray'> 

370 """ 

371 import scipy.sparse 

372 

373 value = self.value_as_matrix 

374 

375 if value is None: 

376 return None 

377 elif isinstance(value, cvxopt.spmatrix): 

378 return scipy.sparse.csc_matrix( 

379 tuple(list(x) for x in reversed(value.CCS)), value.size) 

380 else: 

381 return numpy.array(value) 

382 

383 @property 

384 def valued(self): 

385 """Whether the object is valued. 

386 

387 .. note:: 

388 

389 Querying this attribute is *not* faster than immediately querying 

390 :attr:`value` and checking whether it is :obj:`None`. Use it only if 

391 you do not need to know the value, but only whether it is available. 

392 

393 :Example: 

394 

395 >>> from picos import RealVariable 

396 >>> x = RealVariable("x", 3) 

397 >>> x.valued 

398 False 

399 >>> x.value 

400 >>> print((x|1)) 

401 ∑(x) 

402 >>> x.value = [1, 2, 3] 

403 >>> (x|1).valued 

404 True 

405 >>> print((x|1)) 

406 6.0 

407 """ 

408 try: 

409 self._get_value() 

410 except NotValued: 

411 return False 

412 else: 

413 return True 

414 

415 @valued.setter 

416 def valued(self, x): 

417 if x is False: 

418 self._set_value(None) 

419 else: 

420 raise ValueError("You may only assign 'False' to the 'valued' " 

421 "attribute, which is the same as setting 'value' to 'None'.") 

422 

423 def __index__(self): 

424 """Propose the value as an index.""" 

425 value = self.value_as_matrix 

426 

427 if value is None: 

428 raise NotValued("Cannot use unvalued {} as an index." 

429 .format(self._get_valuable_string())) 

430 

431 if value.size != (1, 1): 

432 raise TypeError("Cannot use multidimensional {} as an index." 

433 .format(self._get_valuable_string())) 

434 

435 value = value[0] 

436 

437 if value.imag: 

438 raise ValueError( 

439 "Cannot use {} as an index as its value of {} has a nonzero " 

440 "imaginary part.".format(self._get_valuable_string(), value)) 

441 

442 value = value.real 

443 

444 if not value.is_integer(): 

445 raise ValueError("Cannot use {} as an index as its value of {} is " 

446 "not integral.".format(self._get_valuable_string(), value)) 

447 

448 return int(value) 

449 

450 def _casting_helper(self, theType): 

451 assert theType in (int, float, complex) 

452 

453 value = self.value_as_matrix 

454 

455 if value is None: 

456 raise NotValued("Cannot cast unvalued {} as {}." 

457 .format(self._get_valuable_string(), theType.__name__)) 

458 

459 if value.size != (1, 1): 

460 raise TypeError( 

461 "Cannot cast multidimensional {} as {}." 

462 .format(self._get_valuable_string(), theType.__name__)) 

463 

464 value = value[0] 

465 

466 return theType(value) 

467 

468 def __int__(self): 

469 """Cast the value to an :class:`int`.""" 

470 return self._casting_helper(int) 

471 

472 def __float__(self): 

473 """Cast the value to a :class:`float`.""" 

474 return self._casting_helper(float) 

475 

476 def __complex__(self): 

477 """Cast the value to a :class:`complex`.""" 

478 return self._casting_helper(complex) 

479 

480 def __round__(self, ndigits=None): 

481 """Round the value to a certain precision.""" 

482 return round(float(self), ndigits) 

483 

484 def __array__(self, dtype=None): 

485 """Return the value as a :class:`NumPy array <numpy.ndarray>`.""" 

486 value = self.safe_value_as_matrix 

487 

488 # Convert CVXOPT sparse to CVXOPT dense. 

489 if isinstance(value, cvxopt.spmatrix): 

490 value = cvxopt.matrix(value) 

491 

492 assert isinstance(value, cvxopt.matrix) 

493 

494 # Convert CVXOPT dense to a NumPy 2D array. 

495 value = numpy.array(value, dtype) 

496 

497 assert len(value.shape) == 2 

498 

499 # Remove dimensions of size one. 

500 if value.shape == (1, 1): 

501 return numpy.reshape(value, ()) 

502 elif 1 in value.shape: 

503 return numpy.ravel(value) 

504 else: 

505 return value 

506 

507 # Prevent NumPy operators from loading PICOS expressions as arrays. 

508 __array_priority__ = float("inf") 

509 __array_ufunc__ = None 

510 

511 

512def patch_scipy_array_priority(): 

513 """Monkey-patch scipy.sparse to make it respect ``__array_priority__``. 

514 

515 This works around https://github.com/scipy/scipy/issues/4819 and is inspired 

516 by CVXPY's scipy_wrapper.py. 

517 """ 

518 import scipy.sparse 

519 

520 def teach_array_priority(operator): 

521 def respect_array_priority(self, other): 

522 if hasattr(other, "__array_priority__") \ 

523 and self.__array_priority__ < other.__array_priority__: 

524 return NotImplemented 

525 else: 

526 return operator(self, other) 

527 

528 return respect_array_priority 

529 

530 base_type = scipy.sparse.spmatrix 

531 matrix_types = (type_ for type_ in scipy.sparse.__dict__.values() 

532 if isinstance(type_, type) and issubclass(type_, base_type)) 

533 

534 for matrix_type in matrix_types: 

535 for operator_name in ( 

536 "__add__", "__div__", "__eq__", "__ge__", "__gt__", "__le__", 

537 "__lt__", "__matmul__", "__mul__", "__ne__", "__pow__", "__sub__", 

538 "__truediv__", 

539 ): 

540 operator = getattr(matrix_type, operator_name) 

541 

542 # Wrap all binary operators of the base class and all overrides. 

543 if matrix_type is base_type \ 

544 or operator is not getattr(base_type, operator_name): 

545 wrapped_operator = teach_array_priority(operator) 

546 setattr(matrix_type, operator_name, wrapped_operator) 

547 

548 

549# -------------------------------------- 

550__all__ = api_end(_API_START, globals())