diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb new file mode 100644 index 0000000..47fa6c5 --- /dev/null +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -0,0 +1,114 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f330297d", + "metadata": {}, + "source": "# Multi-Objective Portfolio Optimization with cuOpt Python API\n\nThis notebook demonstrates how to use the cuOpt Python API to trace the **efficient frontier** of a mean-variance portfolio \u2014 the multi-objective core of portfolio optimization, where there's no single best balance of return and risk, only a curve of optimal tradeoffs.\n\nThe base `QP_portfolio_optimization` notebook solves for individual portfolios; here we sweep the whole frontier with the **\u03b5-constraint method** and read each point's **sensitivity** (the return-constraint dual):\n\n1. **Two objectives that conflict** \u2014 maximize return, minimize variance.\n2. **Keep one as the objective, constrain the other** \u2014 minimize variance subject to `return \u2265 \u03b5`.\n3. **Sweep \u03b5** across the achievable return range; each solve is one frontier point.\n4. **Read the frontier** \u2014 and, because this is a continuous QP, read each point's **dual**: the sensitivity d(variance)/d(return), how much extra variance one more unit of return costs.\n\nA useful thing to notice: the base notebook already loops over target returns, so it is **already doing this** \u2014 naming the method is what lets you add the dual and reuse the recipe on any two-objective problem. (This workflow is also packaged as the `cuopt-multi-objective-exploration` skill.)" + }, + { + "cell_type": "markdown", + "id": "39af174e", + "metadata": {}, + "source": "## Environment Setup" + }, + { + "cell_type": "code", + "id": "dd4db594", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import subprocess\nimport html\nfrom IPython.display import display, HTML\n\ndef check_gpu():\n try:\n result = subprocess.run([\"nvidia-smi\"], capture_output=True, text=True, timeout=5)\n result.check_returncode()\n lines = result.stdout.splitlines()\n gpu_info = lines[2] if len(lines) > 2 else \"GPU detected\"\n gpu_info_escaped = html.escape(gpu_info)\n display(HTML(f\"\"\"\n
\n

\u2705 GPU is enabled

\n
{gpu_info_escaped}
\n
\n \"\"\"))\n return True\n except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e:\n display(HTML(\"\"\"\n
\n

\u26a0\ufe0f GPU not detected!

\n

This notebook requires a GPU runtime.

\n\n

If running in Google Colab:

\n
    \n
  1. Click on Runtime \u2192 Change runtime type
  2. \n
  3. Set Hardware accelerator to GPU
  4. \n
  5. Then click Save and Runtime \u2192 Restart runtime.
  6. \n
\n\n

If running in Docker:

\n
    \n
  1. Ensure you have NVIDIA Docker runtime installed (nvidia-docker2)
  2. \n
  3. Run container with GPU support: docker run --gpus all ...
  4. \n
  5. Or use: docker run --runtime=nvidia ... for older Docker versions
  6. \n
  7. Verify GPU access: docker run --gpus all nvidia/cuda:12.0.0-base-ubuntu22.04 nvidia-smi
  8. \n
\n\n

Additional resources:

\n \n
\n \"\"\"))\n return False\n\ncheck_gpu()" + }, + { + "cell_type": "code", + "id": "258c4642", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Uncomment for your CUDA version if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12 # CUDA 12\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu13 # CUDA 13" + }, + { + "cell_type": "code", + "id": "91a1a66e", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom cuopt.linear_programming.problem import Problem, QuadraticExpression, MINIMIZE\nprint(\"Imports ready (cuOpt QP solver)\")" + }, + { + "cell_type": "markdown", + "id": "16e2d023", + "metadata": {}, + "source": "## Step 1 \u2014 the assets and their two objectives\n\nSame simulated asset universe as `QP_portfolio_optimization`. The annualized **mean return** (to maximize) and **variance** / risk (to minimize) are the two competing objectives we trade off below." + }, + { + "cell_type": "code", + "id": "8b8b0bde", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Simulate monthly returns with realistic assumptions\nnp.random.seed(7)\n\nassets = [\"Cash\", \"US Equity\", \"Intl Equity\", \"Bond\", \"REIT/Gold\"]\n\nannual_mean = np.array([0.02, 0.08, 0.075, 0.04, 0.06])\nannual_vol = np.array([0.005, 0.16, 0.18, 0.06, 0.14])\n\ncorr = np.array([\n [1.00, 0.05, 0.05, 0.10, 0.05],\n [0.05, 1.00, 0.80, -0.10, 0.55],\n [0.05, 0.80, 1.00, -0.05, 0.50],\n [0.10, -0.10, -0.05, 1.00, 0.00],\n [0.05, 0.55, 0.50, 0.00, 1.00],\n])\n\nmonthly_mean = annual_mean / 12.0\nmonthly_vol = annual_vol / np.sqrt(12.0)\nmonthly_cov = np.outer(monthly_vol, monthly_vol) * corr\n\nn_months = 120\nreturns = np.random.multivariate_normal(monthly_mean, monthly_cov, size=n_months)\n\n# Estimate annualized mean and covariance from the simulated data\nmean_returns = returns.mean(axis=0) * 12.0\ncov_matrix = np.cov(returns, rowvar=False) * 12.0\n\nsummary = pd.DataFrame(\n {\n \"Annualized Return\": mean_returns,\n \"Annualized Volatility\": np.sqrt(np.diag(cov_matrix)),\n },\n index=assets,\n)\n\nsummary.style.format({\"Annualized Return\": \"{:.2%}\", \"Annualized Volatility\": \"{:.2%}\"})" + }, + { + "cell_type": "markdown", + "id": "e5004295", + "metadata": {}, + "source": "## Step 2 \u2014 minimize variance, capturing the return-constraint dual\n\nThe base notebook's `solve_min_variance_qp`, with one addition: we keep a handle on the `min_return` constraint and capture its **`.DualValue`** after the solve. For a continuous QP, that dual is the **sensitivity** d(variance)/d(return) \u2014 the marginal variance cost of demanding one more unit of return." + }, + { + "cell_type": "code", + "id": "44ad356a", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=None, max_weight=None):\n \"\"\"Solve the min-variance QP and return the weights plus the return-constraint dual.\n\n Minimizes portfolio variance (w' * cov_matrix * w) subject to fully-invested\n weights and, when target_return is given, a minimum-return epsilon-constraint.\n The min_return constraint's .DualValue is the sensitivity d(variance)/d(return),\n accurate to cuOpt's barrier-solver tolerance (1e-8 by default).\n\n Parameters\n ----------\n cov_matrix : ndarray (n, n)\n Annualized covariance matrix (positive semidefinite).\n mean_returns : ndarray (n,)\n Annualized expected returns.\n target_return : float, optional\n Minimum portfolio return (the swept epsilon-constraint); None = unconstrained.\n max_weight : float, optional\n Upper bound on each asset weight (default 1.0).\n\n Returns\n -------\n dict\n {\"weights\", \"ret\", \"vol\", \"dual\", \"status\"}.\n \"\"\"\n n = len(mean_returns)\n prob = Problem(\"Portfolio_Optimization\")\n ub = max_weight if max_weight is not None else 1.0\n w = [prob.addVariable(lb=0.0, ub=ub, name=f\"w_{i}\") for i in range(n)]\n\n quad = None\n for i in range(n):\n for j in range(n):\n c = float(cov_matrix[i, j])\n if abs(c) > 1e-12:\n term = c * w[i] * w[j]\n quad = term if quad is None else quad + term\n prob.setObjective(quad, sense=MINIMIZE)\n\n prob.addConstraint(sum(w) == 1, name=\"fully_invested\")\n ret_con = None\n if target_return is not None:\n ret_expr = sum(float(mean_returns[i]) * w[i] for i in range(n))\n ret_con = prob.addConstraint(ret_expr >= float(target_return), name=\"min_return\")\n\n prob.solve()\n status = prob.Status.name if hasattr(prob.Status, \"name\") else str(prob.Status)\n weights = np.array([w[i].Value for i in range(n)])\n port_ret = float(mean_returns @ weights)\n port_vol = float(np.sqrt(max(weights @ cov_matrix @ weights, 0.0)))\n dual = abs(float(ret_con.DualValue)) if ret_con is not None else 0.0 # sensitivity d(var)/d(return)\n return {\"weights\": weights, \"ret\": port_ret, \"vol\": port_vol, \"dual\": dual, \"status\": status}\n\nmv = solve_min_variance_qp_dual(cov_matrix, mean_returns)\nprint(f\"Min-variance: status={mv['status']}, return={mv['ret']:.2%}, vol={mv['vol']:.2%}\")" + }, + { + "cell_type": "markdown", + "id": "2a1a84e1", + "metadata": {}, + "source": "## Step 3 \u2014 sweep the return floor \u2192 the frontier (and its duals)\n\nSweep the return floor \u03b5 across the achievable range; each `\u03b5` is one standard cuOpt solve, and we capture the dual at each point. cuOpt solves a quadratic objective with its **barrier (interior-point)** method (the only method that supports quadratic objectives), to 1e-8 relative accuracy by default \u2014 so the dual is accurate to that tolerance, not exact. We keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." + }, + { + "cell_type": "code", + "id": "5098fd42", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "min_ret = mv[\"ret\"]\nmax_ret = float(mean_returns.max())\ntargets = np.linspace(min_ret, max_ret * 0.999, 25)\n\nrets, vols, duals, flagged = [], [], [], 0\nfor t in targets:\n r = solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=t)\n if r[\"status\"] not in (\"Optimal\", \"PrimalFeasible\"):\n continue\n if r[\"status\"] != \"Optimal\":\n flagged += 1\n rets.append(r[\"ret\"]); vols.append(r[\"vol\"]); duals.append(r[\"dual\"])\n\nrets, vols, duals = map(np.array, (rets, vols, duals))\nprint(f\"Frontier points: {len(rets)} | not certified-Optimal (PrimalFeasible): {flagged}\")\nprint(f\"Sensitivity d(variance)/d(return): {duals.min():.3f} -> {duals.max():.3f} as required return rises\")" + }, + { + "cell_type": "code", + "id": "4f051b88", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\naxes[0].plot(vols * 100, rets * 100, \"o-\", color=\"navy\", lw=1.6)\naxes[0].set_xlabel(\"Volatility (%)\"); axes[0].set_ylabel(\"Expected Return (%)\")\naxes[0].set_title(\"Efficient frontier (return vs risk)\"); axes[0].grid(alpha=0.3)\n\naxes[1].plot(rets * 100, duals, \"o-\", color=\"purple\", lw=1.6)\naxes[1].set_xlabel(\"Required return (%)\"); axes[1].set_ylabel(\"Sensitivity d(variance)/d(return)\")\naxes[1].set_title(\"Marginal risk cost of return (cuOpt QP dual)\"); axes[1].grid(alpha=0.3)\nplt.tight_layout(); plt.show()" + }, + { + "cell_type": "markdown", + "id": "6888f83f", + "metadata": {}, + "source": "## Step 4 \u2014 read the frontier\n\n- The **frontier** (left) is the return-vs-risk Pareto set \u2014 every point is a min-variance portfolio for its return floor. There's no single \"best\"; you choose where on the curve to sit.\n- The **dual** (right) is the **sensitivity** d(variance)/d(return): how much extra variance each additional unit of return costs. It rises along the frontier \u2014 the marginal cost of return steepens, which is exactly where a knee analysis pays off.\n\n### Takeaway \u2014 reusing this on your own problem\nTwo competing objectives and a solver for one of them is all you need: keep one objective, turn the other into a swept constraint (`f\u2082 \u2265 \u03b5` or `\u2264 \u03b5`), solve across the range, and read the frontier. If your code already loops over a target value, that loop **is** an \u03b5-constraint sweep \u2014 name it, collect the non-dominated points, and (for an LP or QP) read the constraint's dual for the marginal exchange rate.\n\n### Notes\n- **Synthetic data** \u2014 the base notebook's simulated universe; demonstrates the method.\n- **Barrier solver** \u2014 a quadratic objective is solved by cuOpt's barrier (interior-point) method (1e-8 relative accuracy by default), so the dual is accurate to that tolerance, not exact arithmetic; points reported `PrimalFeasible` rather than `Optimal` are flagged above and could be tightened or dropped.\n- **Duals need continuity** \u2014 these sensitivities exist because the portfolio is a continuous QP; an integer program would expose none." + }, + { + "cell_type": "markdown", + "id": "b281399a", + "metadata": {}, + "source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License." + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/portfolio_optimization/README.md b/portfolio_optimization/README.md index 830e952..5a46717 100644 --- a/portfolio_optimization/README.md +++ b/portfolio_optimization/README.md @@ -14,7 +14,12 @@ The portfolio optimization notebook solves a portfolio optimization problem wher - The aim is to balance expected return with the risk of losses -### 3. Advanced Portfolio Optimization +### 3. Multi-Objective Portfolio Optimization (QP) + +- Traces the efficient frontier (return vs. risk) as an ε-constraint sweep. +- Reads each point's **dual**: the sensitivity d(variance)/d(return), from cuOpt's barrier (interior-point) solver. + +### 4. Advanced Portfolio Optimization For advanced portfolio optimization examples including: - Efficient frontier construction diff --git a/workforce_optimization/README.md b/workforce_optimization/README.md index ae3d74f..a22f940 100644 --- a/workforce_optimization/README.md +++ b/workforce_optimization/README.md @@ -10,4 +10,13 @@ The workforce optimization notebook solves a mixed integer linear programming pr - The goal is to assign workers to shifts while minimizing total labor cost. - The workers have different availability and different pay rates. -- The shifts have different requirements. \ No newline at end of file +- The shifts have different requirements. + +### 2. Workforce Optimization (Multi-Objective) + +The multi-objective notebook extends the MILP above into a Pareto frontier, so you see the tradeoff instead of a single plan. It traces two tradeoffs with the ε-constraint method: + +- **cost vs. coverage**: sweep a coverage floor as an ε-constraint, then read the marginal cost per shift off the frontier. +- **cost vs. fairness**: sweep the fixed per-worker shift cap, a constraint whose assumed level is a candidate objective. + +A MILP has no constraint duals, so the marginal cost is read off the frontier itself. \ No newline at end of file diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb new file mode 100644 index 0000000..40c3fd2 --- /dev/null +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -0,0 +1,162 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "13784d94", + "metadata": {}, + "source": "# Multi-Objective Workforce Optimization with cuOpt Python API\n\nThis notebook demonstrates how to solve a **multi-objective** workforce optimization problem using the cuOpt Python API \u2014 exploring the tradeoffs among labor cost, coverage, and fairness instead of returning a single plan.\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 one plan, the cheapest way to fully staff. But a planner usually faces a **tradeoff with no fixed weighting**: *how much coverage is worth how much cost?* and *how much does fairness cost?* A single solve hides that \u2014 you get one point on a curve you can't see.\n\nWe turn that single solve into the **whole tradeoff curve** with the **\u03b5-constraint method**:\n\n1. **Recognize a hidden objective** \u2014 a hard constraint whose level was *assumed* rather than given is a candidate objective.\n2. **Anchor** each objective's range (solve each on its own).\n3. **Sweep** the promoted constraint as a parametric bound; each solve is one frontier point.\n4. **Filter** dominated points and **read** the frontier \u2014 quote the exchange rate (extra $ per extra shift).\n\nWe run it twice, promoting a different \"fixed\" constraint each time:\n- **cost vs. coverage** \u2014 relax `coverage == required` and sweep a coverage floor.\n- **cost vs. fairness** \u2014 sweep the base model's fixed `max_shifts` cap.\n\n(This workflow is also packaged as the `cuopt-multi-objective-exploration` skill.)" + }, + { + "cell_type": "markdown", + "id": "0f11c9f4", + "metadata": {}, + "source": "## Environment Setup" + }, + { + "cell_type": "code", + "id": "034b6553", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import subprocess\nimport html\nfrom IPython.display import display, HTML\n\ndef check_gpu():\n try:\n result = subprocess.run([\"nvidia-smi\"], capture_output=True, text=True, timeout=5)\n result.check_returncode()\n lines = result.stdout.splitlines()\n gpu_info = lines[2] if len(lines) > 2 else \"GPU detected\"\n gpu_info_escaped = html.escape(gpu_info)\n display(HTML(f\"\"\"\n
\n

\u2705 GPU is enabled

\n
{gpu_info_escaped}
\n
\n \"\"\"))\n return True\n except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e:\n display(HTML(\"\"\"\n
\n

\u26a0\ufe0f GPU not detected!

\n

This notebook requires a GPU runtime.

\n\n

If running in Google Colab:

\n
    \n
  1. Click on Runtime \u2192 Change runtime type
  2. \n
  3. Set Hardware accelerator to GPU
  4. \n
  5. Then click Save and Runtime \u2192 Restart runtime.
  6. \n
\n\n

If running in Docker:

\n
    \n
  1. Ensure you have NVIDIA Docker runtime installed (nvidia-docker2)
  2. \n
  3. Run container with GPU support: docker run --gpus all ...
  4. \n
  5. Or use: docker run --runtime=nvidia ... for older Docker versions
  6. \n
  7. Verify GPU access: docker run --gpus all nvidia/cuda:12.0.0-base-ubuntu22.04 nvidia-smi
  8. \n
\n\n

Additional resources:

\n \n
\n \"\"\"))\n return False\n\ncheck_gpu()" + }, + { + "cell_type": "code", + "id": "69045ade", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Uncomment for your CUDA version if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12 # CUDA 12\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu13 # CUDA 13" + }, + { + "cell_type": "code", + "id": "a16a40c8", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import numpy as np\nimport matplotlib.pyplot as plt\nfrom cuopt.linear_programming.problem import Problem, VType, sense, LinearExpression\nfrom cuopt.linear_programming.solver_settings import SolverSettings\nprint(\"Imports ready\")" + }, + { + "cell_type": "markdown", + "id": "fc8c9738", + "metadata": {}, + "source": "## Problem data\n\nSame workers, shifts, pay, and availability as the base `workforce_optimization_milp` notebook." + }, + { + "cell_type": "code", + "id": "46a485a3", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "shift_requirements = {\n \"Mon1\": 3, \"Tue2\": 2, \"Wed3\": 4, \"Thu4\": 2, \"Fri5\": 5, \"Sat6\": 3, \"Sun7\": 4,\n \"Mon8\": 2, \"Tue9\": 2, \"Wed10\": 3, \"Thu11\": 4, \"Fri12\": 5, \"Sat13\": 7, \"Sun14\": 5,\n}\nworker_pay = {\"Amy\": 10, \"Bob\": 12, \"Cathy\": 10, \"Dan\": 8, \"Ed\": 8, \"Fred\": 9, \"Gu\": 11}\navailability = {\n \"Amy\": [\"Tue2\",\"Wed3\",\"Fri5\",\"Sun7\",\"Tue9\",\"Wed10\",\"Thu11\",\"Fri12\",\"Sat13\",\"Sun14\"],\n \"Bob\": [\"Mon1\",\"Tue2\",\"Fri5\",\"Sat6\",\"Mon8\",\"Thu11\",\"Sat13\",\"Sun14\"],\n \"Cathy\": [\"Wed3\",\"Thu4\",\"Fri5\",\"Sun7\",\"Mon8\",\"Tue9\",\"Wed10\",\"Thu11\",\"Fri12\",\"Sat13\",\"Sun14\"],\n \"Dan\": [\"Tue2\",\"Wed3\",\"Fri5\",\"Sat6\",\"Mon8\",\"Tue9\",\"Wed10\",\"Thu11\",\"Fri12\",\"Sat13\",\"Sun14\"],\n \"Ed\": [\"Mon1\",\"Tue2\",\"Wed3\",\"Thu4\",\"Fri5\",\"Sun7\",\"Mon8\",\"Tue9\",\"Thu11\",\"Sat13\",\"Sun14\"],\n \"Fred\": [\"Mon1\",\"Tue2\",\"Wed3\",\"Sat6\",\"Mon8\",\"Tue9\",\"Fri12\",\"Sat13\",\"Sun14\"],\n \"Gu\": [\"Mon1\",\"Tue2\",\"Wed3\",\"Fri5\",\"Sat6\",\"Sun7\",\"Mon8\",\"Tue9\",\"Wed10\",\"Thu11\",\"Fri12\",\"Sat13\",\"Sun14\"],\n}\npairs = [(w, s) for w, shifts in availability.items() for s in shifts]\nTOTAL_REQUIRED = sum(shift_requirements.values())\nprint(f\"{len(worker_pay)} workers, {len(shift_requirements)} shifts, {len(pairs)} feasible (worker,shift) pairs\")\nprint(f\"Full coverage = {TOTAL_REQUIRED} staffed shifts\")" + }, + { + "cell_type": "markdown", + "id": "a10367b4", + "metadata": {}, + "source": "## A solver helper (one model, used for every point)\n\nBinary `x[w,s]` for each available pair; `assigned[s] \u2264 required[s]` (no overstaffing, which keeps *coverage* a clean linear count). The objective is labor cost; an optional **coverage floor** is the parametric \u03b5-constraint we'll sweep. A `time_limit` bounds every MILP solve." + }, + { + "cell_type": "code", + "id": "202935ab", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def solve(coverage_floor=None, maximize_coverage=False, time_limit=10.0):\n \"\"\"Solve one workforce MILP and return its cost, coverage, and status.\n\n Minimizes labor cost over the binary (worker, shift) assignment model (no\n overstaffing) -- or, with maximize_coverage, maximizes shifts staffed to anchor\n the sweep. An optional coverage_floor adds the epsilon-constraint\n coverage >= coverage_floor, swept to trace the cost-vs-coverage frontier.\n\n Parameters\n ----------\n coverage_floor : int, optional\n Minimum shifts that must be staffed (the swept epsilon-constraint); None = no floor.\n maximize_coverage : bool, default False\n Maximize coverage instead of minimizing cost (anchors the sweep range).\n time_limit : float, default 10.0\n Per-solve time limit in seconds, guarding branch-and-bound.\n\n Returns\n -------\n dict or None\n {\"cost\", \"coverage\", \"status\"} for an Optimal/FeasibleFound solve, else None.\n \"\"\"\n prob = Problem(\"workforce\")\n x = {p: prob.addVariable(name=f\"{p[0]}_{p[1]}\", vtype=VType.INTEGER, lb=0.0, ub=1.0) for p in pairs}\n obj = LinearExpression([], [], 0.0)\n for (w, s), var in x.items():\n coef = (-1.0) if maximize_coverage else float(worker_pay[w]) # maximize coverage = minimize -sum(x)\n if coef != 0:\n obj += var * coef\n prob.setObjective(obj, sense.MINIMIZE)\n for s, req in shift_requirements.items(): # no overstaffing\n e = LinearExpression([], [], 0.0); has = False\n for (w, s2), var in x.items():\n if s2 == s:\n e += var; has = True\n if has:\n prob.addConstraint(e <= req, name=f\"cap_{s}\")\n if coverage_floor is not None: # epsilon-constraint\n cov = LinearExpression([], [], 0.0)\n for var in x.values():\n cov += var\n prob.addConstraint(cov >= float(coverage_floor), name=\"coverage_floor\")\n settings = SolverSettings()\n settings.set_parameter(\"time_limit\", float(time_limit))\n settings.set_parameter(\"log_to_console\", False)\n prob.solve(settings)\n if prob.Status.name not in (\"Optimal\", \"FeasibleFound\"):\n return None\n sel = [(w, s) for (w, s), var in x.items() if var.getValue() > 0.5]\n return {\"cost\": sum(worker_pay[w] for (w, s) in sel), \"coverage\": len(sel), \"status\": prob.Status.name}" + }, + { + "cell_type": "markdown", + "id": "33bea1f0", + "metadata": {}, + "source": "## One objective \u2192 one plan (the base model)\n\nThe base notebook minimizes cost at **full** coverage. That's a single point: the cheapest way to staff everything." + }, + { + "cell_type": "code", + "id": "6857ad4b", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "base = solve(coverage_floor=TOTAL_REQUIRED)\nprint(f\"Cheapest full-coverage plan: cover {base['coverage']}/{TOTAL_REQUIRED} shifts at ${base['cost']} ({base['status']})\")\nprint(\"That's one point. Is full coverage worth its cost vs. covering a little less? One solve can't say.\")" + }, + { + "cell_type": "markdown", + "id": "901b67c6", + "metadata": {}, + "source": "## Two objectives, no fixed weighting \u2192 trace the frontier\n\n**Anchor** the objectives (coverage ranges 0\u2026max; cost 0\u2026full-coverage cost), then **\u03b5-constraint sweep** \u2014 minimize cost subject to `coverage \u2265 \u03b5`, for \u03b5 across the range \u2014 and **filter** to the non-dominated set." + }, + { + "cell_type": "code", + "id": "80849732", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "cov_max = solve(maximize_coverage=True)[\"coverage\"]\npoints, non_optimal = [], 0\nfor eps in range(0, cov_max + 1):\n r = solve(coverage_floor=eps)\n if r:\n points.append((r[\"coverage\"], r[\"cost\"]))\n if r[\"status\"] != \"Optimal\": # solved only to the gap within the time limit\n non_optimal += 1\n\ndef non_dominated(pts): # maximize coverage, minimize cost\n return sorted({(c, k) for (c, k) in pts\n if not any((c2 >= c and k2 <= k and (c2 > c or k2 < k)) for (c2, k2) in pts)})\n\nfrontier = non_dominated(points)\nprint(f\"Max achievable coverage: {cov_max}/{TOTAL_REQUIRED} | frontier points: {len(frontier)}\")\nprint(f\"Swept solves: {len(points)} | not certified-Optimal (FeasibleFound): {non_optimal}\")" + }, + { + "cell_type": "code", + "id": "8fd668c5", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "fr = np.array(frontier)\nfig, ax = plt.subplots(figsize=(8, 5.5))\nax.plot(fr[:, 0], fr[:, 1], \"o-\", color=\"navy\", lw=1.6, label=f\"cost-vs-coverage frontier ({len(frontier)} options)\")\nax.scatter([base[\"coverage\"]], [base[\"cost\"]], s=240, marker=\"*\", color=\"crimson\", zorder=5,\n label=\"base model: one full-coverage plan\")\nax.set_xlabel(\"Coverage (shifts staffed)\"); ax.set_ylabel(\"Labor cost ($)\")\nax.set_title(\"One solve is one point; the frontier is the whole decision\")\nax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()" + }, + { + "cell_type": "markdown", + "id": "1d5b3af8", + "metadata": {}, + "source": "## Read the frontier \u2014 this is the value\n\nThe interpretation step: quote the **exchange rate** (extra $ per extra shift covered) between adjacent points, so the planner can decide *where on the curve* to sit. No single \"best\" \u2014 it's a choice the frontier makes visible." + }, + { + "cell_type": "code", + "id": "53284822", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "print(\"Marginal cost of coverage along the frontier:\")\nfor i in range(1, len(frontier)):\n dcov = frontier[i][0] - frontier[i-1][0]\n dcost = frontier[i][1] - frontier[i-1][1]\n if dcov:\n print(f\" coverage {frontier[i-1][0]:2d} -> {frontier[i][0]:2d}: +${dcost} for +{dcov} shift (${dcost/dcov:.0f}/shift)\")\nprint(\"\\nThe single solve only ever showed the right-most point. The frontier shows the price of every coverage level.\")" + }, + { + "cell_type": "markdown", + "id": "ec45ab69", + "metadata": {}, + "source": "**Method note.** We use the default, **\u03b5-constraint** (minimize one objective, sweep the others as bounds): it enumerates every efficient point and stays correct when the frontier is non-convex. A weighted-sum sweep would agree on the supported points of this (convex) frontier, but on non-convex problems \u2014 common in combinatorial MILPs \u2014 it can skip efficient points entirely, which is why \u03b5-constraint is the default." + }, + { + "cell_type": "markdown", + "id": "79c719ac", + "metadata": {}, + "source": "## A second tradeoff, for free \u2014 cost vs. fairness\n\nThe base model *fixed* `max_shifts_per_worker = 4`. The recognition move \u2014 **a fixed constraint whose assumed level is a candidate objective** \u2014 says: sweep that cap instead of fixing it. A tighter cap spreads work more evenly (fairer) but costs more. Same \u03b5-constraint mechanic, a different tradeoff, no new data." + }, + { + "cell_type": "code", + "id": "d20aff21", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def solve_fairness(max_shifts, time_limit=10.0):\n \"\"\"Solve the full-coverage workforce MILP under a per-worker shift cap.\n\n Sweeping max_shifts promotes the base model's fixed cap to an epsilon-constraint,\n tracing the cost-vs-fairness frontier: a tighter cap spreads work more evenly\n (fairer) but costs more.\n\n Parameters\n ----------\n max_shifts : int\n Maximum shifts any single worker may take (the swept fairness lever).\n time_limit : float, default 10.0\n Per-solve time limit in seconds.\n\n Returns\n -------\n dict or None\n {\"max_shifts\", \"cost\", \"busiest\", \"status\"} if feasible, else None.\n \"\"\"\n prob = Problem(\"workforce_fairness\")\n x = {p: prob.addVariable(name=f\"{p[0]}_{p[1]}\", vtype=VType.INTEGER, lb=0.0, ub=1.0) for p in pairs}\n obj = LinearExpression([], [], 0.0)\n for (w, s), var in x.items():\n if worker_pay[w]:\n obj += var * worker_pay[w]\n prob.setObjective(obj, sense.MINIMIZE)\n for s, req in shift_requirements.items(): # full coverage (hard)\n e = LinearExpression([], [], 0.0); has = False\n for (w, s2), var in x.items():\n if s2 == s:\n e += var; has = True\n if has:\n prob.addConstraint(e == req, name=f\"cover_{s}\")\n for w in worker_pay: # fairness lever: per-worker cap\n e = LinearExpression([], [], 0.0); has = False\n for (w2, s), var in x.items():\n if w2 == w:\n e += var; has = True\n if has:\n prob.addConstraint(e <= float(max_shifts), name=f\"cap_{w}\")\n settings = SolverSettings(); settings.set_parameter(\"time_limit\", float(time_limit)); settings.set_parameter(\"log_to_console\", False)\n prob.solve(settings)\n if prob.Status.name not in (\"Optimal\", \"FeasibleFound\"):\n return None\n sel = [(w, s) for (w, s), var in x.items() if var.getValue() > 0.5]\n busiest = max((sum(1 for (w2, s) in sel if w2 == w) for w in worker_pay), default=0)\n return {\"max_shifts\": max_shifts, \"cost\": sum(worker_pay[w] for (w, s) in sel), \"busiest\": busiest, \"status\": prob.Status.name}\n\nfair, non_optimal = [], 0\nfor cap in range(len(shift_requirements), 0, -1):\n r = solve_fairness(cap)\n print(f\"max_shifts cap {cap:2d}: \" + (f\"full coverage at ${r['cost']}, busiest worker {r['busiest']} shifts\" if r else \"INFEASIBLE (cap too tight to staff every shift)\"))\n if r:\n fair.append((r[\"max_shifts\"], r[\"cost\"]))\n if r[\"status\"] != \"Optimal\":\n non_optimal += 1\nprint(f\"Feasible caps: {len(fair)} | not certified-Optimal (FeasibleFound): {non_optimal}\")\n\nif fair:\n fp = np.array(fair)\n fig, ax = plt.subplots(figsize=(8, 5))\n ax.plot(fp[:, 0], fp[:, 1], \"o-\", color=\"seagreen\", lw=1.6)\n ax.invert_xaxis()\n ax.set_xlabel(\"Max shifts per worker (left = fairer)\"); ax.set_ylabel(\"Labor cost ($) at full coverage\")\n ax.set_title(\"cost vs. fairness: the price of spreading work evenly\")\n ax.grid(alpha=0.3); plt.tight_layout(); plt.show()" + }, + { + "cell_type": "markdown", + "id": "29d59f8d", + "metadata": {}, + "source": "## Notes & takeaway\n\n**Takeaway \u2014 reusing this on your own problem.** When a single-objective model has a hard constraint whose level was *assumed* \u2014 a coverage target, a per-resource cap, a budget \u2014 that constraint is a hidden objective. Promote it to a swept \u03b5-constraint, collect the non-dominated points, and read the exchange rate off the frontier. The same recipe traces any such tradeoff.\n\n- **Synthetic data** \u2014 the base notebook's toy roster; this demonstrates the *method*, not a staffing study.\n- **Optimal to the gap** \u2014 each point is solved under a `time_limit`; every solve here returned `Optimal` (0 `FeasibleFound`), so each is optimal to cuOpt's MIP gap (exact here, since labor cost is integer-valued).\n- **No duals for a MILP** \u2014 an integer program has no constraint duals, so the marginal cost of coverage is read off the frontier itself (above)." + }, + { + "cell_type": "markdown", + "id": "cdf08b7f", + "metadata": {}, + "source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License." + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file