==========================
Getting Started with PyROS
==========================
.. contents:: Table of Contents
:depth: 3
:local:
.. _pyros_installation:
Installation
============
In advance of using PyROS to solve robust optimization problems,
you will need (at least) one local nonlinear programming (NLP) solver
(e.g.,
`CONOPT `_,
`IPOPT `_,
`Knitro `_)
and (at least) one global NLP solver
(e.g.,
`BARON `_,
`COUENNE `_,
`SCIP `_)
installed and licensed on your system.
PyROS can be installed as follows:
1. :ref:`Install Pyomo `.
PyROS is included in the Pyomo software package, at pyomo/contrib/pyros.
2. Install NumPy and SciPy with your preferred package manager;
both NumPy and SciPy are required dependencies of PyROS.
You may install NumPy and SciPy with, for example, ``conda``:
::
conda install numpy scipy
or ``pip``:
::
pip install numpy scipy
3. (*Optional*) Test your installation:
install ``pytest`` and ``parameterized``
with your preferred package manager (as in the previous step):
::
pip install pytest parameterized
You may then run the PyROS tests as follows:
::
python -c 'import os, pytest, pyomo.contrib.pyros as p; pytest.main([os.path.dirname(p.__file__)])'
Some tests involving deterministic NLP solvers may be skipped
if
`IPOPT `_,
`BARON `_,
or
`SCIP `_
is not
pre-installed and licensed on your system.
Quickstart
==========
We now provide a quick overview of how to use PyROS
to solve a robust optimization problem.
Consider the nonconvex deterministic QCQP
.. math::
:nowrap:
\[\begin{array}{clll}
\displaystyle \min_{\substack{x \in [-100, 100], \\ z \in [-100, 100], \\ (y_1, y_2) \in [-100, 100]^2}}
& ~~ x^2 - y_1 z + y_2 & \\
\displaystyle \text{s.t.}
& ~~ xy_1 \geq 150 (q_1 + 1)^2 \\
& ~~ x + y_2^2 \leq 600 \\
& ~~ xz - q_2 y_1 = 2 \\
& ~~ y_1^2 - 2y_2 = 15
\end{array}\]
in which
:math:`x` is the sole first-stage variable,
:math:`z` is the sole second-stage variable,
:math:`y_1, y_2` are the state variables,
and :math:`q_1, q_2` are the uncertain parameters.
The uncertain parameters :math:`q_1, q_2`
each have a nominal value of 1.
We assume that :math:`q_1, q_2`
can independently deviate from their
nominal values by up to :math:`\pm 10\%`,
so that :math:`(q_1, q_2)` is constrained in value to the
interval uncertainty set :math:`\mathcal{Q} = [0.9, 1.1]^2`.
.. note::
Per our analysis, our selections of first-stage variables
and second-stage variables in the present example
satisfy our
:ref:`assumption that the state variable values are
uniquely defined `.
Step 0: Import Pyomo and the PyROS Module
-----------------------------------------
In anticipation of using the PyROS solver and building the deterministic Pyomo
model:
.. _pyros_quickstart_module_imports:
.. doctest::
>>> import pyomo.environ as pyo
>>> import pyomo.contrib.pyros as pyros
Step 1: Define the Solver Inputs
--------------------------------
Deterministic Model
^^^^^^^^^^^^^^^^^^^
The model can be implemented as follows:
.. _pyros_quickstart_model_construct:
.. doctest::
>>> m = pyo.ConcreteModel()
>>> # parameters
>>> m.q1 = pyo.Param(initialize=1, mutable=True)
>>> m.q2 = pyo.Param(initialize=1, mutable=True)
>>> # variables
>>> m.x = pyo.Var(bounds=[-100, 100])
>>> m.z = pyo.Var(bounds=[-100, 100])
>>> m.y1 = pyo.Var(bounds=[-100, 100])
>>> m.y2 = pyo.Var(bounds=[-100, 100])
>>> # objective
>>> m.obj = pyo.Objective(expr=m.x ** 2 - m.y1 * m.z + m.y2)
>>> # constraints
>>> m.ineq1 = pyo.Constraint(expr=m.x * m.y1 >= 150 * (m.q1 + 1) ** 2)
>>> m.ineq2 = pyo.Constraint(expr=m.x + m.y2 ** 2 <= 600)
>>> m.eq1 = pyo.Constraint(expr=m.x * m.z - m.y1 * m.q2 == 2)
>>> m.eq2 = pyo.Constraint(expr=m.y1 ** 2 - 2 * m.y2 == 15)
Observe that the uncertain parameters :math:`q_1, q_2` are implemented
as mutable :class:`~pyomo.core.base.param.Param` objects.
See the
:ref:`Uncertain parameters section of the
Solver Interface documentation `
for further guidance.
First-Stage and Second-Stage Variables
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We take ``m.x`` to be the sole first-stage variable and ``m.z``
to be the sole second-stage variable:
.. doctest::
>>> first_stage_variables = [m.x]
>>> second_stage_variables = [m.z]
Uncertain Parameters
^^^^^^^^^^^^^^^^^^^^
The uncertain parameters are represented by ``m.q1`` and ``m.q2``:
.. doctest::
>>> uncertain_params = [m.q1, m.q2]
Uncertainty Set
^^^^^^^^^^^^^^^
As previously discussed, we take the uncertainty set to be
the interval :math:`[0.9, 1.1]^2`,
which we can implement as a
:class:`~pyomo.contrib.pyros.uncertainty_sets.BoxSet` object:
.. doctest::
>>> box_uncertainty_set = pyros.BoxSet(bounds=[(0.9, 1.1)] * 2)
Further information on PyROS uncertainty sets is presented in the
:ref:`Uncertainty Sets documentation `.
Subordinate NLP Solvers
^^^^^^^^^^^^^^^^^^^^^^^
We will use IPOPT as the subordinate local NLP solver
and BARON as the subordinate global NLP solver:
.. doctest::
:skipif: not (baron_available and baron.license_is_valid() and ipopt_available)
>>> local_solver = pyo.SolverFactory("ipopt")
>>> global_solver = pyo.SolverFactory("baron")
.. note::
Additional NLP optimizers can be automatically used in the event the primary
subordinate local or global optimizer passed
to the PyROS :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method
does not successfully solve a subproblem to an appropriate termination
condition. These alternative solvers can be provided through the optional
keyword arguments ``backup_local_solvers`` and ``backup_global_solvers``
to the PyROS :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method.
In advance of using PyROS, we check that the model can be solved
to optimality with the subordinate global solver:
.. _pyros_quickstart_solve_deterministic:
.. doctest::
:skipif: not (baron_available and baron.license_is_valid() and ipopt_available)
>>> pyo.assert_optimal_termination(global_solver.solve(m))
>>> deterministic_obj = pyo.value(m.obj)
>>> print(f"Optimal deterministic objective value: {deterministic_obj:.2f}")
Optimal deterministic objective value: 5407.94
Step 2: Solve With PyROS
------------------------
PyROS can be instantiated through the Pyomo
:class:`~pyomo.opt.base.solvers.SolverFactory`:
.. doctest::
>>> pyros_solver = pyo.SolverFactory("pyros")
Invoke PyROS
^^^^^^^^^^^^^^^^^
We now use PyROS to solve the model to robust optimality
by invoking the :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve`
method of the PyROS solver object:
.. _pyros_quickstart_single-stage-problem:
.. doctest::
:skipif: not (baron_available and baron.license_is_valid() and ipopt_available)
>>> results_1 = pyros_solver.solve(
... # required arguments
... model=m,
... first_stage_variables=first_stage_variables,
... second_stage_variables=second_stage_variables,
... uncertain_params=uncertain_params,
... uncertainty_set=box_uncertainty_set,
... local_solver=local_solver,
... global_solver=global_solver,
... # optional arguments: passed directly to
... # solve to robust optimality
... objective_focus="worst_case",
... solve_master_globally=True,
... ) # doctest: +ELLIPSIS
==============================================================================
PyROS: The Pyomo Robust Optimization Solver...
...
Robust optimal solution identified.
...
All done. Exiting PyROS.
==============================================================================
PyROS, by default, logs to the output console the progress of the optimization
and, upon termination, a summary of the final result.
The summary includes the iteration and solve time requirements,
the final objective function value, and the termination condition.
For further information on the output log,
see the :ref:`Solver Output Log documentation `.
.. note::
PyROS, like other Pyomo solvers, accepts optional arguments
passed indirectly through the keyword argument ``options``.
This is discussed further in the
:ref:`Optional Arguments section of the
Solver Interface documentation `.
Thus, the PyROS solver invocation in the
:ref:`preceding code snippet `
is equivalent to:
.. code-block::
results_1 = pyros_solver.solve(
model=m,
first_stage_variables=first_stage_variables,
second_stage_variables=second_stage_variables,
uncertain_params=uncertain_params,
uncertainty_set=box_uncertainty_set,
local_solver=local_solver,
global_solver=global_solver,
# optional arguments: passed indirectly to
# solve to robust optimality
options={
"objective_focus": "worst_case",
"solve_master_globally": True,
},
)
Inspect the Results
^^^^^^^^^^^^^^^^^^^
The PyROS :meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method
returns a results object,
of type :class:`~pyomo.contrib.pyros.solve_data.ROSolveResults`,
that summarizes the outcome of invoking PyROS on a robust optimization problem.
By default, a printout of the results object is included at the end of the solver
output log.
Alternatively, we can display the results object ourselves using:
.. code::
>>> print(results_1) # output may vary # doctest: +SKIP
Termination stats:
Iterations : 3
Solve time (wall s) : 0.917
Final objective value : 9.6616e+03
Termination condition : pyrosTerminationCondition.robust_optimal
We can also query the results object's individual attributes:
.. code::
>>> results_1.iterations # total number of iterations
3
>>> results_1.time # total wallclock time; may vary # doctest: +SKIP
0.839
>>> results_1.final_objective_value # final objective value; may vary # doctest: +ELLIPSIS
9661.6...
>>> results_1.pyros_termination_condition # termination condition
Since PyROS has successfully solved our problem,
the final solution has been automatically loaded to the model.
We can inspect the resulting state of the model
by invoking, for example, ``m.display()`` or ``m.pprint()``.
For a general discussion of the PyROS solver outputs,
see the
:ref:`Overview of Outputs section of the
Solver Interface documentation `.
Try Higher-Order Decision Rules
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
PyROS uses polynomial decision rules to approximate the adjustability
of the second-stage variables to the uncertain parameters.
The degree of the decision rule polynomials is
specified through the optional keyword argument
``decision_rule_order`` to the PyROS
:meth:`~pyomo.contrib.pyros.pyros.PyROS.solve` method.
By default, ``decision_rule_order`` is set to 0,
so that static decision rules are used.
Increasing the decision rule order
may yield a solution with better quality:
.. _pyros_quickstart_example-two-stg:
.. doctest::
:skipif: not (baron_available and baron.license_is_valid() and ipopt_available)
>>> results_2 = pyros_solver.solve(
... model=m,
... first_stage_variables=first_stage_variables,
... second_stage_variables=second_stage_variables,
... uncertain_params=uncertain_params,
... uncertainty_set=box_uncertainty_set,
... local_solver=local_solver,
... global_solver=global_solver,
... objective_focus="worst_case",
... solve_master_globally=True,
... decision_rule_order=1, # use affine decision rules
... ) # doctest: +ELLIPSIS
==============================================================================
PyROS: The Pyomo Robust Optimization Solver...
...
Robust optimal solution identified.
...
All done. Exiting PyROS.
==============================================================================
Inspecting the results:
.. code::
>>> print(results_2) # output may vary # doctest: +SKIP
Termination stats:
Iterations : 5
Solve time (wall s) : 1.956
Final objective value : 6.5403e+03
Termination condition : pyrosTerminationCondition.robust_optimal
Notice that when we switch from optimizing over static decision rules
to optimizing over affine decision rules,
there is a ~32% decrease in the final objective
value, albeit at some additional computational expense.
Analyzing the Price of Robustness
---------------------------------
In conjunction with standard Pyomo control flow tools,
PyROS facilitates an analysis of the "price of robustness",
which we define to be the increase in the robust optimal objective value
relative to the deterministically optimal objective value.
Let us, for example, consider optimizing robustly against
an interval uncertainty set :math:`[1 - p, 1 + p]^2`,
where :math:`p` is the half-length of the interval.
We can optimize against intervals of increasing half-length :math:`p`
by iterating over select values for :math:`p` in a ``for`` loop,
and in each iteration, solving a robust optimization problem
subject to a corresponding
:class:`~pyomo.contrib.pyros.uncertainty_sets.BoxSet` instance:
.. doctest::
:skipif: not (baron_available and baron.license_is_valid() and ipopt_available)
>>> results_dict = dict()
>>> for half_length in [0.0, 0.1, 0.2, 0.3, 0.4]:
... print(f"Solving problem for {half_length=}:")
... results_dict[half_length] = pyros_solver.solve(
... model=m,
... first_stage_variables=first_stage_variables,
... second_stage_variables=second_stage_variables,
... uncertain_params=uncertain_params,
... uncertainty_set=pyros.BoxSet(
... bounds=[(1 - half_length, 1 + half_length)] * 2
... ),
... local_solver=local_solver,
... global_solver=global_solver,
... objective_focus="worst_case",
... solve_master_globally=True,
... decision_rule_order=1,
... ) # doctest: +ELLIPSIS
...
Solving problem for half_length=0.0:
...
Solving problem for half_length=0.1:
...
Solving problem for half_length=0.2:
...
Solving problem for half_length=0.3:
...
Solving problem for half_length=0.4:
...
All done. Exiting PyROS.
==============================================================================
Using the :py:obj:`dict` populated in the loop,
and the
:ref:`previously evaluated deterministically optimal
objective value `,
we can print a tabular summary of the results:
.. doctest::
:skipif: not (baron_available and baron.license_is_valid() and ipopt_available)
>>> for idx, (half_length, res) in enumerate(results_dict.items()):
... if idx == 0:
... # print table header
... print("=" * 71)
... print(
... f"{'Half-Length':15s}"
... f"{'Termination Cond.':21s}"
... f"{'Objective Value':18s}"
... f"{'Price of Rob. (%)':17s}"
... )
... print("-" * 71)
... # print table row
... obj_value, percent_obj_increase = float("nan"), float("nan")
... is_robust_optimal = (
... res.pyros_termination_condition
... == pyros.pyrosTerminationCondition.robust_optimal
... )
... is_robust_infeasible = (
... res.pyros_termination_condition
... == pyros.pyrosTerminationCondition.robust_infeasible
... )
... if is_robust_optimal:
... # compute the price of robustness
... obj_value = res.final_objective_value
... price_of_robustness = (
... (res.final_objective_value - deterministic_obj)
... / deterministic_obj
... )
... elif is_robust_infeasible:
... # infinite objective
... obj_value, price_of_robustness = float("inf"), float("inf")
... print(
... f"{half_length:<15.1f}"
... f"{res.pyros_termination_condition.name:21s}"
... f"{obj_value:<18.2f}"
... f"{100 * price_of_robustness:<.2f}"
... )
... print("-" * 71)
...
=======================================================================
Half-Length Termination Cond. Objective Value Price of Rob. (%)
-----------------------------------------------------------------------
0.0 robust_optimal 5407.94 -0.00
-----------------------------------------------------------------------
0.1 robust_optimal 6540.31 20.94
-----------------------------------------------------------------------
0.2 robust_optimal 7838.50 44.94
-----------------------------------------------------------------------
0.3 robust_optimal 9316.88 72.28
-----------------------------------------------------------------------
0.4 robust_infeasible inf inf
-----------------------------------------------------------------------
The table shows the response of the PyROS termination condition,
final objective value, and price of robustness
to the half-length :math:`p`.
Observe that:
* The optimal objective value for the interval of half-length
:math:`p=0` is equal to the optimal deterministic objective value
* The objective value (and thus, the price of robustness)
increases with the half-length
* For large enough half-length (:math:`p=0.4`), the problem
is robust infeasible
Therefore, this example clearly illustrates the potential
impact of the uncertainty set size on the robust optimal
objective function value
and the ease of analyzing the price of robustness
for a given optimization problem under uncertainty.
Beyond the Basics
=================
A more in-depth guide to incorporating PyROS into a
Pyomo optimization workflow is given
in the :ref:`Usage Tutorial `.