Skip to content

Commit b058793

Browse files
authored
Merge pull request #2620 from shermanjasonaf/fix-factor-model-bounds
Generalize and Fix Uncertain Parameter Bounds Evaluation for PyROS `FactorModelSet`
2 parents 1bd9e6f + 33ac1af commit b058793

File tree

4 files changed

+201
-58
lines changed

4 files changed

+201
-58
lines changed

pyomo/contrib/pyros/CHANGELOG.txt

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
PyROS CHANGELOG
33
===============
44

5+
-------------------------------------------------------------------------------
6+
PyROS 1.2.3 22 Nov 2022
7+
-------------------------------------------------------------------------------
8+
- Generalize FactorModelSet.
9+
- Resolve issues with FactorModelSet parameter bounds.
10+
- Modularize construction of uncertainty set bounding problems.
11+
12+
513
-------------------------------------------------------------------------------
614
PyROS 1.2.2 09 Nov 2022
715
-------------------------------------------------------------------------------

pyomo/contrib/pyros/pyros.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from pyomo.core.base import Constraint
4747

4848

49-
__version__ = "1.2.2"
49+
__version__ = "1.2.3"
5050

5151

5252
def NonNegIntOrMinusOne(obj):

pyomo/contrib/pyros/tests/test_grcs.py

+87
Original file line numberDiff line numberDiff line change
@@ -2460,6 +2460,93 @@ def test_error_on_invalid_beta(self):
24602460
with self.assertRaisesRegex(ValueError, big_exc_str):
24612461
fset.beta = big_beta
24622462

2463+
@unittest.skipUnless(
2464+
SolverFactory("cbc").available(exception_flag=False),
2465+
"LP solver CBC not available",
2466+
)
2467+
def test_factor_model_parameter_bounds_correct(self):
2468+
"""
2469+
If LP solver is available, test parameter bounds method
2470+
for factor model set is correct (check against
2471+
results from an LP solver).
2472+
"""
2473+
def eval_parameter_bounds(uncertainty_set, solver):
2474+
"""
2475+
Evaluate parameter bounds of uncertainty set by solving
2476+
bounding problems (as opposed to via the `parameter_bounds`
2477+
method).
2478+
"""
2479+
bounding_mdl = uncertainty_set.bounding_model()
2480+
2481+
param_bounds = []
2482+
for idx, obj in bounding_mdl.param_var_objectives.items():
2483+
# activate objective for corresponding dimension
2484+
obj.activate()
2485+
bounds = []
2486+
2487+
# solve for lower bound, then upper bound
2488+
# solve should be successful
2489+
for sense in (minimize, maximize):
2490+
obj.sense = sense
2491+
solver.solve(bounding_mdl)
2492+
bounds.append(value(obj))
2493+
2494+
# add parameter bounds for current dimension
2495+
param_bounds.append(tuple(bounds))
2496+
2497+
# ensure sense is minimize when done, deactivate
2498+
obj.sense = minimize
2499+
obj.deactivate()
2500+
2501+
return param_bounds
2502+
2503+
solver = SolverFactory("cbc")
2504+
2505+
# four cases where prior parameter bounds
2506+
# approximations were probably too tight
2507+
fset1 = FactorModelSet(
2508+
origin=[0, 0],
2509+
number_of_factors=3,
2510+
psi_mat=[[1, -1, 1], [1, 0.1, 1]],
2511+
beta=1/6,
2512+
)
2513+
fset2 = FactorModelSet(
2514+
origin=[0],
2515+
number_of_factors=3,
2516+
psi_mat=[[1, 6, 8]],
2517+
beta=1/2,
2518+
)
2519+
fset3 = FactorModelSet(
2520+
origin=[1],
2521+
number_of_factors=2,
2522+
psi_mat=[[1, 2]],
2523+
beta=1/4,
2524+
)
2525+
fset4 = FactorModelSet(
2526+
origin=[1],
2527+
number_of_factors=3,
2528+
psi_mat=[[-1, -6, -8]],
2529+
beta=1/2,
2530+
)
2531+
2532+
# check parameter bounds matches LP results
2533+
# exactly for each case
2534+
for fset in [fset1, fset2, fset3, fset4]:
2535+
param_bounds = fset.parameter_bounds
2536+
lp_param_bounds = eval_parameter_bounds(fset, solver)
2537+
2538+
self.assertTrue(
2539+
np.allclose(param_bounds, lp_param_bounds),
2540+
msg=(
2541+
"Parameter bounds not consistent with LP values for "
2542+
"FactorModelSet with parameterization:\n"
2543+
f"F={fset.number_of_factors},\n"
2544+
f"beta={fset.beta},\n"
2545+
f"psi_mat={fset.psi_mat},\n"
2546+
f"origin={fset.origin}."
2547+
),
2548+
)
2549+
24632550
@unittest.skipIf(not numpy_available, 'Numpy is not available.')
24642551
def test_uncertainty_set_with_correct_params(self):
24652552
'''

pyomo/contrib/pyros/uncertainty_sets.py

+105-57
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,38 @@ def parameter_bounds(self):
348348
"""
349349
raise NotImplementedError
350350

351+
def bounding_model(self):
352+
"""
353+
Make uncertain parameter value bounding problems (optimize
354+
value of each uncertain parameter subject to constraints on the
355+
uncertain parameters).
356+
357+
Returns
358+
-------
359+
model : ConcreteModel
360+
Bounding problem, with all Objectives deactivated.
361+
"""
362+
model = ConcreteModel()
363+
model.util = Block()
364+
365+
# construct param vars, initialize to nominal point
366+
model.param_vars = Var(range(self.dim))
367+
368+
# add constraints
369+
model.cons = self.set_as_constraint(
370+
uncertain_params=model.param_vars,
371+
model=model,
372+
)
373+
374+
@model.Objective(range(self.dim))
375+
def param_var_objectives(self, idx):
376+
return model.param_vars[idx]
377+
378+
# deactivate all objectives
379+
model.param_var_objectives.deactivate()
380+
381+
return model
382+
351383
def is_bounded(self, config):
352384
"""
353385
Determine whether the uncertainty set is bounded.
@@ -374,35 +406,36 @@ def is_bounded(self, config):
374406
This method is invoked during the validation step of a PyROS
375407
solver call.
376408
"""
377-
# === Determine bounds on all uncertain params
378-
bounding_model = ConcreteModel()
379-
bounding_model.util = Block() # So that boundedness checks work for Cardinality and FactorModel sets
380-
bounding_model.uncertain_param_vars = IndexedVar(range(len(config.uncertain_params)), initialize=1)
381-
for idx, param in enumerate(config.uncertain_params):
382-
bounding_model.uncertain_param_vars[idx].set_value(
383-
param.value, skip_validation=True)
384-
385-
bounding_model.add_component("uncertainty_set_constraint",
386-
config.uncertainty_set.set_as_constraint(
387-
uncertain_params=bounding_model.uncertain_param_vars,
388-
model=bounding_model,
389-
config=config
390-
))
391-
392-
for idx, param in enumerate(list(bounding_model.uncertain_param_vars.values())):
393-
bounding_model.add_component("lb_obj_" + str(idx), Objective(expr=param, sense=minimize))
394-
bounding_model.add_component("ub_obj_" + str(idx), Objective(expr=param, sense=maximize))
395-
396-
for o in bounding_model.component_data_objects(Objective):
397-
o.deactivate()
398-
399-
for i in range(len(bounding_model.uncertain_param_vars)):
400-
for limit in ("lb", "ub"):
401-
getattr(bounding_model, limit + "_obj_" + str(i)).activate()
402-
res = config.global_solver.solve(bounding_model, tee=False)
403-
getattr(bounding_model, limit + "_obj_" + str(i)).deactivate()
409+
bounding_model = self.bounding_model()
410+
solver = config.global_solver
411+
412+
# initialize uncertain parameter variables
413+
for param, param_var in zip(
414+
config.uncertain_params,
415+
bounding_model.param_vars.values(),
416+
):
417+
param_var.set_value(param.value, skip_validation=True)
418+
419+
for idx, obj in bounding_model.param_var_objectives.items():
420+
# activate objective for corresponding dimension
421+
obj.activate()
422+
423+
# solve for lower bound, then upper bound
424+
for sense in (minimize, maximize):
425+
obj.sense = sense
426+
res = solver.solve(
427+
bounding_model,
428+
load_solutions=False,
429+
tee=False,
430+
)
431+
404432
if not check_optimal_termination(res):
405433
return False
434+
435+
# ensure sense is minimize when done, deactivate
436+
obj.sense = minimize
437+
obj.deactivate()
438+
406439
return True
407440

408441
def is_nonempty(self, config):
@@ -1572,10 +1605,9 @@ class FactorModelSet(UncertaintySet):
15721605
Natural number representing the dimensionality of the
15731606
space to which the set projects.
15741607
psi_mat : (N, `number_of_factors`) array_like
1575-
Matrix with nonnegative entries designating each
1576-
uncertain parameter's contribution to each factor.
1577-
Each row is associated with a separate uncertain parameter.
1578-
Each column is associated with a separate factor.
1608+
Matrix designating each uncertain parameter's contribution to
1609+
each factor. Each row is associated with a separate uncertain
1610+
parameter. Each column is associated with a separate factor.
15791611
beta : numeric type
15801612
Real value between 0 and 1 specifying the fraction of the
15811613
independent factors that can simultaneously attain
@@ -1661,8 +1693,7 @@ def psi_mat(self):
16611693
(N, `number_of_factors`) numpy.ndarray : Matrix designating each
16621694
uncertain parameter's contribution to each factor. Each row is
16631695
associated with a separate uncertain parameter. Each column with
1664-
a separate factor. Every entry of the matrix must be
1665-
nonnegative.
1696+
a separate factor.
16661697
"""
16671698
return self._psi_mat
16681699

@@ -1697,13 +1728,6 @@ def psi_mat(self, val):
16971728
"one nonzero entry"
16981729
)
16991730

1700-
for entry in column:
1701-
if entry < 0:
1702-
raise ValueError(
1703-
f"Entry {entry} of attribute 'psi_mat' is negative. "
1704-
"Ensure all entries are nonnegative"
1705-
)
1706-
17071731
self._psi_mat = psi_mat_arr
17081732

17091733
@property
@@ -1758,27 +1782,51 @@ def parameter_bounds(self):
17581782
the uncertain parameter bounds for the corresponding set
17591783
dimension.
17601784
"""
1761-
nom_val = self.origin
1785+
F = self.number_of_factors
17621786
psi_mat = self.psi_mat
17631787

1764-
F = self.number_of_factors
1765-
beta_F = self.beta * F
1766-
floor_beta_F = math.floor(beta_F)
1788+
# evaluate some important quantities
1789+
beta_F = self.beta * self.number_of_factors
1790+
crit_pt_type = int((beta_F + F) / 2)
1791+
beta_F_fill_in = (beta_F + F) - 2 * crit_pt_type - 1
1792+
1793+
# argsort rows of psi_mat in descending order
1794+
row_wise_args = np.argsort(-psi_mat, axis=1)
1795+
17671796
parameter_bounds = []
1768-
for i in range(len(nom_val)):
1769-
non_decreasing_factor_row = sorted(psi_mat[i], reverse=True)
1770-
# deviation = sum_j=1^floor(beta F) {psi_if_j} + (beta F - floor(beta F)) psi_{if_{betaF +1}}
1771-
# because indexing starts at 0, we adjust the limit on the sum and the final factor contribution
1772-
if beta_F - floor_beta_F == 0:
1773-
deviation = sum(non_decreasing_factor_row[j] for j in range(floor_beta_F - 1))
1797+
for idx, orig_val in enumerate(self.origin):
1798+
# number nonnegative values in row
1799+
M = len(psi_mat[idx][psi_mat[idx] >= 0])
1800+
1801+
# argsort psi matrix row in descending order
1802+
sorted_psi_row_args = row_wise_args[idx]
1803+
sorted_psi_row = psi_mat[idx, sorted_psi_row_args]
1804+
1805+
# now evaluate max deviation from origin
1806+
# (depends on number nonneg entries and critical point type)
1807+
if M > crit_pt_type:
1808+
max_deviation = (
1809+
sorted_psi_row[:crit_pt_type].sum()
1810+
+ beta_F_fill_in * sorted_psi_row[crit_pt_type]
1811+
- sorted_psi_row[crit_pt_type + 1:].sum()
1812+
)
1813+
elif M < F - crit_pt_type:
1814+
max_deviation = (
1815+
sorted_psi_row[:F - crit_pt_type - 1].sum()
1816+
- beta_F_fill_in * sorted_psi_row[F - crit_pt_type - 1]
1817+
- sorted_psi_row[F - crit_pt_type:].sum()
1818+
)
17741819
else:
1775-
deviation = sum(non_decreasing_factor_row[j] for j in range(floor_beta_F - 1)) + (
1776-
beta_F - floor_beta_F) * psi_mat[i][floor_beta_F]
1777-
lb = nom_val[i] - deviation
1778-
ub = nom_val[i] + deviation
1779-
if lb > ub:
1780-
raise AttributeError("The computed lower bound on uncertain parameters must be less than or equal to the upper bound.")
1781-
parameter_bounds.append((lb, ub))
1820+
max_deviation = (
1821+
sorted_psi_row[:M].sum()
1822+
- sorted_psi_row[M:].sum()
1823+
)
1824+
1825+
# finally, evaluate the bounds for this dimension
1826+
parameter_bounds.append(
1827+
(orig_val - max_deviation, orig_val + max_deviation),
1828+
)
1829+
17821830
return parameter_bounds
17831831

17841832
def set_as_constraint(self, uncertain_params, **kwargs):

0 commit comments

Comments
 (0)