Skip to content

Commit f95d119

Browse files
authored
Merge pull request #3672 from emma58/linear-dual-fix
Bugfixes for `core.lp_dual` transformation
2 parents d8f1ba2 + 679020a commit f95d119

File tree

7 files changed

+179
-25
lines changed

7 files changed

+179
-25
lines changed

pyomo/core/plugins/transform/lp_dual.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
NonPositiveReals,
2525
maximize,
2626
minimize,
27+
RangeSet,
2728
Reals,
2829
)
30+
from pyomo.core.expr.numvalue import native_numeric_types
2931
from pyomo.opt import WriterFactory
3032
from pyomo.repn.standard_repn import isclose_const
3133
from pyomo.util.config_domains import ComponentDataSet
@@ -110,6 +112,11 @@ def _take_dual(self, model, std_form):
110112
"Model '%s' has no objective or multiple active objectives. Can "
111113
"only take dual with exactly one active objective!" % model.name
112114
)
115+
if len(std_form.columns) == 0 and std_form.c.shape[1] == 0:
116+
raise ValueError(
117+
f"Model '{model.name}' has no variables in the active Constraints "
118+
f"or Objective."
119+
)
113120
primal_sense = std_form.objectives[0].sense
114121

115122
dual = ConcreteModel(name="%s dual" % model.name)
@@ -121,7 +128,29 @@ def _take_dual(self, model, std_form):
121128
dual_cols = range(A.shape[0])
122129
dual.x = Var(dual_cols, domain=NonNegativeReals)
123130
trans_info = dual.private_data()
131+
A_csr = A.tocsr()
124132
for j, (primal_cons, ineq) in enumerate(std_form.rows):
133+
# We need to check this constraint isn't trivial due to the
134+
# parameterization, which we can detect if the row is all 0's.
135+
if A_csr.indptr[j] == A_csr.indptr[j + 1]:
136+
# All 0's in the coefficient matrix: check what's on the RHS
137+
b = std_form.rhs[j]
138+
if type(b) not in native_numeric_types:
139+
# The parameterization made this trivial. I'm not sure what's
140+
# really expected here, so maybe we just scream? Or we leave
141+
# the constraint in the model as it is written...
142+
raise ValueError(
143+
f"The primal model contains a constraint that the "
144+
f"parameterization makes trivial: '{primal_cons.name}'"
145+
f"\nPlease deactivate it or declare it on another Block "
146+
f"before taking the dual."
147+
)
148+
else:
149+
# The whole constraint is trivial--it will already have been
150+
# checked compiling the standard form, so we can safely ignore
151+
# it.
152+
pass
153+
125154
# maximize is -1 and minimize is +1 and ineq is +1 for <= and -1 for
126155
# >=, so we need to change domain to NonPositiveReals if the product
127156
# of these is +1.
@@ -141,17 +170,28 @@ def _take_dual(self, model, std_form):
141170
primal_row = A.indices[j]
142171
lhs += coef * dual.x[primal_row]
143172

144-
if primal.domain is Reals:
173+
domain = primal.domain
174+
lb, ub = domain.bounds()
175+
# Note: the following checks the domain for continuity and compactness:
176+
if not domain == RangeSet(*domain.bounds(), 0):
177+
raise ValueError(
178+
f"The domain of the primal variable '{primal.name}' "
179+
f"is not continuous."
180+
)
181+
unrestricted = (lb is None or lb < 0) and (ub is None or ub > 0)
182+
nonneg = (lb is not None) and lb >= 0
183+
184+
if unrestricted:
145185
dual.constraints[i] = lhs == c[i]
146186
elif primal_sense is minimize:
147-
if primal.domain is NonNegativeReals:
187+
if nonneg:
148188
dual.constraints[i] = lhs <= c[i]
149-
else: # primal.domain is NonPositiveReals
189+
else: # primal domain is nonpositive
150190
dual.constraints[i] = lhs >= c[i]
151191
else:
152-
if primal.domain is NonNegativeReals:
192+
if nonneg:
153193
dual.constraints[i] = lhs >= c[i]
154-
else: # primal.domain is NonPositiveReals
194+
else: # primal domain is nonpositive
155195
dual.constraints[i] = lhs <= c[i]
156196
trans_info.dual_constraint[primal] = dual.constraints[i]
157197
trans_info.primal_var[dual.constraints[i]] = primal

pyomo/core/tests/unit/test_lp_dual.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
Constraint,
1818
maximize,
1919
minimize,
20+
NonNegativeIntegers,
2021
NonNegativeReals,
2122
NonPositiveReals,
2223
Objective,
24+
Param,
2325
Reals,
2426
Suffix,
2527
TerminationCondition,
@@ -398,3 +400,45 @@ def test_dual_var_map_error(self):
398400
"on model 'primal'",
399401
):
400402
thing = lp_dual.get_dual_var(m, m.c_new)
403+
404+
def test_parameterization_makes_constraint_trivial(self):
405+
m = self.get_bilevel_model()
406+
m.budgetish = Constraint(expr=m.outer[2] + m.outer[3] == 1)
407+
408+
lp_dual = TransformationFactory('core.lp_dual')
409+
with self.assertRaisesRegex(
410+
ValueError,
411+
"The primal model contains a constraint that the "
412+
"parameterization makes trivial: 'budgetish'"
413+
"\nPlease deactivate it or declare it on another Block "
414+
"before taking the dual.",
415+
):
416+
dual = lp_dual.create_using(m, parameterize_wrt=m.outer)
417+
418+
def test_normal_trivial_constraint_error(self):
419+
m = ConcreteModel()
420+
m.p = Param(initialize=3, mutable=True)
421+
m.x = Var(bounds=(0, 9))
422+
m.c = Constraint(expr=m.x * m.p <= 8)
423+
m.x.fix(2)
424+
425+
m.obj = Objective(expr=m.x)
426+
427+
lp_dual = TransformationFactory('core.lp_dual')
428+
with self.assertRaisesRegex(
429+
ValueError,
430+
"Model 'unknown' has no variables in the active Constraints "
431+
"or Objective.",
432+
):
433+
dual = lp_dual.create_using(m)
434+
435+
def test_discrete_primal_var_error(self):
436+
m = self.get_bilevel_model()
437+
m.x.domain = NonNegativeIntegers
438+
439+
with self.assertRaisesRegex(
440+
ValueError, "The domain of the primal variable 'x' is not continuous"
441+
):
442+
dual = TransformationFactory('core.lp_dual').create_using(
443+
m, parameterize_wrt=[m.outer, m.outer1]
444+
)

pyomo/repn/parameterized.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ def _before_general_expression(visitor, child):
8383
def _before_var(visitor, child):
8484
_id = id(child)
8585
if _id not in visitor.var_map:
86-
if child.fixed:
87-
return False, (_CONSTANT, visitor.check_constant(child.value, child))
8886
if child in visitor.wrt:
89-
# pseudo-constant
87+
# pseudo-constant, and we need to leave it in even if it is fixed!
9088
# We aren't treating this Var as a Var for the purposes of this walker
9189
return False, (_FIXED, child)
90+
if child.fixed:
91+
return False, (_CONSTANT, visitor.check_constant(child.value, child))
9292
# This is a normal situation
9393
visitor.var_recorder.add(child)
9494
ans = visitor.Result()

pyomo/repn/plugins/parameterized_standard_form.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,64 @@ def todense(self):
224224

225225
return dense
226226

227+
def tocsr(self):
228+
"""Implements the same algorithm as scipy's csr_tocsc function from
229+
sparsetools.
230+
"""
231+
csc_data = self.data
232+
row_index = self.indices
233+
col_index_ptr = self.indptr
234+
ncols = self.shape[1]
235+
236+
num_nonzeros = len(csc_data)
237+
csr_data = np.empty(csc_data.shape[0], dtype=object)
238+
col_index = np.empty(num_nonzeros, dtype=int)
239+
# tally the nonzeros in each column
240+
row_index_ptr = np.zeros(self.shape[0], dtype=int)
241+
for i in row_index:
242+
row_index_ptr[int(i)] += 1
243+
244+
# cumulative sum the tally to get the column index pointer
245+
cum_sum = 0
246+
for i, tally in enumerate(row_index_ptr):
247+
row_index_ptr[i] = cum_sum
248+
cum_sum += tally
249+
# We have now initialized the row_index_ptr to the *starting* position
250+
# of each column in the data vector. Note that row_index_ptr is only
251+
# num_rows long: we have ignored the last entry in the standard CSR
252+
# row_index_ptr (the total number of nonzeros). This will get resolved
253+
# below when we shift this vector by one position.
254+
255+
# Now we are actually going to mess up what we just did while we
256+
# construct the row index: We can imagine that row_index_ptr holds the
257+
# position of the *next* nonzero in each column, so each time we move a
258+
# data element into a column we will increment that row_index_ptr by
259+
# one. This is beautiful because by "messing up" the row_index_pointer,
260+
# we are just transforming the vector of *starting* indices for each
261+
# column to a vector of *ending* indices (actually 1 past the last
262+
# index) of each column. Thank you, scipy.
263+
for col in range(ncols):
264+
for j in range(col_index_ptr[col], col_index_ptr[col + 1]):
265+
row = row_index[j]
266+
dest = row_index_ptr[row]
267+
col_index[dest] = col
268+
# Note that the data changes order because now we are looking
269+
# for nonzeros through the columns rather than through the rows.
270+
csr_data[dest] = csc_data[j]
271+
272+
row_index_ptr[row] += 1
273+
274+
# Fix the row index pointer by inserting 0 at the beginning. The
275+
# row_index_ptr currently holds pointers to 1 past the last element of
276+
# each row, which is really the starting index for the next
277+
# row. Inserting the 0 (the starting index for the first column)
278+
# shifts everything by one column, "converting" the vector to the
279+
# starting indices of each row, and extending the vector length to
280+
# num_rows + 1 (as is expected by the CSR matrix).
281+
row_index_ptr = np.insert(row_index_ptr, 0, 0)
282+
283+
return _CSRMatrix((csr_data, col_index, row_index_ptr), self.shape)
284+
227285
def sum_duplicates(self):
228286
"""Implements the algorithm from scipy's csr_sum_duplicates function
229287
in sparsetools.

pyomo/repn/tests/test_parameterized_linear.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -293,15 +293,11 @@ def test_ANY_over_constant_division(self):
293293
m.x = Var()
294294
m.z = Var()
295295
m.y = Var()
296-
# We will use the fixed value regardless of the fact that we aren't
297-
# treating this as a Var.
298296
m.y.fix(1)
299297

300298
expr = m.y + m.x + m.z + ((3 * m.z * m.x) / m.p) / m.y
301299
cfg = VisitorConfig()
302-
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]).walk_expression(
303-
expr
304-
)
300+
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.z]).walk_expression(expr)
305301

306302
self.assertEqual(repn.multiplier, 1)
307303
assertExpressionsEqual(self, repn.constant, 1 + m.z)
@@ -330,17 +326,15 @@ def test_errors_propagate_nan(self):
330326
"\texpression: 3*z*x/p\n",
331327
)
332328
self.assertEqual(repn.multiplier, 1)
333-
assertExpressionsEqual(self, repn.constant, 1 + m.z)
329+
assertExpressionsEqual(self, repn.constant, m.y + m.z)
334330
self.assertEqual(len(repn.linear), 1)
335331
self.assertIsInstance(repn.linear[id(m.x)], InvalidNumber)
336-
assertExpressionsEqual(self, repn.linear[id(m.x)].value, 1 + float('nan'))
332+
assertExpressionsEqual(self, repn.linear[id(m.x)].value, 1 + float('nan') / m.y)
337333
self.assertEqual(repn.nonlinear, None)
338334

339335
m.y.fix(None)
340336
expr = m.z * log(m.y) + 3
341-
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y, m.z]).walk_expression(
342-
expr
343-
)
337+
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.z]).walk_expression(expr)
344338
self.assertEqual(repn.multiplier, 1)
345339
self.assertIsInstance(repn.constant, InvalidNumber)
346340
assertExpressionsEqual(self, repn.constant.value, float('nan') * m.z + 3)
@@ -523,7 +517,7 @@ def test_0_mult_nan_param(self):
523517
e = m.p * (m.y**2)
524518

525519
cfg = VisitorConfig()
526-
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.y]).walk_expression(e)
520+
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[]).walk_expression(e)
527521

528522
self.assertEqual(len(repn.linear), 0)
529523
self.assertEqual(repn.multiplier, 1)
@@ -539,11 +533,11 @@ def test_0_mult_linear_with_nan(self):
539533
e = m.p * (3 * m.x * m.y + m.z)
540534

541535
cfg = VisitorConfig()
542-
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.x]).walk_expression(e)
536+
repn = ParameterizedLinearRepnVisitor(**cfg, wrt=[m.z]).walk_expression(e)
543537
self.assertEqual(len(repn.linear), 1)
544538
self.assertIn(id(m.y), repn.linear)
545539
self.assertIsInstance(repn.linear[id(m.y)], InvalidNumber)
546540
assertExpressionsEqual(self, repn.linear[id(m.y)].value, 0 * 3 * float('nan'))
547541
self.assertEqual(repn.multiplier, 1)
548542
self.assertIsNone(repn.nonlinear)
549-
self.assertEqual(repn.constant, 0)
543+
assertExpressionsEqual(self, repn.constant, 0 * m.z)

pyomo/repn/tests/test_parameterized_quadratic.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,9 +1059,7 @@ def test_negation_nonlinear_wrt_y_fix_z(self):
10591059
)
10601060

10611061
cfg = VisitorConfig()
1062-
# note: variable fixing takes precedence over inclusion in
1063-
# the `wrt` list; that is tested here
1064-
visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z])
1062+
visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y])
10651063
repn = visitor.walk_expression(expr)
10661064

10671065
self.assertEqual(cfg.subexpr, {})
@@ -1391,7 +1389,7 @@ def test_pow_integer_fixed_var(self):
13911389
expr = (1 + 2 * m.x + 3 * m.y) ** (m.z)
13921390

13931391
cfg = VisitorConfig()
1394-
visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y, m.z])
1392+
visitor = ParameterizedQuadraticRepnVisitor(**cfg, wrt=[m.y])
13951393
repn = visitor.walk_expression(expr)
13961394

13971395
self.assertEqual(cfg.subexpr, {})

pyomo/repn/tests/test_parameterized_standard_form.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ def test_csr_to_csc_only_data(self):
5151
self.assertTrue(np.all(thing.indices == np.array([0, 1, 3, 2])))
5252
self.assertTrue(np.all(thing.indptr == np.array([0, 1, 3, 4, 4])))
5353

54+
should_be_A = thing.tocsr()
55+
self.assertTrue(np.all(should_be_A.data == A.data))
56+
self.assertTrue(np.all(should_be_A.indices == A.indices))
57+
self.assertTrue(np.all(should_be_A.indptr == A.indptr))
58+
5459
def test_csr_to_csc_pyomo_exprs(self):
5560
m = ConcreteModel()
5661
m.x = Var()
@@ -70,6 +75,15 @@ def test_csr_to_csc_pyomo_exprs(self):
7075
self.assertTrue(np.all(thing.indices == np.array([0, 1, 3, 2])))
7176
self.assertTrue(np.all(thing.indptr == np.array([0, 1, 3, 4, 4])))
7277

78+
should_be_A = thing.tocsr()
79+
self.assertEqual(should_be_A.data[0], 5)
80+
assertExpressionsEqual(self, should_be_A.data[1], 8 * m.x)
81+
assertExpressionsEqual(self, should_be_A.data[2], 3 * m.x * m.y**2)
82+
self.assertEqual(should_be_A.data[3], 6)
83+
self.assertEqual(should_be_A.data.shape, (4,))
84+
self.assertTrue(np.all(should_be_A.indices == np.array([0, 1, 2, 1])))
85+
self.assertTrue(np.all(should_be_A.indptr == np.array([0, 1, 2, 3, 4])))
86+
7387
def test_csr_to_csc_empty_matrix(self):
7488
A = _CSRMatrix(([], [], [0]), [0, 4])
7589
thing = A.tocsc()
@@ -79,6 +93,12 @@ def test_csr_to_csc_empty_matrix(self):
7993
self.assertEqual(thing.shape, (0, 4))
8094
self.assertTrue(np.all(thing.indptr == np.zeros(5)))
8195

96+
should_be_A = thing.tocsr()
97+
self.assertEqual(should_be_A.data.size, 0)
98+
self.assertEqual(should_be_A.indices.size, 0)
99+
self.assertEqual(should_be_A.shape, (0, 4))
100+
self.assertTrue(np.all(should_be_A.indptr == np.zeros(5)))
101+
82102
def test_todense(self):
83103
A = _CSRMatrix(([5, 8, 3, 6], [0, 1, 2, 1], [0, 1, 2, 3, 4]), [4, 4])
84104
dense = np.array([[5, 0, 0, 0], [0, 8, 0, 0], [0, 0, 3, 0], [0, 6, 0, 0]])

0 commit comments

Comments
 (0)