From e8ce93482dcfcd901d57e4d0ab06637695a4cac7 Mon Sep 17 00:00:00 2001 From: cafzal Date: Mon, 1 Jun 2026 10:43:11 -0700 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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",