From e8ce93482dcfcd901d57e4d0ab06637695a4cac7 Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 10:43:11 -0700 Subject: [PATCH 01/15] Add multi-objective Pareto-frontier examples (workforce MILP, portfolio QP duals) Signed-off-by: cafzal --- .../QP_portfolio_frontier_duals.ipynb | 95 +++++++++++ ...orkforce_optimization_multiobjective.ipynb | 150 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 portfolio_optimization/QP_portfolio_frontier_duals.ipynb create mode 100644 workforce_optimization/workforce_optimization_multiobjective.ipynb diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb new file mode 100644 index 0000000..604c010 --- /dev/null +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Portfolio Optimization \u2014 the Frontier via the Skill, + Shadow Prices (cuOpt QP)\n\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/cuopt-examples/blob/main/portfolio_optimization/QP_portfolio_frontier_duals.ipynb)\n\nThe base `QP_portfolio_optimization` notebook **hand-codes** an efficient-frontier sweep (a manual loop over target returns). This sibling shows that following the `cuopt-multi-objective-exploration` skill **recreates that frontier as a named, systematic workflow** \u2014 anchor each objective \u2192 \u03b5-constraint sweep (the return floor is the parametric bound) \u2192 filter dominated \u2192 read the frontier \u2014 with less ad-hoc scaffolding, and **adds the one thing the manual sweep omits**: the return-constraint **dual** (shadow price d(variance)/d(return)).\n\nSo the two examples are complementary tests of the skill: here it **reproduces** an existing frontier (return vs risk) with less manual work and surfaces the duals; the workforce MILP (`workforce_optimization/workforce_optimization_multiobjective.ipynb`) is the **net-new** case \u2014 and the deliberate contrast is that **a QP has constraint duals, an integer program does not.**\n\n> **Requirements.** cuOpt needs **Linux + an NVIDIA GPU** (Colab: *Runtime \u2192 Change runtime type \u2192 GPU*)." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Environment Setup" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import subprocess\ntry:\n out = subprocess.run([\"nvidia-smi\", \"--query-gpu=name,memory.total\", \"--format=csv,noheader\"],\n capture_output=True, text=True)\n print(out.stdout.strip() or \"no nvidia-smi output\")\nexcept FileNotFoundError:\n print(\"No NVIDIA GPU detected - cuOpt cannot run. In Colab: Runtime -> Change runtime type -> GPU.\")" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Uncomment if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" + }, + { + "cell_type": "code", + "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", + "metadata": {}, + "source": "## Data\n\nSame simulated asset universe as `QP_portfolio_optimization` (annualized mean returns + covariance)." + }, + { + "cell_type": "code", + "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", + "metadata": {}, + "source": "## Min-variance QP with the return-constraint dual\n\nThis is the base notebook's `solve_min_variance_qp`, with one addition: we keep a handle on the `min_return` constraint and read its **`.DualValue`** after the solve. For the QP, that dual is the shadow price d(variance)/d(return)." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=None, max_weight=None):\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 # shadow price 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", + "metadata": {}, + "source": "## Sweep the return target \u2192 frontier + shadow price\n\nThe \u03b5-constraint sweep (return floor as the parametric bound), capturing the dual at each point. The skill's note applies: cuOpt's QP beta is PDLP (a first-order method), so the dual is accurate **to the solver's tolerance** \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." + }, + { + "cell_type": "code", + "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\"Shadow price d(variance)/d(return): {duals.min():.3f} -> {duals.max():.3f} as required return rises\")" + }, + { + "cell_type": "code", + "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(\"Shadow price 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", + "metadata": {}, + "source": "## Reading it\n\n- The **frontier** (left) is the return-vs-risk Pareto set \u2014 every point is a min-variance portfolio for its return floor.\n- The **dual** (right) is the *exchange rate* the skill asks you to report: how much variance you take on per extra unit of return. It rises along the frontier \u2014 the marginal cost of return gets steeper, which is exactly where a knee analysis pays off.\n\n### Notes (honest)\n- **Synthetic data** \u2014 the base notebook's simulated universe; demonstrates the method.\n- **PDLP / first-order** \u2014 cuOpt's QP beta is a first-order solver, so the dual is optimal **to its convergence tolerance**, not exact arithmetic; points reported `PrimalFeasible` rather than `Optimal` are flagged above and could be tightened or dropped.\n- **Continuous only** \u2014 these duals exist because the portfolio is a QP. The integer workforce model (`workforce_optimization_multiobjective.ipynb`) has **no constraint duals**; there you read the marginal cost off the frontier itself.\n\nThis adds the duals/interpretation step of the `cuopt-multi-objective-exploration` skill to cuOpt's existing portfolio frontier." + } + ], + "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/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb new file mode 100644 index 0000000..dfdacf6 --- /dev/null +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -0,0 +1,150 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Workforce Optimization \u2014 Multi-Objective with cuOpt\n\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/cuopt-examples/blob/main/workforce_optimization/workforce_optimization_multiobjective.ipynb)\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with everything else **hard-constrained** \u2014 one objective, one plan. Real staffing weighs several conflicting considerations with **no fixed weighting**: **labor cost**, **coverage / service level**, **fairness** (workload balance), and \u2014 with extra data \u2014 **overtime cost** and **worker preferences**.\n\nThe key move of the `cuopt-multi-objective-exploration` skill: **any hard constraint is a candidate objective.** Promote one to a *parametric* constraint and sweep it to trace the tradeoff (the \u03b5-constraint method). The base model's two hard constraints are exactly such candidates:\n\n- coverage `\u03a3 x[\u00b7,s] == required[s]` \u2192 relax to an **objective** \u21d2 **cost vs. coverage**\n- `\u03a3 x[w,\u00b7] \u2264 max_shifts` \u2192 **sweep the cap** \u21d2 **cost vs. fairness** (turning a constraint into an objective, with no new data)\n\nThis notebook battle-tests the skill on a net-new multi-objective MILP: both tradeoffs above, the \u03b5-constraint-vs-weighted-sum **gotcha**, a `time_limit` on every solve, and the honest **\"no duals for a MILP\"** note. Overtime and preferences are further candidate objectives (they need overtime-rate / preference data) and are left as extensions.\n\n> **Requirements.** cuOpt needs **Linux + an NVIDIA GPU** (Colab: *Runtime \u2192 Change runtime type \u2192 GPU*)." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Environment Setup" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "import subprocess\ntry:\n out = subprocess.run([\"nvidia-smi\", \"--query-gpu=name,memory.total\", \"--format=csv,noheader\"],\n capture_output=True, text=True)\n print(out.stdout.strip() or \"no nvidia-smi output\")\nexcept FileNotFoundError:\n print(\"No NVIDIA GPU detected - cuOpt cannot run. In Colab: Runtime -> Change runtime type -> GPU.\")" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Uncomment if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" + }, + { + "cell_type": "code", + "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", + "metadata": {}, + "source": "## Problem Data\n\nSame workers, shifts, pay, and availability as the base `workforce_optimization_milp` notebook." + }, + { + "cell_type": "code", + "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\"Total required coverage (all shifts fully staffed): {TOTAL_REQUIRED}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Two objectives, no fixed priority \u2014 the Pareto pattern\n\n- **Minimize** total labor cost = \u03a3 pay[w]\u00b7x[w,s]\n- **Maximize** coverage = \u03a3 x[w,s] (with `assigned[s] \u2264 required[s]` \u2014 no overstaffing, which keeps coverage linear)\n\nThese conflict (more coverage costs more) and there's no agreed weighting, so we trace the frontier instead of committing to one. Each point is one cuOpt MILP solve. The helper below builds the model once and supports either an **\u03b5-constraint** (a coverage floor) or a **weighted-sum** objective, with a `time_limit` per solve (the skill's practical note: bound each MILP solve)." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def solve_workforce(coverage_floor=None, weight_lambda=None, time_limit=10.0):\n \"\"\"Build + solve one workforce MILP point.\n - coverage_floor: if set, add constraint (total coverage >= floor) -> epsilon-constraint\n - weight_lambda: if set, objective = cost - lambda*coverage -> weighted-sum\n - default (both None): minimize cost only.\n Returns dict(cost, coverage, status).\n \"\"\"\n prob = Problem(\"workforce_mo\")\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\n # Objective\n obj = LinearExpression([], [], 0.0)\n for (w, s), var in x.items():\n coef = worker_pay[w] - (weight_lambda if weight_lambda is not None else 0.0)\n if coef != 0:\n obj += var * coef\n prob.setObjective(obj, sense.MINIMIZE)\n\n # No overstaffing: assigned[s] <= required[s]\n for s, req in shift_requirements.items():\n e = LinearExpression([], [], 0.0)\n 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\n # epsilon-constraint: total coverage floor\n if coverage_floor is not None:\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\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\n status = prob.Status.name\n if status not in (\"Optimal\", \"FeasibleFound\"):\n return {\"cost\": None, \"coverage\": None, \"status\": status}\n sel = [(w, s) for (w, s), var in x.items() if var.getValue() > 0.5]\n cost = sum(worker_pay[w] for (w, s) in sel)\n coverage = len(sel)\n return {\"cost\": cost, \"coverage\": coverage, \"status\": status}" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### Step 1 \u2014 anchor the objectives (payoff table)\n\nThe skill's first step: solve each objective alone to get the achievable ranges. Minimum cost is trivially 0 (assign no one). Maximum coverage is what the workforce can actually staff given availability and the per-shift caps \u2014 found by maximizing coverage (i.e. minimizing \u2212coverage via a large \u03bb)." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Max achievable coverage: large lambda makes every (pay - lambda) negative, so the solver covers all it can.\nanchor = solve_workforce(weight_lambda=max(worker_pay.values()) + 1.0)\nCOVERAGE_MAX = anchor[\"coverage\"]\nprint(f\"Max achievable coverage: {COVERAGE_MAX} of {TOTAL_REQUIRED} required (cost at full coverage: ${anchor['cost']})\")\nprint(f\"Coverage ranges over [0, {COVERAGE_MAX}]; cost over [0, {anchor['cost']}].\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### Steps 2\u20133 \u2014 \u03b5-constraint sweep, then filter dominated\n\nMinimize cost subject to `coverage \u2265 \u03b5`, sweeping \u03b5 across the coverage range. This is the skill's preferred method for MILP because it reaches the **whole** frontier, including unsupported points." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "eps_grid = list(range(0, COVERAGE_MAX + 1)) # coverage floors 0..max\neps_points = []\nfor eps in eps_grid:\n r = solve_workforce(coverage_floor=eps)\n if r[\"status\"] in (\"Optimal\", \"FeasibleFound\") and r[\"cost\"] is not None:\n eps_points.append((r[\"coverage\"], r[\"cost\"]))\n\ndef non_dominated(points):\n \"\"\"points = list of (coverage, cost); maximize coverage, minimize cost.\"\"\"\n out = []\n for (cov, cost) in points:\n if not any((c2 >= cov and k2 <= cost and (c2 > cov or k2 < cost)) for (c2, k2) in points):\n out.append((cov, cost))\n return sorted(set(out))\n\nfrontier = non_dominated(eps_points)\nprint(f\"epsilon-constraint solves: {len(eps_points)} | non-dominated frontier points: {len(frontier)}\")\nfor cov, cost in frontier:\n print(f\" coverage {cov:2d}/{TOTAL_REQUIRED} -> min cost ${cost}\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### Weighted-sum, and the gotcha\n\nSweep \u03bb in `minimize \u03a3(pay \u2212 \u03bb)\u00b7x`. Each \u03bb implicitly weights cost against coverage. The skill's warning: on a MILP, weighted-sum only returns **supported** (convex-hull) points \u2014 it cannot produce efficient points sitting in a non-convex dent, no matter the weight. We compute both and compare which efficient points each method recovers." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "lam_grid = np.linspace(0.0, max(worker_pay.values()) + 1.0, 40)\nws_points = []\nfor lam in lam_grid:\n r = solve_workforce(weight_lambda=float(lam))\n if r[\"status\"] in (\"Optimal\", \"FeasibleFound\") and r[\"cost\"] is not None:\n ws_points.append((r[\"coverage\"], r[\"cost\"]))\n\nws_frontier = non_dominated(ws_points)\nfrontier_set = set(frontier)\nws_set = set(ws_frontier)\nmissed_by_ws = sorted(frontier_set - ws_set) # efficient points epsilon-constraint found but weighted-sum did not\nprint(f\"weighted-sum distinct non-dominated points: {len(ws_set)}\")\nprint(f\"epsilon-constraint non-dominated points: {len(frontier_set)}\")\nprint(f\"Efficient points weighted-sum MISSED (unsupported): {len(missed_by_ws)}\")\nfor cov, cost in missed_by_ws:\n print(f\" coverage {cov}/{TOTAL_REQUIRED}, cost ${cost} - reachable by epsilon-constraint, not by any weight\")\nif not missed_by_ws:\n print(\" (none on this small instance - see notes: the gap is problem-dependent; the method guarantees completeness regardless)\")" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "fig, ax = plt.subplots(figsize=(8, 5.5))\nif eps_points:\n ec = np.array(sorted(frontier))\n ax.plot(ec[:, 0], ec[:, 1], \"o-\", color=\"navy\", lw=1.6, label=f\"epsilon-constraint frontier ({len(frontier)})\")\nif ws_frontier:\n wc = np.array(sorted(ws_frontier))\n ax.scatter(wc[:, 0], wc[:, 1], s=90, facecolor=\"none\", edgecolor=\"darkorange\",\n linewidth=1.8, zorder=3, label=f\"weighted-sum points ({len(ws_frontier)})\")\nif missed_by_ws:\n mc = np.array(missed_by_ws)\n ax.scatter(mc[:, 0], mc[:, 1], s=160, marker=\"x\", color=\"crimson\", zorder=4,\n label=f\"missed by weighted-sum ({len(missed_by_ws)})\")\nax.set_xlabel(\"Coverage (shifts staffed)\"); ax.set_ylabel(\"Labor cost ($)\")\nax.set_title(\"Workforce: cost vs coverage Pareto frontier (cuOpt MILP)\")\nax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### Step 4 \u2014 read the frontier\n\nThe skill's interpretation step. Quote the **exchange rate** ($ per extra shift covered) between adjacent points, flag the **knee**, and leave the choice to the planner \u2014 don't collapse the frontier to one \"best\" plan." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "fr = sorted(frontier)\nprint(\"Marginal cost of coverage along the frontier:\")\nfor i in range(1, len(fr)):\n dcov = fr[i][0] - fr[i-1][0]\n dcost = fr[i][1] - fr[i-1][1]\n rate = (dcost / dcov) if dcov else float(\"nan\")\n print(f\" coverage {fr[i-1][0]:2d} -> {fr[i][0]:2d}: +${dcost:>3} for +{dcov} shift(s) (~${rate:.1f}/shift)\")\nprint(\"\\nThe planner picks the coverage level worth its marginal cost. No single 'best' - it's a choice.\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Second tradeoff: cost vs. fairness (promote the max-shifts constraint)\n\nThe base notebook *fixed* `max_shifts_per_worker = 4` \u2014 a hard constraint. The skill says: that cap is a candidate objective. Keep **full coverage** hard, then **sweep the cap**: a tighter cap spreads work more evenly (fairer) but costs more (you need more, or pricier, workers). Sweeping the cap turns the constraint into the fairness axis \u2014 the same \u03b5-constraint mechanic, a structurally different tradeoff." + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "def solve_workforce_fairness(max_shifts, time_limit=10.0):\n \"\"\"Full coverage (hard) + per-worker cap <= max_shifts; minimize cost. Sweeping max_shifts\n turns the base notebook's fixed 'max 4 shifts' constraint into a fairness objective.\"\"\"\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] != 0:\n obj += var * worker_pay[w]\n prob.setObjective(obj, sense.MINIMIZE)\n # Full coverage (hard, == required)\n for s, req in shift_requirements.items():\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 # Fairness lever: each worker works at most max_shifts\n for w in worker_pay:\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\"maxshifts_{w}\")\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 st = prob.Status.name\n if st not in (\"Optimal\", \"FeasibleFound\"):\n return {\"max_shifts\": max_shifts, \"cost\": None, \"status\": st}\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),\n \"busiest\": busiest, \"status\": st}" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "# Sweep the per-worker cap from loose (cheap) to tight (fair). Tighter -> fairer but costlier; too tight -> infeasible.\ncap_grid = list(range(len(shift_requirements), 0, -1)) # 14 down to 1\nfair_pts = []\nfor cap in cap_grid:\n r = solve_workforce_fairness(cap)\n if r[\"cost\"] is not None:\n fair_pts.append((r[\"max_shifts\"], r[\"cost\"], r[\"busiest\"]))\n tag = \"\"\n else:\n tag = f\" (infeasible at cap={cap}: cannot fully cover with everyone capped this low)\"\n print(f\"max_shifts cap {cap:2d}: \" + (f\"min cost ${r['cost']}, busiest worker {r['busiest']} shifts\" if r['cost'] is not None else f\"INFEASIBLE\") + tag)\n\nfig, ax = plt.subplots(figsize=(8, 5))\nif fair_pts:\n fp = np.array([(c, k) for (c, k, b) in fair_pts])\n ax.plot(fp[:, 0], fp[:, 1], \"o-\", color=\"seagreen\", lw=1.6)\n ax.invert_xaxis() # left = fairer (tighter cap)\nax.set_xlabel(\"Max shifts allowed per worker (left = fairer)\")\nax.set_ylabel(\"Labor cost ($) at full coverage\")\nax.set_title(\"Workforce: cost vs fairness (sweeping the max-shifts constraint)\")\nax.grid(alpha=0.3); plt.tight_layout(); plt.show()\nprint(\"\\nThe planner reads the price of fairness: each step tighter on the cap costs $X more at full coverage.\")" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## Notes (honest)\n\n- **Synthetic data** \u2014 the same toy roster as the base notebook; this demonstrates the *method*, not a real staffing study.\n- **Optimal to the gap, within the time limit** \u2014 each point is solved by cuOpt's MILP solver under a `time_limit`; points are optimal to the solver's gap, not certified global optima unless it returns `Optimal` at a zero gap.\n- **\u03b5-constraint vs weighted-sum** \u2014 \u03b5-constraint reaches the complete frontier by construction; weighted-sum only returns supported (convex-hull) points. Whether *this* small instance actually exhibits unsupported points is empirical (the cell above reports it); the completeness guarantee holds regardless of instance.\n- **No duals for a MILP** \u2014 unlike the continuous portfolio QP (see `portfolio_optimization/`), an integer program has no constraint duals/shadow prices; read the marginal cost of coverage from the frontier itself, as above.\n\nThis notebook reproduces the `cuopt-multi-objective-exploration` skill end-to-end on cuOpt's own workforce MILP." + } + ], + "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 From e18920b5801c99d71f91e860b427195b6e93bafa Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 11:00:48 -0700 Subject: [PATCH 02/15] Refocus workforce example on the multi-objective value (drop weighted-sum gotcha demo) Signed-off-by: cafzal --- ...orkforce_optimization_multiobjective.ipynb | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index dfdacf6..795d79d 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -3,7 +3,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Workforce Optimization \u2014 Multi-Objective with cuOpt\n\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/cuopt-examples/blob/main/workforce_optimization/workforce_optimization_multiobjective.ipynb)\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with everything else **hard-constrained** \u2014 one objective, one plan. Real staffing weighs several conflicting considerations with **no fixed weighting**: **labor cost**, **coverage / service level**, **fairness** (workload balance), and \u2014 with extra data \u2014 **overtime cost** and **worker preferences**.\n\nThe key move of the `cuopt-multi-objective-exploration` skill: **any hard constraint is a candidate objective.** Promote one to a *parametric* constraint and sweep it to trace the tradeoff (the \u03b5-constraint method). The base model's two hard constraints are exactly such candidates:\n\n- coverage `\u03a3 x[\u00b7,s] == required[s]` \u2192 relax to an **objective** \u21d2 **cost vs. coverage**\n- `\u03a3 x[w,\u00b7] \u2264 max_shifts` \u2192 **sweep the cap** \u21d2 **cost vs. fairness** (turning a constraint into an objective, with no new data)\n\nThis notebook battle-tests the skill on a net-new multi-objective MILP: both tradeoffs above, the \u03b5-constraint-vs-weighted-sum **gotcha**, a `time_limit` on every solve, and the honest **\"no duals for a MILP\"** note. Overtime and preferences are further candidate objectives (they need overtime-rate / preference data) and are left as extensions.\n\n> **Requirements.** cuOpt needs **Linux + an NVIDIA GPU** (Colab: *Runtime \u2192 Change runtime type \u2192 GPU*)." + "source": "# Workforce Optimization \u2014 Multi-Objective with cuOpt\n\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/cuopt-examples/blob/main/workforce_optimization/workforce_optimization_multiobjective.ipynb)\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 it returns **one plan**. But that plan answers only *\"cheapest way to fully staff.\"* 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; you get one point on a curve you can't see.\n\nThis notebook follows the `cuopt-multi-objective-exploration` skill to turn the single solve into the **whole tradeoff curve**, so the planner can see the options and choose. Two tradeoffs, both built by promoting one of the base model's hard constraints into an objective:\n\n1. **cost vs. coverage** \u2014 relax `coverage == required` and sweep a coverage floor.\n2. **cost vs. fairness** \u2014 sweep the base model's fixed `max_shifts` cap.\n\n> **Requirements.** cuOpt needs **Linux + an NVIDIA GPU** (Colab: *Runtime \u2192 Change runtime type \u2192 GPU*)." }, { "cell_type": "markdown", @@ -34,105 +34,91 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## Problem Data\n\nSame workers, shifts, pay, and availability as the base `workforce_optimization_milp` notebook." + "source": "## Problem data\n\nSame workers, shifts, pay, and availability as the base `workforce_optimization_milp` notebook." }, { "cell_type": "code", "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\"Total required coverage (all shifts fully staffed): {TOTAL_REQUIRED}\")" + "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", "metadata": {}, - "source": "## Two objectives, no fixed priority \u2014 the Pareto pattern\n\n- **Minimize** total labor cost = \u03a3 pay[w]\u00b7x[w,s]\n- **Maximize** coverage = \u03a3 x[w,s] (with `assigned[s] \u2264 required[s]` \u2014 no overstaffing, which keeps coverage linear)\n\nThese conflict (more coverage costs more) and there's no agreed weighting, so we trace the frontier instead of committing to one. Each point is one cuOpt MILP solve. The helper below builds the model once and supports either an **\u03b5-constraint** (a coverage floor) or a **weighted-sum** objective, with a `time_limit` per solve (the skill's practical note: bound each MILP solve)." + "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 (the skill's practical note)." }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], - "source": "def solve_workforce(coverage_floor=None, weight_lambda=None, time_limit=10.0):\n \"\"\"Build + solve one workforce MILP point.\n - coverage_floor: if set, add constraint (total coverage >= floor) -> epsilon-constraint\n - weight_lambda: if set, objective = cost - lambda*coverage -> weighted-sum\n - default (both None): minimize cost only.\n Returns dict(cost, coverage, status).\n \"\"\"\n prob = Problem(\"workforce_mo\")\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\n # Objective\n obj = LinearExpression([], [], 0.0)\n for (w, s), var in x.items():\n coef = worker_pay[w] - (weight_lambda if weight_lambda is not None else 0.0)\n if coef != 0:\n obj += var * coef\n prob.setObjective(obj, sense.MINIMIZE)\n\n # No overstaffing: assigned[s] <= required[s]\n for s, req in shift_requirements.items():\n e = LinearExpression([], [], 0.0)\n 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\n # epsilon-constraint: total coverage floor\n if coverage_floor is not None:\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\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\n status = prob.Status.name\n if status not in (\"Optimal\", \"FeasibleFound\"):\n return {\"cost\": None, \"coverage\": None, \"status\": status}\n sel = [(w, s) for (w, s), var in x.items() if var.getValue() > 0.5]\n cost = sum(worker_pay[w] for (w, s) in sel)\n coverage = len(sel)\n return {\"cost\": cost, \"coverage\": coverage, \"status\": status}" + "source": "def solve(coverage_floor=None, maximize_coverage=False, time_limit=10.0):\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", "metadata": {}, - "source": "### Step 1 \u2014 anchor the objectives (payoff table)\n\nThe skill's first step: solve each objective alone to get the achievable ranges. Minimum cost is trivially 0 (assign no one). Maximum coverage is what the workforce can actually staff given availability and the per-shift caps \u2014 found by maximizing coverage (i.e. minimizing \u2212coverage via a large \u03bb)." + "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", "metadata": {}, "execution_count": null, "outputs": [], - "source": "# Max achievable coverage: large lambda makes every (pay - lambda) negative, so the solver covers all it can.\nanchor = solve_workforce(weight_lambda=max(worker_pay.values()) + 1.0)\nCOVERAGE_MAX = anchor[\"coverage\"]\nprint(f\"Max achievable coverage: {COVERAGE_MAX} of {TOTAL_REQUIRED} required (cost at full coverage: ${anchor['cost']})\")\nprint(f\"Coverage ranges over [0, {COVERAGE_MAX}]; cost over [0, {anchor['cost']}].\")" + "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", "metadata": {}, - "source": "### Steps 2\u20133 \u2014 \u03b5-constraint sweep, then filter dominated\n\nMinimize cost subject to `coverage \u2265 \u03b5`, sweeping \u03b5 across the coverage range. This is the skill's preferred method for MILP because it reaches the **whole** frontier, including unsupported points." + "source": "## Two objectives, no fixed weighting \u2192 trace the frontier\n\nFollowing the skill: **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", "metadata": {}, "execution_count": null, "outputs": [], - "source": "eps_grid = list(range(0, COVERAGE_MAX + 1)) # coverage floors 0..max\neps_points = []\nfor eps in eps_grid:\n r = solve_workforce(coverage_floor=eps)\n if r[\"status\"] in (\"Optimal\", \"FeasibleFound\") and r[\"cost\"] is not None:\n eps_points.append((r[\"coverage\"], r[\"cost\"]))\n\ndef non_dominated(points):\n \"\"\"points = list of (coverage, cost); maximize coverage, minimize cost.\"\"\"\n out = []\n for (cov, cost) in points:\n if not any((c2 >= cov and k2 <= cost and (c2 > cov or k2 < cost)) for (c2, k2) in points):\n out.append((cov, cost))\n return sorted(set(out))\n\nfrontier = non_dominated(eps_points)\nprint(f\"epsilon-constraint solves: {len(eps_points)} | non-dominated frontier points: {len(frontier)}\")\nfor cov, cost in frontier:\n print(f\" coverage {cov:2d}/{TOTAL_REQUIRED} -> min cost ${cost}\")" - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "### Weighted-sum, and the gotcha\n\nSweep \u03bb in `minimize \u03a3(pay \u2212 \u03bb)\u00b7x`. Each \u03bb implicitly weights cost against coverage. The skill's warning: on a MILP, weighted-sum only returns **supported** (convex-hull) points \u2014 it cannot produce efficient points sitting in a non-convex dent, no matter the weight. We compute both and compare which efficient points each method recovers." - }, - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": "lam_grid = np.linspace(0.0, max(worker_pay.values()) + 1.0, 40)\nws_points = []\nfor lam in lam_grid:\n r = solve_workforce(weight_lambda=float(lam))\n if r[\"status\"] in (\"Optimal\", \"FeasibleFound\") and r[\"cost\"] is not None:\n ws_points.append((r[\"coverage\"], r[\"cost\"]))\n\nws_frontier = non_dominated(ws_points)\nfrontier_set = set(frontier)\nws_set = set(ws_frontier)\nmissed_by_ws = sorted(frontier_set - ws_set) # efficient points epsilon-constraint found but weighted-sum did not\nprint(f\"weighted-sum distinct non-dominated points: {len(ws_set)}\")\nprint(f\"epsilon-constraint non-dominated points: {len(frontier_set)}\")\nprint(f\"Efficient points weighted-sum MISSED (unsupported): {len(missed_by_ws)}\")\nfor cov, cost in missed_by_ws:\n print(f\" coverage {cov}/{TOTAL_REQUIRED}, cost ${cost} - reachable by epsilon-constraint, not by any weight\")\nif not missed_by_ws:\n print(\" (none on this small instance - see notes: the gap is problem-dependent; the method guarantees completeness regardless)\")" + "source": "cov_max = solve(maximize_coverage=True)[\"coverage\"]\npoints = []\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\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)}\")" }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], - "source": "fig, ax = plt.subplots(figsize=(8, 5.5))\nif eps_points:\n ec = np.array(sorted(frontier))\n ax.plot(ec[:, 0], ec[:, 1], \"o-\", color=\"navy\", lw=1.6, label=f\"epsilon-constraint frontier ({len(frontier)})\")\nif ws_frontier:\n wc = np.array(sorted(ws_frontier))\n ax.scatter(wc[:, 0], wc[:, 1], s=90, facecolor=\"none\", edgecolor=\"darkorange\",\n linewidth=1.8, zorder=3, label=f\"weighted-sum points ({len(ws_frontier)})\")\nif missed_by_ws:\n mc = np.array(missed_by_ws)\n ax.scatter(mc[:, 0], mc[:, 1], s=160, marker=\"x\", color=\"crimson\", zorder=4,\n label=f\"missed by weighted-sum ({len(missed_by_ws)})\")\nax.set_xlabel(\"Coverage (shifts staffed)\"); ax.set_ylabel(\"Labor cost ($)\")\nax.set_title(\"Workforce: cost vs coverage Pareto frontier (cuOpt MILP)\")\nax.legend(); ax.grid(alpha=0.3); plt.tight_layout(); plt.show()" + "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", "metadata": {}, - "source": "### Step 4 \u2014 read the frontier\n\nThe skill's interpretation step. Quote the **exchange rate** ($ per extra shift covered) between adjacent points, flag the **knee**, and leave the choice to the planner \u2014 don't collapse the frontier to one \"best\" plan." + "source": "## Read the frontier \u2014 this is the value\n\nThe skill's 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", "metadata": {}, "execution_count": null, "outputs": [], - "source": "fr = sorted(frontier)\nprint(\"Marginal cost of coverage along the frontier:\")\nfor i in range(1, len(fr)):\n dcov = fr[i][0] - fr[i-1][0]\n dcost = fr[i][1] - fr[i-1][1]\n rate = (dcost / dcov) if dcov else float(\"nan\")\n print(f\" coverage {fr[i-1][0]:2d} -> {fr[i][0]:2d}: +${dcost:>3} for +{dcov} shift(s) (~${rate:.1f}/shift)\")\nprint(\"\\nThe planner picks the coverage level worth its marginal cost. No single 'best' - it's a choice.\")" + "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", "metadata": {}, - "source": "## Second tradeoff: cost vs. fairness (promote the max-shifts constraint)\n\nThe base notebook *fixed* `max_shifts_per_worker = 4` \u2014 a hard constraint. The skill says: that cap is a candidate objective. Keep **full coverage** hard, then **sweep the cap**: a tighter cap spreads work more evenly (fairer) but costs more (you need more, or pricier, workers). Sweeping the cap turns the constraint into the fairness axis \u2014 the same \u03b5-constraint mechanic, a structurally different tradeoff." + "source": "**Method note.** We use the skill's 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": "code", + "cell_type": "markdown", "metadata": {}, - "execution_count": null, - "outputs": [], - "source": "def solve_workforce_fairness(max_shifts, time_limit=10.0):\n \"\"\"Full coverage (hard) + per-worker cap <= max_shifts; minimize cost. Sweeping max_shifts\n turns the base notebook's fixed 'max 4 shifts' constraint into a fairness objective.\"\"\"\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] != 0:\n obj += var * worker_pay[w]\n prob.setObjective(obj, sense.MINIMIZE)\n # Full coverage (hard, == required)\n for s, req in shift_requirements.items():\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 # Fairness lever: each worker works at most max_shifts\n for w in worker_pay:\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\"maxshifts_{w}\")\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 st = prob.Status.name\n if st not in (\"Optimal\", \"FeasibleFound\"):\n return {\"max_shifts\": max_shifts, \"cost\": None, \"status\": st}\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),\n \"busiest\": busiest, \"status\": st}" + "source": "## A second tradeoff, for free \u2014 cost vs. fairness\n\nThe base model *fixed* `max_shifts_per_worker = 4`. The skill's move \u2014 **a fixed constraint 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", "metadata": {}, "execution_count": null, "outputs": [], - "source": "# Sweep the per-worker cap from loose (cheap) to tight (fair). Tighter -> fairer but costlier; too tight -> infeasible.\ncap_grid = list(range(len(shift_requirements), 0, -1)) # 14 down to 1\nfair_pts = []\nfor cap in cap_grid:\n r = solve_workforce_fairness(cap)\n if r[\"cost\"] is not None:\n fair_pts.append((r[\"max_shifts\"], r[\"cost\"], r[\"busiest\"]))\n tag = \"\"\n else:\n tag = f\" (infeasible at cap={cap}: cannot fully cover with everyone capped this low)\"\n print(f\"max_shifts cap {cap:2d}: \" + (f\"min cost ${r['cost']}, busiest worker {r['busiest']} shifts\" if r['cost'] is not None else f\"INFEASIBLE\") + tag)\n\nfig, ax = plt.subplots(figsize=(8, 5))\nif fair_pts:\n fp = np.array([(c, k) for (c, k, b) in fair_pts])\n ax.plot(fp[:, 0], fp[:, 1], \"o-\", color=\"seagreen\", lw=1.6)\n ax.invert_xaxis() # left = fairer (tighter cap)\nax.set_xlabel(\"Max shifts allowed per worker (left = fairer)\")\nax.set_ylabel(\"Labor cost ($) at full coverage\")\nax.set_title(\"Workforce: cost vs fairness (sweeping the max-shifts constraint)\")\nax.grid(alpha=0.3); plt.tight_layout(); plt.show()\nprint(\"\\nThe planner reads the price of fairness: each step tighter on the cap costs $X more at full coverage.\")" + "source": "def solve_fairness(max_shifts, time_limit=10.0):\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}\n\nfair = []\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\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", "metadata": {}, - "source": "## Notes (honest)\n\n- **Synthetic data** \u2014 the same toy roster as the base notebook; this demonstrates the *method*, not a real staffing study.\n- **Optimal to the gap, within the time limit** \u2014 each point is solved by cuOpt's MILP solver under a `time_limit`; points are optimal to the solver's gap, not certified global optima unless it returns `Optimal` at a zero gap.\n- **\u03b5-constraint vs weighted-sum** \u2014 \u03b5-constraint reaches the complete frontier by construction; weighted-sum only returns supported (convex-hull) points. Whether *this* small instance actually exhibits unsupported points is empirical (the cell above reports it); the completeness guarantee holds regardless of instance.\n- **No duals for a MILP** \u2014 unlike the continuous portfolio QP (see `portfolio_optimization/`), an integer program has no constraint duals/shadow prices; read the marginal cost of coverage from the frontier itself, as above.\n\nThis notebook reproduces the `cuopt-multi-objective-exploration` skill end-to-end on cuOpt's own workforce MILP." + "source": "## Notes\n\n- **Synthetic data** \u2014 the base notebook's toy roster; this demonstrates the *method*, not a staffing study.\n- **Optimal to the gap, within the time limit** \u2014 each point is solved under a `time_limit`; points are optimal to cuOpt's gap, not certified global optima unless it returns `Optimal` at a zero gap.\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). The continuous portfolio QP (`portfolio_optimization/QP_portfolio_frontier_duals.ipynb`) *does* expose duals \u2014 the deliberate contrast.\n\nBuilt by following the `cuopt-multi-objective-exploration` skill end-to-end on cuOpt's own workforce MILP." } ], "metadata": { From 08424184255e5a4b9c2adfdda8ca5ecc19b80c2c Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 12:01:48 -0700 Subject: [PATCH 03/15] Align multi-objective notebooks with repo conventions; list them in folder READMEs Signed-off-by: cafzal --- .../QP_portfolio_frontier_duals.ipynb | 25 +++++++++++++-- portfolio_optimization/README.md | 7 +++- workforce_optimization/README.md | 11 ++++++- ...orkforce_optimization_multiobjective.ipynb | 32 +++++++++++++++++-- 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 604c010..68c9707 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -2,30 +2,35 @@ "cells": [ { "cell_type": "markdown", + "id": "f330297d", "metadata": {}, - "source": "# Portfolio Optimization \u2014 the Frontier via the Skill, + Shadow Prices (cuOpt QP)\n\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/cuopt-examples/blob/main/portfolio_optimization/QP_portfolio_frontier_duals.ipynb)\n\nThe base `QP_portfolio_optimization` notebook **hand-codes** an efficient-frontier sweep (a manual loop over target returns). This sibling shows that following the `cuopt-multi-objective-exploration` skill **recreates that frontier as a named, systematic workflow** \u2014 anchor each objective \u2192 \u03b5-constraint sweep (the return floor is the parametric bound) \u2192 filter dominated \u2192 read the frontier \u2014 with less ad-hoc scaffolding, and **adds the one thing the manual sweep omits**: the return-constraint **dual** (shadow price d(variance)/d(return)).\n\nSo the two examples are complementary tests of the skill: here it **reproduces** an existing frontier (return vs risk) with less manual work and surfaces the duals; the workforce MILP (`workforce_optimization/workforce_optimization_multiobjective.ipynb`) is the **net-new** case \u2014 and the deliberate contrast is that **a QP has constraint duals, an integer program does not.**\n\n> **Requirements.** cuOpt needs **Linux + an NVIDIA GPU** (Colab: *Runtime \u2192 Change runtime type \u2192 GPU*)." + "source": "# Portfolio Optimization \u2014 the Frontier via the Skill, + Shadow Prices (cuOpt QP)\n\nThe base `QP_portfolio_optimization` notebook **hand-codes** an efficient-frontier sweep (a manual loop over target returns). This sibling shows that following the `cuopt-multi-objective-exploration` skill **recreates that frontier as a named, systematic workflow** \u2014 anchor each objective \u2192 \u03b5-constraint sweep (the return floor is the parametric bound) \u2192 filter dominated \u2192 read the frontier \u2014 with less ad-hoc scaffolding, and **adds the one thing the manual sweep omits**: the return-constraint **dual** (shadow price d(variance)/d(return)).\n\nSo the two examples are complementary tests of the skill: here it **reproduces** an existing frontier (return vs risk) with less manual work and surfaces the duals; the workforce MILP (`workforce_optimization/workforce_optimization_multiobjective.ipynb`) is the **net-new** case \u2014 and the deliberate contrast is that **a QP has constraint duals, an integer program does not.**" }, { "cell_type": "markdown", + "id": "39af174e", "metadata": {}, "source": "## Environment Setup" }, { "cell_type": "code", + "id": "dd4db594", "metadata": {}, "execution_count": null, "outputs": [], - "source": "import subprocess\ntry:\n out = subprocess.run([\"nvidia-smi\", \"--query-gpu=name,memory.total\", \"--format=csv,noheader\"],\n capture_output=True, text=True)\n print(out.stdout.strip() or \"no nvidia-smi output\")\nexcept FileNotFoundError:\n print(\"No NVIDIA GPU detected - cuOpt cannot run. In Colab: Runtime -> Change runtime type -> GPU.\")" + "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 if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" + "source": "# Enable this in case you are running this in google colab or such places where cuOpt is not yet installed\n#!pip uninstall -y cuda-python cuda-bindings cuda-core\n#!pip install --upgrade --extra-index-url=https://pypi.nvidia.com cuopt-cu12 nvidia-nvjitlink-cu12 rapids-logger==0.1.19\n#!pip install --upgrade --extra-index-url=https://pypi.nvidia.com cuopt-cu13 nvidia-nvjitlink-cu13 rapids-logger==0.1.19" }, { "cell_type": "code", + "id": "91a1a66e", "metadata": {}, "execution_count": null, "outputs": [], @@ -33,11 +38,13 @@ }, { "cell_type": "markdown", + "id": "16e2d023", "metadata": {}, "source": "## Data\n\nSame simulated asset universe as `QP_portfolio_optimization` (annualized mean returns + covariance)." }, { "cell_type": "code", + "id": "8b8b0bde", "metadata": {}, "execution_count": null, "outputs": [], @@ -45,11 +52,13 @@ }, { "cell_type": "markdown", + "id": "e5004295", "metadata": {}, "source": "## Min-variance QP with the return-constraint dual\n\nThis is the base notebook's `solve_min_variance_qp`, with one addition: we keep a handle on the `min_return` constraint and read its **`.DualValue`** after the solve. For the QP, that dual is the shadow price d(variance)/d(return)." }, { "cell_type": "code", + "id": "44ad356a", "metadata": {}, "execution_count": null, "outputs": [], @@ -57,11 +66,13 @@ }, { "cell_type": "markdown", + "id": "2a1a84e1", "metadata": {}, "source": "## Sweep the return target \u2192 frontier + shadow price\n\nThe \u03b5-constraint sweep (return floor as the parametric bound), capturing the dual at each point. The skill's note applies: cuOpt's QP beta is PDLP (a first-order method), so the dual is accurate **to the solver's tolerance** \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." }, { "cell_type": "code", + "id": "5098fd42", "metadata": {}, "execution_count": null, "outputs": [], @@ -69,6 +80,7 @@ }, { "cell_type": "code", + "id": "4f051b88", "metadata": {}, "execution_count": null, "outputs": [], @@ -76,8 +88,15 @@ }, { "cell_type": "markdown", + "id": "6888f83f", "metadata": {}, "source": "## Reading it\n\n- The **frontier** (left) is the return-vs-risk Pareto set \u2014 every point is a min-variance portfolio for its return floor.\n- The **dual** (right) is the *exchange rate* the skill asks you to report: how much variance you take on per extra unit of return. It rises along the frontier \u2014 the marginal cost of return gets steeper, which is exactly where a knee analysis pays off.\n\n### Notes (honest)\n- **Synthetic data** \u2014 the base notebook's simulated universe; demonstrates the method.\n- **PDLP / first-order** \u2014 cuOpt's QP beta is a first-order solver, so the dual is optimal **to its convergence tolerance**, not exact arithmetic; points reported `PrimalFeasible` rather than `Optimal` are flagged above and could be tightened or dropped.\n- **Continuous only** \u2014 these duals exist because the portfolio is a QP. The integer workforce model (`workforce_optimization_multiobjective.ipynb`) has **no constraint duals**; there you read the marginal cost off the frontier itself.\n\nThis adds the duals/interpretation step of the `cuopt-multi-objective-exploration` skill to cuOpt's existing portfolio frontier." + }, + { + "cell_type": "markdown", + "id": "b281399a", + "metadata": {}, + "source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2025 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": { diff --git a/portfolio_optimization/README.md b/portfolio_optimization/README.md index 830e952..01056af 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. Portfolio Frontier with Shadow Prices (QP) + +- Builds the efficient frontier as a named ε-constraint sweep (following the `cuopt-multi-objective-exploration` skill). +- Adds the return-constraint **dual** — the shadow price d(variance)/d(return) — along the frontier, with the PDLP-tolerance caveat. + +### 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..67f0ece 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) + +Extends the MILP above into a Pareto frontier — choose the tradeoff instead of getting one plan: + +- **cost vs. coverage** — sweep a coverage floor as an ε-constraint; read the marginal cost per shift off the frontier. +- **cost vs. fairness** — promote the fixed per-worker shift cap into a swept objective (a constraint treated as a candidate objective). + +Follows the `cuopt-multi-objective-exploration` skill. A MILP has no constraint duals, so the marginal cost comes from 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 index 795d79d..9abca92 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -2,30 +2,35 @@ "cells": [ { "cell_type": "markdown", + "id": "13784d94", "metadata": {}, - "source": "# Workforce Optimization \u2014 Multi-Objective with cuOpt\n\n[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/NVIDIA/cuopt-examples/blob/main/workforce_optimization/workforce_optimization_multiobjective.ipynb)\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 it returns **one plan**. But that plan answers only *\"cheapest way to fully staff.\"* 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; you get one point on a curve you can't see.\n\nThis notebook follows the `cuopt-multi-objective-exploration` skill to turn the single solve into the **whole tradeoff curve**, so the planner can see the options and choose. Two tradeoffs, both built by promoting one of the base model's hard constraints into an objective:\n\n1. **cost vs. coverage** \u2014 relax `coverage == required` and sweep a coverage floor.\n2. **cost vs. fairness** \u2014 sweep the base model's fixed `max_shifts` cap.\n\n> **Requirements.** cuOpt needs **Linux + an NVIDIA GPU** (Colab: *Runtime \u2192 Change runtime type \u2192 GPU*)." + "source": "# Workforce Optimization \u2014 Multi-Objective with cuOpt\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 it returns **one plan**. But that plan answers only *\"cheapest way to fully staff.\"* 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; you get one point on a curve you can't see.\n\nThis notebook follows the `cuopt-multi-objective-exploration` skill to turn the single solve into the **whole tradeoff curve**, so the planner can see the options and choose. Two tradeoffs, both built by promoting one of the base model's hard constraints into an objective:\n\n1. **cost vs. coverage** \u2014 relax `coverage == required` and sweep a coverage floor.\n2. **cost vs. fairness** \u2014 sweep the base model's fixed `max_shifts` cap." }, { "cell_type": "markdown", + "id": "0f11c9f4", "metadata": {}, "source": "## Environment Setup" }, { "cell_type": "code", + "id": "034b6553", "metadata": {}, "execution_count": null, "outputs": [], - "source": "import subprocess\ntry:\n out = subprocess.run([\"nvidia-smi\", \"--query-gpu=name,memory.total\", \"--format=csv,noheader\"],\n capture_output=True, text=True)\n print(out.stdout.strip() or \"no nvidia-smi output\")\nexcept FileNotFoundError:\n print(\"No NVIDIA GPU detected - cuOpt cannot run. In Colab: Runtime -> Change runtime type -> GPU.\")" + "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 if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" + "source": "# Install cuOpt if not already installed\n# Uncomment the following line if running in Google Colab or similar environment\n#!pip uninstall -y cuda-python cuda-bindings cuda-core\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12 nvidia-nvjitlink-cu12 rapids-logger==0.1.19 # For cuda 12\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu13 nvidia-nvjitlink-cu13 rapids-logger==0.1.19 # For cuda 13" }, { "cell_type": "code", + "id": "a16a40c8", "metadata": {}, "execution_count": null, "outputs": [], @@ -33,11 +38,13 @@ }, { "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": [], @@ -45,11 +52,13 @@ }, { "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 (the skill's practical note)." }, { "cell_type": "code", + "id": "202935ab", "metadata": {}, "execution_count": null, "outputs": [], @@ -57,11 +66,13 @@ }, { "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": [], @@ -69,11 +80,13 @@ }, { "cell_type": "markdown", + "id": "901b67c6", "metadata": {}, "source": "## Two objectives, no fixed weighting \u2192 trace the frontier\n\nFollowing the skill: **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": [], @@ -81,6 +94,7 @@ }, { "cell_type": "code", + "id": "8fd668c5", "metadata": {}, "execution_count": null, "outputs": [], @@ -88,11 +102,13 @@ }, { "cell_type": "markdown", + "id": "1d5b3af8", "metadata": {}, "source": "## Read the frontier \u2014 this is the value\n\nThe skill's 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": [], @@ -100,16 +116,19 @@ }, { "cell_type": "markdown", + "id": "ec45ab69", "metadata": {}, "source": "**Method note.** We use the skill's 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 skill's move \u2014 **a fixed constraint 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": [], @@ -117,8 +136,15 @@ }, { "cell_type": "markdown", + "id": "29d59f8d", "metadata": {}, "source": "## Notes\n\n- **Synthetic data** \u2014 the base notebook's toy roster; this demonstrates the *method*, not a staffing study.\n- **Optimal to the gap, within the time limit** \u2014 each point is solved under a `time_limit`; points are optimal to cuOpt's gap, not certified global optima unless it returns `Optimal` at a zero gap.\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). The continuous portfolio QP (`portfolio_optimization/QP_portfolio_frontier_duals.ipynb`) *does* expose duals \u2014 the deliberate contrast.\n\nBuilt by following the `cuopt-multi-objective-exploration` skill end-to-end on cuOpt's own workforce MILP." + }, + { + "cell_type": "markdown", + "id": "cdf08b7f", + "metadata": {}, + "source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2025 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": { From d68162d9a689c8f9f96ed9b965826db5eef1b420 Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 12:28:46 -0700 Subject: [PATCH 04/15] Report non-Optimal (FeasibleFound) point counts in the workforce MILP sweeps Signed-off-by: cafzal --- .../workforce_optimization_multiobjective.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index 9abca92..b81be8d 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -90,7 +90,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "cov_max = solve(maximize_coverage=True)[\"coverage\"]\npoints = []\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\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)}\")" + "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", @@ -132,7 +132,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "def solve_fairness(max_shifts, time_limit=10.0):\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}\n\nfair = []\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\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()" + "source": "def solve_fairness(max_shifts, time_limit=10.0):\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", From edd1a2a3e03560b77a18ee55e70dd9b6e7300042 Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 12:46:42 -0700 Subject: [PATCH 05/15] Use the lean cuopt-cu12 install cell on both notebooks (faster on Colab) Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 2 +- .../workforce_optimization_multiobjective.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 68c9707..bfd13d0 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "# Enable this in case you are running this in google colab or such places where cuOpt is not yet installed\n#!pip uninstall -y cuda-python cuda-bindings cuda-core\n#!pip install --upgrade --extra-index-url=https://pypi.nvidia.com cuopt-cu12 nvidia-nvjitlink-cu12 rapids-logger==0.1.19\n#!pip install --upgrade --extra-index-url=https://pypi.nvidia.com cuopt-cu13 nvidia-nvjitlink-cu13 rapids-logger==0.1.19" + "source": "# Uncomment if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" }, { "cell_type": "code", diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index b81be8d..3693ab6 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "# Install cuOpt if not already installed\n# Uncomment the following line if running in Google Colab or similar environment\n#!pip uninstall -y cuda-python cuda-bindings cuda-core\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12 nvidia-nvjitlink-cu12 rapids-logger==0.1.19 # For cuda 12\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu13 nvidia-nvjitlink-cu13 rapids-logger==0.1.19 # For cuda 13" + "source": "# Uncomment if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" }, { "cell_type": "code", From 4ad0b5790f5ed46a73091abff6df94b7e0f7a243 Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 13:04:54 -0700 Subject: [PATCH 06/15] Offer cuda12 and cuda13 install lines in both notebooks (match repo convention) Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 2 +- .../workforce_optimization_multiobjective.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index bfd13d0..2e6b958 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "# Uncomment if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" + "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", diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index 3693ab6..9ed0c6a 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "# Uncomment if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12" + "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", From 99a6673d670ef8c38861af1075b75feae6cec897 Mon Sep 17 00:00:00 2001 From: cafzal Date: Thu, 4 Jun 2026 11:50:46 -0700 Subject: [PATCH 07/15] Add docstrings to the notebooks' solver helper functions Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 2 +- .../workforce_optimization_multiobjective.ipynb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 2e6b958..16fbcc5 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -62,7 +62,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "def solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=None, max_weight=None):\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 # shadow price 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%}\")" + "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 shadow price d(variance)/d(return),\n accurate to PDLP's tolerance.\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 # shadow price 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", diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index 9ed0c6a..5746df3 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -62,7 +62,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "def solve(coverage_floor=None, maximize_coverage=False, time_limit=10.0):\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}" + "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", @@ -132,7 +132,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "def solve_fairness(max_shifts, time_limit=10.0):\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()" + "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", From d0d98a51283e2fe56f9a3b14c8144e65b309b9a3 Mon Sep 17 00:00:00 2001 From: Ramakrishnap <42624703+rgsl888prabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:17:34 -0500 Subject: [PATCH 08/15] Update workforce_optimization/workforce_optimization_multiobjective.ipynb --- .../workforce_optimization_multiobjective.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index 5746df3..5fb71de 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -144,7 +144,7 @@ "cell_type": "markdown", "id": "cdf08b7f", "metadata": {}, - "source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2025 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." + "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": { From 078cf88b2c139c0565e4d5922827921fbb256b37 Mon Sep 17 00:00:00 2001 From: Ramakrishnap <42624703+rgsl888prabhu@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:17:43 -0500 Subject: [PATCH 09/15] Update portfolio_optimization/QP_portfolio_frontier_duals.ipynb --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 16fbcc5..e16b409 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -96,7 +96,7 @@ "cell_type": "markdown", "id": "b281399a", "metadata": {}, - "source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2025 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." + "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": { From f2f8b48630a126a6b8552ca7a116bcb50ad0ad30 Mon Sep 17 00:00:00 2001 From: cafzal Date: Fri, 5 Jun 2026 12:12:53 -0700 Subject: [PATCH 10/15] Address review: make the epsilon-constraint method + takeaway explicit, drop cross-references, fix QP-dual wording Signed-off-by: cafzal --- .../QP_portfolio_frontier_duals.ipynb | 14 +++++++------- .../workforce_optimization_multiobjective.ipynb | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index e16b409..3541da3 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "f330297d", "metadata": {}, - "source": "# Portfolio Optimization \u2014 the Frontier via the Skill, + Shadow Prices (cuOpt QP)\n\nThe base `QP_portfolio_optimization` notebook **hand-codes** an efficient-frontier sweep (a manual loop over target returns). This sibling shows that following the `cuopt-multi-objective-exploration` skill **recreates that frontier as a named, systematic workflow** \u2014 anchor each objective \u2192 \u03b5-constraint sweep (the return floor is the parametric bound) \u2192 filter dominated \u2192 read the frontier \u2014 with less ad-hoc scaffolding, and **adds the one thing the manual sweep omits**: the return-constraint **dual** (shadow price d(variance)/d(return)).\n\nSo the two examples are complementary tests of the skill: here it **reproduces** an existing frontier (return vs risk) with less manual work and surfaces the duals; the workforce MILP (`workforce_optimization/workforce_optimization_multiobjective.ipynb`) is the **net-new** case \u2014 and the deliberate contrast is that **a QP has constraint duals, an integer program does not.**" + "source": "# Portfolio Optimization \u2014 the Efficient Frontier as an \u03b5-Constraint Sweep (cuOpt QP)\n\nMean-variance portfolio optimization is a **two-objective** problem: maximize expected return, minimize risk (variance). There's no single right weighting of the two, so the answer isn't one portfolio \u2014 it's the **efficient frontier**, the set of portfolios where you can't cut risk without giving up return.\n\nWe trace that frontier with the **\u03b5-constraint method**, the workhorse of multi-objective optimization:\n\n1. **Recognize the two objectives** \u2014 return and variance pull against each other.\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), i.e. how much extra variance one more unit of return costs.\n\nThe base `QP_portfolio_optimization` notebook already hand-codes step 3 as a loop over target returns \u2014 so it is **already doing \u03b5-constraint optimization without naming it**. Naming the method is what lets you add the piece the manual loop omits (the dual) and reuse the same recipe on any two-objective problem. (This workflow is also packaged as the `cuopt-multi-objective-exploration` skill.)" }, { "cell_type": "markdown", @@ -54,7 +54,7 @@ "cell_type": "markdown", "id": "e5004295", "metadata": {}, - "source": "## Min-variance QP with the return-constraint dual\n\nThis is the base notebook's `solve_min_variance_qp`, with one addition: we keep a handle on the `min_return` constraint and read its **`.DualValue`** after the solve. For the QP, that dual is the shadow price d(variance)/d(return)." + "source": "## Step 2 \u2014 minimize variance, and read 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 read 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", @@ -62,13 +62,13 @@ "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 shadow price d(variance)/d(return),\n accurate to PDLP's tolerance.\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 # shadow price 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%}\")" + "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 the solver's convergence tolerance.\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": "## Sweep the return target \u2192 frontier + shadow price\n\nThe \u03b5-constraint sweep (return floor as the parametric bound), capturing the dual at each point. The skill's note applies: cuOpt's QP beta is PDLP (a first-order method), so the dual is accurate **to the solver's tolerance** \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." + "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's QP support is **beta**, so the dual is accurate **to the solver's convergence tolerance**, not exact \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." }, { "cell_type": "code", @@ -76,7 +76,7 @@ "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\"Shadow price d(variance)/d(return): {duals.min():.3f} -> {duals.max():.3f} as required return rises\")" + "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", @@ -84,13 +84,13 @@ "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(\"Shadow price 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()" + "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": "## Reading it\n\n- The **frontier** (left) is the return-vs-risk Pareto set \u2014 every point is a min-variance portfolio for its return floor.\n- The **dual** (right) is the *exchange rate* the skill asks you to report: how much variance you take on per extra unit of return. It rises along the frontier \u2014 the marginal cost of return gets steeper, which is exactly where a knee analysis pays off.\n\n### Notes (honest)\n- **Synthetic data** \u2014 the base notebook's simulated universe; demonstrates the method.\n- **PDLP / first-order** \u2014 cuOpt's QP beta is a first-order solver, so the dual is optimal **to its convergence tolerance**, not exact arithmetic; points reported `PrimalFeasible` rather than `Optimal` are flagged above and could be tightened or dropped.\n- **Continuous only** \u2014 these duals exist because the portfolio is a QP. The integer workforce model (`workforce_optimization_multiobjective.ipynb`) has **no constraint duals**; there you read the marginal cost off the frontier itself.\n\nThis adds the duals/interpretation step of the `cuopt-multi-objective-exploration` skill to cuOpt's existing portfolio frontier." + "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- **QP is beta** \u2014 cuOpt's QP solver returns duals accurate to its convergence 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", diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index 5fb71de..cb3b864 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "13784d94", "metadata": {}, - "source": "# Workforce Optimization \u2014 Multi-Objective with cuOpt\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 it returns **one plan**. But that plan answers only *\"cheapest way to fully staff.\"* 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; you get one point on a curve you can't see.\n\nThis notebook follows the `cuopt-multi-objective-exploration` skill to turn the single solve into the **whole tradeoff curve**, so the planner can see the options and choose. Two tradeoffs, both built by promoting one of the base model's hard constraints into an objective:\n\n1. **cost vs. coverage** \u2014 relax `coverage == required` and sweep a coverage floor.\n2. **cost vs. fairness** \u2014 sweep the base model's fixed `max_shifts` cap." + "source": "# Workforce Optimization \u2014 Cost vs. Coverage vs. Fairness (cuOpt MILP)\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 it returns **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\nThis notebook turns 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", @@ -54,7 +54,7 @@ "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 (the skill's practical note)." + "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", @@ -82,7 +82,7 @@ "cell_type": "markdown", "id": "901b67c6", "metadata": {}, - "source": "## Two objectives, no fixed weighting \u2192 trace the frontier\n\nFollowing the skill: **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." + "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", @@ -104,7 +104,7 @@ "cell_type": "markdown", "id": "1d5b3af8", "metadata": {}, - "source": "## Read the frontier \u2014 this is the value\n\nThe skill's 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." + "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", @@ -118,13 +118,13 @@ "cell_type": "markdown", "id": "ec45ab69", "metadata": {}, - "source": "**Method note.** We use the skill's 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." + "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 skill's move \u2014 **a fixed constraint 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." + "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", @@ -138,7 +138,7 @@ "cell_type": "markdown", "id": "29d59f8d", "metadata": {}, - "source": "## Notes\n\n- **Synthetic data** \u2014 the base notebook's toy roster; this demonstrates the *method*, not a staffing study.\n- **Optimal to the gap, within the time limit** \u2014 each point is solved under a `time_limit`; points are optimal to cuOpt's gap, not certified global optima unless it returns `Optimal` at a zero gap.\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). The continuous portfolio QP (`portfolio_optimization/QP_portfolio_frontier_duals.ipynb`) *does* expose duals \u2014 the deliberate contrast.\n\nBuilt by following the `cuopt-multi-objective-exploration` skill end-to-end on cuOpt's own workforce MILP." + "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", From a14171305d0ad329d0f3b87e7c7a24539d60d8d3 Mon Sep 17 00:00:00 2001 From: cafzal Date: Fri, 5 Jun 2026 12:17:51 -0700 Subject: [PATCH 11/15] Align example intros with the repo's 'solve X with cuOpt' framing Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 2 +- .../workforce_optimization_multiobjective.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 3541da3..495a61f 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "f330297d", "metadata": {}, - "source": "# Portfolio Optimization \u2014 the Efficient Frontier as an \u03b5-Constraint Sweep (cuOpt QP)\n\nMean-variance portfolio optimization is a **two-objective** problem: maximize expected return, minimize risk (variance). There's no single right weighting of the two, so the answer isn't one portfolio \u2014 it's the **efficient frontier**, the set of portfolios where you can't cut risk without giving up return.\n\nWe trace that frontier with the **\u03b5-constraint method**, the workhorse of multi-objective optimization:\n\n1. **Recognize the two objectives** \u2014 return and variance pull against each other.\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), i.e. how much extra variance one more unit of return costs.\n\nThe base `QP_portfolio_optimization` notebook already hand-codes step 3 as a loop over target returns \u2014 so it is **already doing \u03b5-constraint optimization without naming it**. Naming the method is what lets you add the piece the manual loop omits (the dual) and reuse the same recipe on any two-objective problem. (This workflow is also packaged as the `cuopt-multi-objective-exploration` skill.)" + "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", diff --git a/workforce_optimization/workforce_optimization_multiobjective.ipynb b/workforce_optimization/workforce_optimization_multiobjective.ipynb index cb3b864..40c3fd2 100644 --- a/workforce_optimization/workforce_optimization_multiobjective.ipynb +++ b/workforce_optimization/workforce_optimization_multiobjective.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "13784d94", "metadata": {}, - "source": "# Workforce Optimization \u2014 Cost vs. Coverage vs. Fairness (cuOpt MILP)\n\nThe base `workforce_optimization_milp` notebook minimizes labor cost with coverage **hard-constrained** \u2014 it returns **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\nThis notebook turns 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.)" + "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", From 13b48fbbf1d22dbe2e04de750f4b924c3b1ba14a Mon Sep 17 00:00:00 2001 From: cafzal Date: Fri, 5 Jun 2026 12:23:29 -0700 Subject: [PATCH 12/15] Number the portfolio data section as Step 1 for a complete method walk-through Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 495a61f..0752f09 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -40,7 +40,7 @@ "cell_type": "markdown", "id": "16e2d023", "metadata": {}, - "source": "## Data\n\nSame simulated asset universe as `QP_portfolio_optimization` (annualized mean returns + covariance)." + "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", @@ -54,7 +54,7 @@ "cell_type": "markdown", "id": "e5004295", "metadata": {}, - "source": "## Step 2 \u2014 minimize variance, and read 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 read 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." + "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", From 1fea3422a3b83c8ad5949551065a6dc94e71992c Mon Sep 17 00:00:00 2001 From: cafzal Date: Fri, 5 Jun 2026 12:36:40 -0700 Subject: [PATCH 13/15] Name the QP barrier (interior-point) solver precisely (QP is not PDLP) Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index 0752f09..f18a4d5 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -62,13 +62,13 @@ "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 the solver's convergence tolerance.\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%}\")" + "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's QP support is **beta**, so the dual is accurate **to the solver's convergence tolerance**, not exact \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." + "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. a quadratic objective is solved by cuOpt's **barrier (interior-point)** method \u2014 the only method that supports quadratic objectives \u2014 to 1e-8 relative accuracy by default, so the dual is accurate **to that tolerance**, not exact \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." }, { "cell_type": "code", @@ -90,7 +90,7 @@ "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- **QP is beta** \u2014 cuOpt's QP solver returns duals accurate to its convergence 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." + "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", From 1b8b0b90510398ae36f88bae19d4a0256f15d061 Mon Sep 17 00:00:00 2001 From: cafzal Date: Fri, 5 Jun 2026 12:37:32 -0700 Subject: [PATCH 14/15] Tighten the barrier note wording Signed-off-by: cafzal --- portfolio_optimization/QP_portfolio_frontier_duals.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb index f18a4d5..47fa6c5 100644 --- a/portfolio_optimization/QP_portfolio_frontier_duals.ipynb +++ b/portfolio_optimization/QP_portfolio_frontier_duals.ipynb @@ -68,7 +68,7 @@ "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. a quadratic objective is solved by cuOpt's **barrier (interior-point)** method \u2014 the only method that supports quadratic objectives \u2014 to 1e-8 relative accuracy by default, so the dual is accurate **to that tolerance**, not exact \u2014 we keep points the solver reports as `Optimal` and flag any `PrimalFeasible`." + "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", From 9265dae68fdfb65513600922dd987082b8502d58 Mon Sep 17 00:00:00 2001 From: cafzal Date: Fri, 5 Jun 2026 12:51:59 -0700 Subject: [PATCH 15/15] Audit folder READMEs: sensitivity/barrier terms, drop em dashes, match base style Signed-off-by: cafzal --- portfolio_optimization/README.md | 6 +++--- workforce_optimization/README.md | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/portfolio_optimization/README.md b/portfolio_optimization/README.md index 01056af..5a46717 100644 --- a/portfolio_optimization/README.md +++ b/portfolio_optimization/README.md @@ -14,10 +14,10 @@ The portfolio optimization notebook solves a portfolio optimization problem wher - The aim is to balance expected return with the risk of losses -### 3. Portfolio Frontier with Shadow Prices (QP) +### 3. Multi-Objective Portfolio Optimization (QP) -- Builds the efficient frontier as a named ε-constraint sweep (following the `cuopt-multi-objective-exploration` skill). -- Adds the return-constraint **dual** — the shadow price d(variance)/d(return) — along the frontier, with the PDLP-tolerance caveat. +- 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 diff --git a/workforce_optimization/README.md b/workforce_optimization/README.md index 67f0ece..a22f940 100644 --- a/workforce_optimization/README.md +++ b/workforce_optimization/README.md @@ -14,9 +14,9 @@ The workforce optimization notebook solves a mixed integer linear programming pr ### 2. Workforce Optimization (Multi-Objective) -Extends the MILP above into a Pareto frontier — choose the tradeoff instead of getting one plan: +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; read the marginal cost per shift off the frontier. -- **cost vs. fairness** — promote the fixed per-worker shift cap into a swept objective (a constraint treated as a candidate objective). +- **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. -Follows the `cuopt-multi-objective-exploration` skill. A MILP has no constraint duals, so the marginal cost comes from the frontier itself. \ No newline at end of file +A MILP has no constraint duals, so the marginal cost is read off the frontier itself. \ No newline at end of file