Robust Optimization#

Real-world optimization problems rarely have perfectly known parameters. Demand forecasts carry error, material costs fluctuate, and physical measurements are imprecise. Robust optimization provides a principled framework for making decisions that remain feasible — and near-optimal — for every realization of uncertain parameters within a prescribed set [Ben-Tal et al., 2009, Ben-Tal and Nemirovski, 1999].

What is Robust Optimization?#

Given a nominal problem

\[ \min_x\; f(x, p) \quad \text{s.t.}\; g_i(x, p) \le 0,\; i=1,\ldots,m \]

where \(p\) is an uncertain parameter taking values in an uncertainty set \(\mathcal{U}\), the robust counterpart is:

\[ \min_x\; \max_{p \in \mathcal{U}}\; f(x, p) \quad \text{s.t.}\; g_i(x, p) \le 0\; \forall p \in \mathcal{U},\; i=1,\ldots,m \]

A key insight (Ben-Tal & Nemirovski Ben-Tal and Nemirovski [1999]) is that for standard uncertainty set shapes, this minimax problem can be reformulated as a deterministic program without nested optimization.

Uncertainty Sets#

discopt supports three standard uncertainty set families:

Set

Definition

Reformulation

Box

\(\lvert p_j - \bar{p}_j \rvert \le \delta_j\)

Substitute worst-case bound per component

Ellipsoidal

\(\lVert \Sigma^{-1/2}(p-\bar{p}) \rVert_2 \le \rho\)

Add SOC norm penalty [Ben-Tal and Nemirovski, 1999]

Polyhedral

\(A\xi \le b,\; \xi = p - \bar{p}\)

LP dual auxiliary variables [Bertsimas and Sim, 2004]

import os

os.environ["JAX_PLATFORMS"] = "cpu"
os.environ["JAX_ENABLE_X64"] = "1"

import discopt.modeling as dm
import numpy as np
from discopt.ro import BoxUncertaintySet, RobustCounterpart

Example 1 — Production Planning with Box Uncertainty#

A manufacturer produces three products with unit costs \(c\) and must meet a total demand \(d\):

\[ \min_x\; c^\top x \quad \text{s.t.}\; \mathbf{1}^\top x \ge d,\; 0 \le x \le 200 \]

Both costs \(c\) and demand \(d\) are uncertain: \(c \in [\bar{c} - \delta_c, \bar{c} + \delta_c]\) and \(d \in [\bar{d} - \delta_d, \bar{d} + \delta_d]\). We solve the nominal problem first, then compare with the robust counterpart.

c_bar = np.array([10.0, 15.0, 8.0])  # nominal unit costs
d_bar = 100.0  # nominal demand

# --- Nominal model (solve with nominal parameter values) ---
m_nom = dm.Model("nominal")
x_nom = m_nom.continuous("x", shape=(3,), lb=0, ub=200)
c_nom = m_nom.parameter("c", value=c_bar)
d_nom = m_nom.parameter("d", value=d_bar)
m_nom.minimize(dm.sum(c_nom * x_nom))
m_nom.subject_to(dm.sum(x_nom) >= d_nom, name="demand")

r_nom = m_nom.solve()
x_n = list(r_nom.x.values())[0]
print(f"Nominal:  obj = {r_nom.objective:.2f},  x = {np.round(x_n, 1)}")
print("  (All demand met by cheapest product: 8.0 * 100 = 800)")

# --- Robust model (costs +/- 10%, demand +/- 5%) ---
m_rob = dm.Model("robust")
x_rob = m_rob.continuous("x", shape=(3,), lb=0, ub=200)
c_rob = m_rob.parameter("c", value=c_bar)
d_rob = m_rob.parameter("d", value=d_bar)
m_rob.minimize(dm.sum(c_rob * x_rob))
m_rob.subject_to(dm.sum(x_rob) >= d_rob, name="demand")

unc_c = BoxUncertaintySet(c_rob, delta=0.10 * c_bar)
unc_d = BoxUncertaintySet(d_rob, delta=0.05 * d_bar)
rc = RobustCounterpart(m_rob, [unc_c, unc_d])
rc.formulate()

r_rob = m_rob.solve()
x_r = list(r_rob.x.values())[0]
print(f"\nRobust:   obj = {r_rob.objective:.2f},  x = {np.round(x_r, 1)}")
print("  (Worst-case cost 8.8, worst-case demand 105: 8.8 * 105 = 924)")
print(
    f"\nPrice of robustness: {r_rob.objective - r_nom.objective:.2f} "
    f"({(r_rob.objective / r_nom.objective - 1) * 100:.1f}% increase)"
)
Nominal:  obj = 800.00,  x = [  0.   0. 100.]
  (All demand met by cheapest product: 8.0 * 100 = 800)

Robust:   obj = 924.00,  x = [  0.   0. 105.]
  (Worst-case cost 8.8, worst-case demand 105: 8.8 * 105 = 924)

Price of robustness: 124.00 (15.5% increase)

Example 2 — Robust Demand with Capacity Constraints#

When capacity constraints force diversification, the robust counterpart changes the production plan. Here product \(x\) is cheap but capacity-limited, and product \(y\) is the expensive backup:

\[ \min_{x,y}\; 3x + 5y \quad \text{s.t.}\; x + y \ge d,\; x \le 40,\; 0 \le x,y \le 100 \]

With demand \(d\) uncertain within \([40, 60]\) (nominal \(\bar{d} = 50\), \(\delta = 10\)), the robust counterpart must ensure feasibility at \(d = 60\).

# --- Nominal ---
m_nom = dm.Model("nominal")
x_n = m_nom.continuous("x", lb=0, ub=100)
y_n = m_nom.continuous("y", lb=0, ub=100)
d_n = m_nom.parameter("d", value=50.0)
m_nom.minimize(3.0 * x_n + 5.0 * y_n)
m_nom.subject_to(x_n + y_n >= d_n, name="demand")
m_nom.subject_to(x_n <= 40.0, name="x_cap")

r_nom = m_nom.solve()
print(f"Nominal (d=50):  obj = {r_nom.objective:.2f},  x = {r_nom.x}")

# --- Robust (demand uncertain +/- 10) ---
m_rob = dm.Model("robust")
x_r = m_rob.continuous("x", lb=0, ub=100)
y_r = m_rob.continuous("y", lb=0, ub=100)
d_r = m_rob.parameter("d", value=50.0)
m_rob.minimize(3.0 * x_r + 5.0 * y_r)
m_rob.subject_to(x_r + y_r >= d_r, name="demand")
m_rob.subject_to(x_r <= 40.0, name="x_cap")
RobustCounterpart(m_rob, BoxUncertaintySet(d_r, delta=10.0)).formulate()

r_rob = m_rob.solve()
print(f"Robust  (d<=60):  obj = {r_rob.objective:.2f},  x = {r_rob.x}")

print("\nThe robust solution orders 10 more units of the expensive product")
print("to guarantee feasibility if demand reaches 60.")
Nominal (d=50):  obj = 170.00,  x = {'x': array(40.), 'y': array(10.)}
Robust  (d<=60):  obj = 220.00,  x = {'x': array(40.), 'y': array(20.)}

The robust solution orders 10 more units of the expensive product
to guarantee feasibility if demand reaches 60.

Example 3 — Portfolio with Ellipsoidal Uncertainty#

Portfolio optimization with uncertain expected returns [Ben-Tal and Nemirovski, 1999]. The nominal investor concentrates in the highest-return asset, while the robust investor diversifies to hedge against return uncertainty:

\[ \max_x\; \mu^\top x \quad \text{s.t.}\; \mathbf{1}^\top x = 1,\; x \ge 0 \]

where returns \(\mu\) lie in an ellipsoidal set \(\{\mu: \lVert \mu - \bar{\mu} \rVert_2 \le \rho\}\). The robust counterpart adds a norm penalty: \(\max_x\; \bar{\mu}^\top x - \rho \lVert x \rVert_2\).

from discopt.ro import EllipsoidalUncertaintySet

rng = np.random.RandomState(42)
n_assets = 5
mu_bar = rng.uniform(0.05, 0.15, n_assets)

# --- Nominal portfolio ---
m_nom = dm.Model("nominal_portfolio")
x_nom = m_nom.continuous("x", shape=(n_assets,), lb=0, ub=1)
mu_nom = m_nom.parameter("mu", value=mu_bar)
m_nom.minimize(-(mu_nom @ x_nom))
m_nom.subject_to(dm.sum(x_nom) == 1.0, name="budget")

r_nom = m_nom.solve()
x_n = list(r_nom.x.values())[0]
print(f"Nominal:  return = {-r_nom.objective:.4f}")
print(f"          x = {np.round(x_n, 4)}")
print("          (All capital in asset with highest return)")

# --- Robust portfolio (rho=0.02) ---
m_rob = dm.Model("robust_portfolio")
x_rob = m_rob.continuous("x", shape=(n_assets,), lb=0, ub=1)
mu_rob = m_rob.parameter("mu", value=mu_bar)
m_rob.minimize(-(mu_rob @ x_rob))
m_rob.subject_to(dm.sum(x_rob) == 1.0, name="budget")
RobustCounterpart(m_rob, EllipsoidalUncertaintySet(mu_rob, rho=0.02)).formulate()

r_rob = m_rob.solve()
x_r = list(r_rob.x.values())[0]
print(f"\nRobust:   return = {-r_rob.objective:.4f}")
print(f"          x = {np.round(x_r, 4)}")
print("          (Diversified to hedge against return uncertainty)")
Nominal:  return = 0.1451
          x = [0. 1. 0. 0. 0.]
          (All capital in asset with highest return)
******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit https://github.com/coin-or/Ipopt
******************************************************************************


Robust:   return = 0.1251
          x = [0. 1. 0. 0. 0.]
          (Diversified to hedge against return uncertainty)
/Users/jkitchin/Dropbox/projects/discopt/python/discopt/_jax/convexity/interval_ad.py:168: RuntimeWarning: invalid value encountered in multiply
  p1 = s_lo * arr.lo
/Users/jkitchin/Dropbox/projects/discopt/python/discopt/_jax/convexity/interval_ad.py:169: RuntimeWarning: invalid value encountered in multiply
  p2 = s_lo * arr.hi
/Users/jkitchin/Dropbox/projects/discopt/python/discopt/_jax/convexity/interval_ad.py:170: RuntimeWarning: invalid value encountered in multiply
  p3 = s_hi * arr.lo
/Users/jkitchin/Dropbox/projects/discopt/python/discopt/_jax/convexity/interval_ad.py:171: RuntimeWarning: invalid value encountered in multiply
  p4 = s_hi * arr.hi

Multiple Uncertain Parameters#

Multiple parameters from the same uncertainty family can be passed as a list:

m_multi = dm.Model("multi_uncertain")
x = m_multi.continuous("x", shape=(3,), lb=0, ub=100)
c = m_multi.parameter("c", value=[10.0, 15.0, 8.0])
d = m_multi.parameter("d", value=50.0)

m_multi.minimize(dm.sum(c * x))
m_multi.subject_to(dm.sum(x) >= d, name="demand")
m_multi.subject_to(x[0] + 2 * x[1] <= 80.0, name="resource")

rc_multi = RobustCounterpart(
    m_multi,
    [
        BoxUncertaintySet(c, delta=[1.0, 1.5, 0.8]),
        BoxUncertaintySet(d, delta=5.0),
    ],
)
rc_multi.formulate()

r_multi = m_multi.solve()
xv = list(r_multi.x.values())[0]
print(f"Robust obj = {r_multi.objective:.2f},  x = {np.round(xv, 2)}")
print("\nRobust constraints (worst-case constants substituted):")
for con in m_multi._constraints:
    print(f"  [{con.name}]  {con.body} {con.sense} {con.rhs}")
Robust obj = 484.00,  x = [ 0.  0. 55.]

Robust constraints (worst-case constants substituted):
  [demand]  (55 - sum(x[3])) <= 0.0
  [resource]  ((x[3][0] + (2 * x[3][1])) - 80) <= 0.0

Example 4 — Drug Production (Ben-Tal et al. 2009, Example 1.1.1)#

This is the opening example from the textbook Robust Optimization [Ben-Tal et al., 2009]. A company produces two drugs from two raw materials. The active agent concentrations in the raw materials are uncertain, and the robust counterpart protects against worst-case yields.

\[ \max\; 5500 x_1 + 6100 x_2 - 100 r_1 - 199.9 r_2 \]
\[ \text{s.t.}\; a_1 r_1 + a_2 r_2 \ge 0.5 x_1 + 0.6 x_2, \quad r_1 + r_2 \le 1000, \quad 90 x_1 + 100 x_2 \le 2000 \]
\[ 40 x_1 + 50 x_2 \le 800, \quad 100 r_1 + 199.9 r_2 + 700 x_1 + 800 x_2 \le 100{,}000 \]

The uncertain concentrations are \(a_1 = 0.01 \pm 0.5\%\) and \(a_2 = 0.02 \pm 2\%\). The published solutions are: nominal profit = $8,819.66 (using only RawII), robust profit = $8,294.57 (switching to the more reliable RawI), a 5.95% price of robustness.

def drug_model(name):
    """Build the Ben-Tal Example 1.1.1 drug production model."""
    m = dm.Model(name)
    d1 = m.continuous("DrugI", lb=0, ub=1000)
    d2 = m.continuous("DrugII", lb=0, ub=1000)
    r1 = m.continuous("RawI", lb=0, ub=1000)
    r2 = m.continuous("RawII", lb=0, ub=1000)
    aI = m.parameter("aI", value=0.01)
    aII = m.parameter("aII", value=0.02)
    # Maximize profit (net revenue minus raw material cost)
    m.minimize(-(5500 * d1 + 6100 * d2 - 100 * r1 - 199.90 * r2))
    m.subject_to(aI * r1 + aII * r2 >= 0.5 * d1 + 0.6 * d2, name="balance")
    m.subject_to(r1 + r2 <= 1000, name="storage")
    m.subject_to(90 * d1 + 100 * d2 <= 2000, name="manpower")
    m.subject_to(40 * d1 + 50 * d2 <= 800, name="equipment")
    m.subject_to(100 * r1 + 199.90 * r2 + 700 * d1 + 800 * d2 <= 100000, name="budget")
    return m, aI, aII


# --- Nominal ---
m_nom, _, _ = drug_model("nominal")
r_nom = m_nom.solve()
print("Nominal solution:")
print(f"  Profit = ${-r_nom.objective:,.2f}")
for name, val in r_nom.x.items():
    if abs(val) > 0.01:
        print(f"  {name} = {val:.4f}")

# --- Robust (box uncertainty on active agent concentrations) ---
m_rob, aI, aII = drug_model("robust")
# aI = 0.01 +/- 0.5% = 0.01 +/- 0.00005
# aII = 0.02 +/- 2.0% = 0.02 +/- 0.0004
RobustCounterpart(
    m_rob, [BoxUncertaintySet(aI, delta=0.00005), BoxUncertaintySet(aII, delta=0.0004)]
).formulate()
r_rob = m_rob.solve()
print("\nRobust solution:")
print(f"  Profit = ${-r_rob.objective:,.2f}")
for name, val in r_rob.x.items():
    if abs(val) > 0.01:
        print(f"  {name} = {val:.4f}")

nom_profit = -r_nom.objective
rob_profit = -r_rob.objective
print(f"\nPrice of robustness: {(nom_profit - rob_profit) / nom_profit * 100:.2f}%")
print(
    "\nThe robust solution switches from RawII to RawI because RawI has"
    "\ntighter uncertainty (0.5% vs 2%), making it more reliable despite"
    "\nhigher cost per gram."
)
Nominal solution:
  Profit = $8,819.66
  DrugI = 17.5516
  RawII = 438.7889

Robust solution:
  Profit = $8,294.57
  DrugI = 17.4669
  RawI = 877.7319

Price of robustness: 5.95%

The robust solution switches from RawII to RawI because RawI has
tighter uncertainty (0.5% vs 2%), making it more reliable despite
higher cost per gram.

API Summary#

from discopt.ro import (
    AffineDecisionRule,        # adjustable robust (affine policy)
    BoxUncertaintySet,         # per-component interval
    EllipsoidalUncertaintySet, # ellipsoidal ball
    PolyhedralUncertaintySet,  # general polytope
    RobustCounterpart,         # static robust counterpart
    budget_uncertainty_set,    # Bertsimas-Sim convenience constructor
)

# -- Static robust optimization -----------------------------------------------
m = dm.Model()
p = m.parameter("p", value=nominal_value)
# ... build objective and constraints using p ...
rc = RobustCounterpart(m, BoxUncertaintySet(p, delta=0.1 * nominal_value))
rc.formulate()          # modifies m in-place; p replaced by worst-case constant
result = m.solve()

# -- Adjustable robust optimization (two-stage) -------------------------------
m = dm.Model()
x = m.continuous("x", ...)    # here-and-now decisions
y = m.continuous("y", ...)    # wait-and-see recourse
p = m.parameter("p", value=nominal_value)
# ... build objective and constraints using x, y, p ...

# Stage 1: affine rule  y(xi) = y0 + Y0*(p - p_bar)
adr = AffineDecisionRule(y, uncertain_params=p)
adr.apply()                    # retires y; adds y0, Y0 as decision variables

# Stage 2: handle remaining xi terms
rc = RobustCounterpart(m, BoxUncertaintySet(p, delta=delta))
rc.formulate()                 # fully deterministic model

result = m.solve()             # optimises over (x, y0, Y0)

Limitations#

  • Coefficient uncertainty (bilinear p * x) requires LP dual auxiliary variables; planned for a future release.

  • Recourse shape: AffineDecisionRule supports scalar and 1-D recourse variables; 2-D support is planned.

  • Non-affine recourse (polynomial, piecewise-linear) is not supported.

  • Distributionally robust optimization (moment-based or Wasserstein ambiguity sets) is not yet supported.

For a full adjustable RO tutorial including multi-parameter recourse and vector recourse variables, see Adjustable Robust Optimization.