Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions portfolio_optimization/QP_portfolio_frontier_duals.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "f330297d",
"metadata": {},
"source": "# Multi-Objective Portfolio Optimization with cuOpt Python API\n\nThis notebook demonstrates how to use the cuOpt Python API to trace the **efficient frontier** of a mean-variance portfolio \u2014 the multi-objective core of portfolio optimization, where there's no single best balance of return and risk, only a curve of optimal tradeoffs.\n\nThe base `QP_portfolio_optimization` notebook solves for individual portfolios; here we sweep the whole frontier with the **\u03b5-constraint method** and read each point's **sensitivity** (the return-constraint dual):\n\n1. **Two objectives that conflict** \u2014 maximize return, minimize variance.\n2. **Keep one as the objective, constrain the other** \u2014 minimize variance subject to `return \u2265 \u03b5`.\n3. **Sweep \u03b5** across the achievable return range; each solve is one frontier point.\n4. **Read the frontier** \u2014 and, because this is a continuous QP, read each point's **dual**: the sensitivity d(variance)/d(return), how much extra variance one more unit of return costs.\n\nA useful thing to notice: the base notebook already loops over target returns, so it is **already doing this** \u2014 naming the method is what lets you add the dual and reuse the recipe on any two-objective problem. (This workflow is also packaged as the `cuopt-multi-objective-exploration` skill.)"
},
{
"cell_type": "markdown",
"id": "39af174e",
"metadata": {},
"source": "## Environment Setup"
},
{
"cell_type": "code",
"id": "dd4db594",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "import subprocess\nimport html\nfrom IPython.display import display, HTML\n\ndef check_gpu():\n try:\n result = subprocess.run([\"nvidia-smi\"], capture_output=True, text=True, timeout=5)\n result.check_returncode()\n lines = result.stdout.splitlines()\n gpu_info = lines[2] if len(lines) > 2 else \"GPU detected\"\n gpu_info_escaped = html.escape(gpu_info)\n display(HTML(f\"\"\"\n <div style=\"border:2px solid #4CAF50;padding:10px;border-radius:10px;background:#e8f5e9;\">\n <h3>\u2705 GPU is enabled</h3>\n <pre>{gpu_info_escaped}</pre>\n </div>\n \"\"\"))\n return True\n except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e:\n display(HTML(\"\"\"\n <div style=\"border:2px solid red;padding:15px;border-radius:10px;background:#ffeeee;\">\n <h3>\u26a0\ufe0f GPU not detected!</h3>\n <p>This notebook requires a <b>GPU runtime</b>.</p>\n\n <h4>If running in Google Colab:</h4>\n <ol>\n <li>Click on <b>Runtime \u2192 Change runtime type</b></li>\n <li>Set <b>Hardware accelerator</b> to <b>GPU</b></li>\n <li>Then click <b>Save</b> and <b>Runtime \u2192 Restart runtime</b>.</li>\n </ol>\n\n <h4>If running in Docker:</h4>\n <ol>\n <li>Ensure you have <b>NVIDIA Docker runtime</b> installed (<code>nvidia-docker2</code>)</li>\n <li>Run container with GPU support: <code>docker run --gpus all ...</code></li>\n <li>Or use: <code>docker run --runtime=nvidia ...</code> for older Docker versions</li>\n <li>Verify GPU access: <code>docker run --gpus all nvidia/cuda:12.0.0-base-ubuntu22.04 nvidia-smi</code></li>\n </ol>\n\n <p><b>Additional resources:</b></p>\n <ul>\n <li><a href=\"https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html\" target=\"_blank\">NVIDIA Container Toolkit Installation Guide</a></li>\n </ul>\n </div>\n \"\"\"))\n return False\n\ncheck_gpu()"
},
{
"cell_type": "code",
"id": "258c4642",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "# Uncomment for your CUDA version if cuOpt is not already installed (e.g., Google Colab):\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu12 # CUDA 12\n# !pip install --upgrade --extra-index-url https://pypi.nvidia.com cuopt-cu13 # CUDA 13"
},
{
"cell_type": "code",
"id": "91a1a66e",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "import numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom cuopt.linear_programming.problem import Problem, QuadraticExpression, MINIMIZE\nprint(\"Imports ready (cuOpt QP solver)\")"
},
{
"cell_type": "markdown",
"id": "16e2d023",
"metadata": {},
"source": "## Step 1 \u2014 the assets and their two objectives\n\nSame simulated asset universe as `QP_portfolio_optimization`. The annualized **mean return** (to maximize) and **variance** / risk (to minimize) are the two competing objectives we trade off below."
},
{
"cell_type": "code",
"id": "8b8b0bde",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "# Simulate monthly returns with realistic assumptions\nnp.random.seed(7)\n\nassets = [\"Cash\", \"US Equity\", \"Intl Equity\", \"Bond\", \"REIT/Gold\"]\n\nannual_mean = np.array([0.02, 0.08, 0.075, 0.04, 0.06])\nannual_vol = np.array([0.005, 0.16, 0.18, 0.06, 0.14])\n\ncorr = np.array([\n [1.00, 0.05, 0.05, 0.10, 0.05],\n [0.05, 1.00, 0.80, -0.10, 0.55],\n [0.05, 0.80, 1.00, -0.05, 0.50],\n [0.10, -0.10, -0.05, 1.00, 0.00],\n [0.05, 0.55, 0.50, 0.00, 1.00],\n])\n\nmonthly_mean = annual_mean / 12.0\nmonthly_vol = annual_vol / np.sqrt(12.0)\nmonthly_cov = np.outer(monthly_vol, monthly_vol) * corr\n\nn_months = 120\nreturns = np.random.multivariate_normal(monthly_mean, monthly_cov, size=n_months)\n\n# Estimate annualized mean and covariance from the simulated data\nmean_returns = returns.mean(axis=0) * 12.0\ncov_matrix = np.cov(returns, rowvar=False) * 12.0\n\nsummary = pd.DataFrame(\n {\n \"Annualized Return\": mean_returns,\n \"Annualized Volatility\": np.sqrt(np.diag(cov_matrix)),\n },\n index=assets,\n)\n\nsummary.style.format({\"Annualized Return\": \"{:.2%}\", \"Annualized Volatility\": \"{:.2%}\"})"
},
{
"cell_type": "markdown",
"id": "e5004295",
"metadata": {},
"source": "## Step 2 \u2014 minimize variance, capturing the return-constraint dual\n\nThe base notebook's `solve_min_variance_qp`, with one addition: we keep a handle on the `min_return` constraint and capture its **`.DualValue`** after the solve. For a continuous QP, that dual is the **sensitivity** d(variance)/d(return) \u2014 the marginal variance cost of demanding one more unit of return."
},
{
"cell_type": "code",
"id": "44ad356a",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "def solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=None, max_weight=None):\n \"\"\"Solve the min-variance QP and return the weights plus the return-constraint dual.\n\n Minimizes portfolio variance (w' * cov_matrix * w) subject to fully-invested\n weights and, when target_return is given, a minimum-return epsilon-constraint.\n The min_return constraint's .DualValue is the sensitivity d(variance)/d(return),\n accurate to cuOpt's barrier-solver tolerance (1e-8 by default).\n\n Parameters\n ----------\n cov_matrix : ndarray (n, n)\n Annualized covariance matrix (positive semidefinite).\n mean_returns : ndarray (n,)\n Annualized expected returns.\n target_return : float, optional\n Minimum portfolio return (the swept epsilon-constraint); None = unconstrained.\n max_weight : float, optional\n Upper bound on each asset weight (default 1.0).\n\n Returns\n -------\n dict\n {\"weights\", \"ret\", \"vol\", \"dual\", \"status\"}.\n \"\"\"\n n = len(mean_returns)\n prob = Problem(\"Portfolio_Optimization\")\n ub = max_weight if max_weight is not None else 1.0\n w = [prob.addVariable(lb=0.0, ub=ub, name=f\"w_{i}\") for i in range(n)]\n\n quad = None\n for i in range(n):\n for j in range(n):\n c = float(cov_matrix[i, j])\n if abs(c) > 1e-12:\n term = c * w[i] * w[j]\n quad = term if quad is None else quad + term\n prob.setObjective(quad, sense=MINIMIZE)\n\n prob.addConstraint(sum(w) == 1, name=\"fully_invested\")\n ret_con = None\n if target_return is not None:\n ret_expr = sum(float(mean_returns[i]) * w[i] for i in range(n))\n ret_con = prob.addConstraint(ret_expr >= float(target_return), name=\"min_return\")\n\n prob.solve()\n status = prob.Status.name if hasattr(prob.Status, \"name\") else str(prob.Status)\n weights = np.array([w[i].Value for i in range(n)])\n port_ret = float(mean_returns @ weights)\n port_vol = float(np.sqrt(max(weights @ cov_matrix @ weights, 0.0)))\n dual = abs(float(ret_con.DualValue)) if ret_con is not None else 0.0 # sensitivity d(var)/d(return)\n return {\"weights\": weights, \"ret\": port_ret, \"vol\": port_vol, \"dual\": dual, \"status\": status}\n\nmv = solve_min_variance_qp_dual(cov_matrix, mean_returns)\nprint(f\"Min-variance: status={mv['status']}, return={mv['ret']:.2%}, vol={mv['vol']:.2%}\")"
},
{
"cell_type": "markdown",
"id": "2a1a84e1",
"metadata": {},
"source": "## Step 3 \u2014 sweep the return floor \u2192 the frontier (and its duals)\n\nSweep the return floor \u03b5 across the achievable range; each `\u03b5` is one standard cuOpt solve, and we capture the dual at each point. cuOpt solves a quadratic objective with its **barrier (interior-point)** method (the only method that supports quadratic objectives), to 1e-8 relative accuracy by default \u2014 so the dual is accurate to that tolerance, not exact. We keep points the solver reports as `Optimal` and flag any `PrimalFeasible`."
},
{
"cell_type": "code",
"id": "5098fd42",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "min_ret = mv[\"ret\"]\nmax_ret = float(mean_returns.max())\ntargets = np.linspace(min_ret, max_ret * 0.999, 25)\n\nrets, vols, duals, flagged = [], [], [], 0\nfor t in targets:\n r = solve_min_variance_qp_dual(cov_matrix, mean_returns, target_return=t)\n if r[\"status\"] not in (\"Optimal\", \"PrimalFeasible\"):\n continue\n if r[\"status\"] != \"Optimal\":\n flagged += 1\n rets.append(r[\"ret\"]); vols.append(r[\"vol\"]); duals.append(r[\"dual\"])\n\nrets, vols, duals = map(np.array, (rets, vols, duals))\nprint(f\"Frontier points: {len(rets)} | not certified-Optimal (PrimalFeasible): {flagged}\")\nprint(f\"Sensitivity d(variance)/d(return): {duals.min():.3f} -> {duals.max():.3f} as required return rises\")"
},
{
"cell_type": "code",
"id": "4f051b88",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\naxes[0].plot(vols * 100, rets * 100, \"o-\", color=\"navy\", lw=1.6)\naxes[0].set_xlabel(\"Volatility (%)\"); axes[0].set_ylabel(\"Expected Return (%)\")\naxes[0].set_title(\"Efficient frontier (return vs risk)\"); axes[0].grid(alpha=0.3)\n\naxes[1].plot(rets * 100, duals, \"o-\", color=\"purple\", lw=1.6)\naxes[1].set_xlabel(\"Required return (%)\"); axes[1].set_ylabel(\"Sensitivity d(variance)/d(return)\")\naxes[1].set_title(\"Marginal risk cost of return (cuOpt QP dual)\"); axes[1].grid(alpha=0.3)\nplt.tight_layout(); plt.show()"
},
{
"cell_type": "markdown",
"id": "6888f83f",
"metadata": {},
"source": "## Step 4 \u2014 read the frontier\n\n- The **frontier** (left) is the return-vs-risk Pareto set \u2014 every point is a min-variance portfolio for its return floor. There's no single \"best\"; you choose where on the curve to sit.\n- The **dual** (right) is the **sensitivity** d(variance)/d(return): how much extra variance each additional unit of return costs. It rises along the frontier \u2014 the marginal cost of return steepens, which is exactly where a knee analysis pays off.\n\n### Takeaway \u2014 reusing this on your own problem\nTwo competing objectives and a solver for one of them is all you need: keep one objective, turn the other into a swept constraint (`f\u2082 \u2265 \u03b5` or `\u2264 \u03b5`), solve across the range, and read the frontier. If your code already loops over a target value, that loop **is** an \u03b5-constraint sweep \u2014 name it, collect the non-dominated points, and (for an LP or QP) read the constraint's dual for the marginal exchange rate.\n\n### Notes\n- **Synthetic data** \u2014 the base notebook's simulated universe; demonstrates the method.\n- **Barrier solver** \u2014 a quadratic objective is solved by cuOpt's barrier (interior-point) method (1e-8 relative accuracy by default), so the dual is accurate to that tolerance, not exact arithmetic; points reported `PrimalFeasible` rather than `Optimal` are flagged above and could be tightened or dropped.\n- **Duals need continuity** \u2014 these sensitivities exist because the portfolio is a continuous QP; an integer program would expose none."
},
{
"cell_type": "markdown",
"id": "b281399a",
"metadata": {},
"source": "## License\n\nSPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\nSPDX-License-Identifier: Apache-2.0\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License."
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
7 changes: 6 additions & 1 deletion portfolio_optimization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ The portfolio optimization notebook solves a portfolio optimization problem wher

- The aim is to balance expected return with the risk of losses

### 3. Advanced Portfolio Optimization
### 3. Multi-Objective Portfolio Optimization (QP)

- Traces the efficient frontier (return vs. risk) as an ε-constraint sweep.
- Reads each point's **dual**: the sensitivity d(variance)/d(return), from cuOpt's barrier (interior-point) solver.

### 4. Advanced Portfolio Optimization

For advanced portfolio optimization examples including:
- Efficient frontier construction
Expand Down
11 changes: 10 additions & 1 deletion workforce_optimization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- The shifts have different requirements.

### 2. Workforce Optimization (Multi-Objective)

The multi-objective notebook extends the MILP above into a Pareto frontier, so you see the tradeoff instead of a single plan. It traces two tradeoffs with the ε-constraint method:

- **cost vs. coverage**: sweep a coverage floor as an ε-constraint, then read the marginal cost per shift off the frontier.
- **cost vs. fairness**: sweep the fixed per-worker shift cap, a constraint whose assumed level is a candidate objective.

A MILP has no constraint duals, so the marginal cost is read off the frontier itself.
Loading