diff --git a/supplier_selection/README.md b/supplier_selection/README.md new file mode 100644 index 0000000..b0b7e80 --- /dev/null +++ b/supplier_selection/README.md @@ -0,0 +1,14 @@ +# Supplier Sourcing + +This folder contains examples of how to use NVIDIA cuOpt to solve supplier sourcing problems. The notebooks solve supplier sourcing problems using the cuOpt Python API. + +## Examples + +### 1. Multi-Objective Supplier Sourcing (QP) + +The notebook splits one order across suppliers when reliability and diversification conflict — the reliable suppliers cluster, so demanding reliability concentrates the order: + +- Minimizes **concentration risk** (a quadratic `wᵀ C w` over the allocation, high within-region correlation) subject to a fully-allocated order, a per-supplier cap, and a unit-cost budget. +- Traces the **concentration vs. reliability** frontier as an ε-constraint sweep (sweep the reliability floor). +- Reads each point's **dual**: the sensitivity d(concentration)/d(reliability) — the diversification given up per point of reliability. +- Builds the dense concentration quadratic from the matrix directly (`QuadraticExpression(qmatrix=...)`) rather than term by term. diff --git a/supplier_selection/supplier_sourcing_frontier_duals.ipynb b/supplier_selection/supplier_sourcing_frontier_duals.ipynb new file mode 100644 index 0000000..053b45e --- /dev/null +++ b/supplier_selection/supplier_sourcing_frontier_duals.ipynb @@ -0,0 +1,349 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Multi-Objective Supplier Sourcing with cuOpt Python API\n", + "\n", + "This notebook uses the cuOpt Python API to trace the **efficient frontier** of a sourcing decision — splitting one\n", + "order across suppliers when reliability and diversification genuinely conflict, and there's no single best split,\n", + "only a curve of optimal tradeoffs.\n", + "\n", + "The most reliable suppliers tend to cluster (same region, same logistics), so demanding higher reliability quietly\n", + "pushes the order toward a few correlated names — concentration risk. We trace that tradeoff with the\n", + "**ε-constraint method** and read each point's **sensitivity** (the reliability-floor dual):\n", + "\n", + "1. **Two objectives** — minimize concentration risk, maximize reliability.\n", + "2. **Keep one as the objective, constrain the other** — minimize concentration subject to `reliability ≥ ε`.\n", + "3. **Sweep ε** across the achievable reliability range; each solve is one frontier point.\n", + "4. **Read the frontier** — and, because this is a continuous QP, read each point's **dual**: the sensitivity\n", + " d(concentration)/d(reliability), how much diversification one more point of reliability costs." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Environment Setup" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import subprocess\n", + "import html\n", + "from IPython.display import display, HTML\n", + "\n", + "def 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", + "

✅ 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", + "

⚠️ GPU not detected!

\n", + "

This notebook requires a GPU runtime.

\n", + "\n", + "

If running in Google Colab:

\n", + "
    \n", + "
  1. Click on Runtime → Change runtime type
  2. \n", + "
  3. Set Hardware accelerator to GPU
  4. \n", + "
  5. Then click Save and Runtime → 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", + "\n", + "check_gpu()" + ] + }, + { + "cell_type": "code", + "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", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from cuopt.linear_programming.problem import Problem, QuadraticExpression, MINIMIZE\n", + "print(\"Imports ready (cuOpt QP solver)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 — the suppliers and their two objectives\n", + "\n", + "A simulated panel of 12 suppliers across 3 regions. Each has a **reliability** score (to maximize) and a **unit\n", + "cost**; the reliable suppliers are pricier and cluster in one region. The **concentration risk** is a quadratic\n", + "form `wᵀ C w` over the allocation `w`: `C` has a high within-region correlation and near-zero across regions, so\n", + "piling the order into one region—even across different suppliers there—reads as concentrated. Minimizing it is\n", + "what pushes the order to spread across regions (multi-sourcing)." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "np.random.seed(7)\n", + "\n", + "regions = [\"A\", \"B\", \"C\"]\n", + "region_of = [r for r in regions for _ in range(4)] # 12 suppliers, 4 per region\n", + "suppliers = [f\"{r}{i+1}\" for r in regions for i in range(4)]\n", + "n = len(suppliers)\n", + "\n", + "# Reliable suppliers cluster in region A (and cost more); region C is cheap but less reliable.\n", + "base_rel = {\"A\": 0.95, \"B\": 0.85, \"C\": 0.75}\n", + "base_cost = {\"A\": 12.0, \"B\": 9.0, \"C\": 6.0}\n", + "reliability = np.array([np.clip(base_rel[r] + np.random.normal(0, 0.02), 0.5, 0.99) for r in region_of])\n", + "unit_cost = np.array([max(2.0, base_cost[r] + np.random.normal(0, 1.0)) for r in region_of])\n", + "\n", + "# Concentration matrix: 1.0 on the diagonal, 0.6 within region, 0.05 across (dense, PSD).\n", + "within, across = 0.6, 0.05\n", + "concentration = np.array([[1.0 if i == j else (within if region_of[i] == region_of[j] else across)\n", + " for j in range(n)] for i in range(n)])\n", + "\n", + "summary = pd.DataFrame({\"Region\": region_of, \"Reliability\": reliability, \"Unit Cost\": unit_cost}, index=suppliers)\n", + "summary.style.format({\"Reliability\": \"{:.3f}\", \"Unit Cost\": \"${:.2f}\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 — minimize concentration risk, capturing the reliability-floor dual\n", + "\n", + "The model splits one order: weights `w` sum to 1, no supplier over a cap, total unit cost within budget. The\n", + "objective is the concentration quadratic `wᵀ C w`; the swept ε-constraint is a **reliability floor** whose\n", + "**`.DualValue`** we keep after each solve. For a continuous QP that dual is the **sensitivity**\n", + "d(concentration)/d(reliability) — the diversification cost of demanding one more point of reliability.\n", + "\n", + "**Building the quadratic — matrix vs term-by-term.** A dense quadratic can be built term by\n", + "term, one Python expression per matrix entry (`for i: for j: quad += c * w[i] * w[j]`) — fine for a small dense\n", + "matrix, but O(n²) Python objects as it grows. cuOpt's `QuadraticExpression` also takes the matrix **directly**\n", + "(`qmatrix=C, qvars=w`), a single vectorized construction. Both express the same `wᵀ C w`; the matrix form is the\n", + "one to reach for on a dense `C`. We use it here." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "def solve_min_concentration_qp_dual(concentration, reliability, unit_cost, budget,\n", + " target_reliability=None, max_weight=0.22):\n", + " \"\"\"Solve the min-concentration sourcing QP and return the split plus the reliability-floor dual.\n", + "\n", + " Minimizes concentration risk (w' * concentration * w) subject to a fully-allocated order\n", + " (weights sum to 1), a per-supplier cap, a unit-cost budget, and — when target_reliability is\n", + " given — a minimum-reliability epsilon-constraint. That constraint's .DualValue is the\n", + " sensitivity d(concentration)/d(reliability).\n", + "\n", + " Parameters\n", + " ----------\n", + " concentration : ndarray (n, n)\n", + " Concentration-risk matrix (symmetric, positive semidefinite).\n", + " reliability : ndarray (n,)\n", + " Per-supplier reliability scores.\n", + " unit_cost : ndarray (n,)\n", + " Per-supplier unit cost.\n", + " budget : float\n", + " Maximum allowed weighted unit cost (sum of cost_i * w_i).\n", + " target_reliability : float, optional\n", + " Minimum weighted reliability (the swept epsilon-constraint); None = unconstrained.\n", + " max_weight : float\n", + " Upper bound on each supplier's share of the order.\n", + "\n", + " Returns\n", + " -------\n", + " dict\n", + " {\"weights\", \"concentration\", \"reliability\", \"cost\", \"dual\", \"active\", \"status\"}.\n", + " \"\"\"\n", + " n = len(reliability)\n", + " prob = Problem(\"Supplier_Sourcing\")\n", + " w = [prob.addVariable(lb=0.0, ub=float(max_weight), name=f\"w_{i}\") for i in range(n)]\n", + "\n", + " # Concentration quadratic w' C w, built from the matrix directly (vs term-by-term).\n", + " quad = QuadraticExpression(qmatrix=concentration, qvars=w)\n", + " prob.setObjective(quad, sense=MINIMIZE)\n", + "\n", + " prob.addConstraint(sum(w) == 1, name=\"fully_allocated\")\n", + " prob.addConstraint(sum(float(unit_cost[i]) * w[i] for i in range(n)) <= float(budget), name=\"budget\")\n", + " rel_con = None\n", + " if target_reliability is not None:\n", + " rel_expr = sum(float(reliability[i]) * w[i] for i in range(n))\n", + " rel_con = prob.addConstraint(rel_expr >= float(target_reliability), name=\"min_reliability\")\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", + " conc = float(weights @ concentration @ weights)\n", + " dual = abs(float(rel_con.DualValue)) if rel_con is not None else 0.0 # sensitivity d(conc)/d(reliability)\n", + " return {\"weights\": weights, \"concentration\": conc, \"reliability\": float(reliability @ weights),\n", + " \"cost\": float(unit_cost @ weights), \"dual\": dual,\n", + " \"active\": int((weights > 1e-4).sum()), \"status\": status}\n", + "\n", + "BUDGET = 11.5\n", + "base = solve_min_concentration_qp_dual(concentration, reliability, unit_cost, BUDGET)\n", + "print(f\"Min-concentration split: status={base['status']}, concentration={base['concentration']:.4f}, \"\n", + " f\"reliability={base['reliability']:.3f}, active suppliers={base['active']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 — sweep the reliability floor → the frontier (and its duals)\n", + "\n", + "Sweep the reliability floor ε from the unconstrained mix’s reliability up toward the achievable ceiling; each ε is\n", + "one standard cuOpt solve. Floors past what the caps and budget allow are simply infeasible — we skip them and keep\n", + "the feasible frontier." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "rel_min = base[\"reliability\"]\n", + "rel_max = float(reliability.max())\n", + "targets = np.linspace(rel_min + 0.002, rel_max * 0.999, 25)\n", + "\n", + "rels, concs, duals, actives, flagged = [], [], [], [], 0\n", + "for t in targets:\n", + " r = solve_min_concentration_qp_dual(concentration, reliability, unit_cost, BUDGET, target_reliability=t)\n", + " if r[\"status\"] not in (\"Optimal\", \"PrimalFeasible\"):\n", + " continue # floor beyond the caps/budget — infeasible, skip\n", + " if r[\"status\"] != \"Optimal\":\n", + " flagged += 1\n", + " rels.append(r[\"reliability\"]); concs.append(r[\"concentration\"])\n", + " duals.append(r[\"dual\"]); actives.append(r[\"active\"])\n", + "\n", + "rels, concs, duals, actives = map(np.array, (rels, concs, duals, actives))\n", + "print(f\"frontier points: {len(rels)} | not certified-Optimal (PrimalFeasible): {flagged}\")\n", + "print(f\"Active suppliers: {actives.max()} (most diversified) -> {actives.min()} (most concentrated) as the floor rises\")\n", + "print(f\"Sensitivity d(concentration)/d(reliability): {duals.min():.3f} -> {duals.max():.3f}\")" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(13, 5))\n", + "sc = axes[0].scatter(rels * 100, concs, c=actives, cmap=\"viridis\", s=60, zorder=3)\n", + "axes[0].plot(rels * 100, concs, \"-\", color=\"navy\", lw=1.0, alpha=0.5, zorder=2)\n", + "axes[0].set_xlabel(\"Required reliability (%)\"); axes[0].set_ylabel(\"Concentration risk (wᵀ C w)\")\n", + "axes[0].set_title(\"Sourcing frontier (concentration vs reliability)\"); axes[0].grid(alpha=0.3)\n", + "fig.colorbar(sc, ax=axes[0], label=\"active suppliers\")\n", + "\n", + "axes[1].plot(rels * 100, duals, \"o-\", color=\"purple\", lw=1.6)\n", + "axes[1].set_xlabel(\"Required reliability (%)\"); axes[1].set_ylabel(\"Sensitivity d(concentration)/d(reliability)\")\n", + "axes[1].set_title(\"Marginal diversification cost of reliability (cuOpt QP dual)\"); axes[1].grid(alpha=0.3)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4 — read the frontier\n", + "\n", + "- The **frontier** (left) is the concentration-vs-reliability Pareto set — each point the least-concentrated split\n", + " that meets its reliability floor. There's no single \"best\"; you choose where to sit. The color shows the order\n", + " spreading across **fewer suppliers** as you demand more reliability — the reliable names cluster, so reliability\n", + " and diversification pull apart.\n", + "- The **dual** (right) is the **sensitivity** d(concentration)/d(reliability): the diversification given up for one\n", + " more point of reliability. It steepens along the frontier — the marginal cost of reliability rises, which is\n", + " exactly where a knee analysis pays off.\n", + "\n", + "### Takeaway — reusing this on your own problem\n", + "Two competing objectives and a solver for one of them is all you need: keep one objective, turn the other into a\n", + "swept constraint (`f₂ ≥ ε` or `≤ ε`), solve across the range, collect the non-dominated points, and — for an LP or\n", + "QP — read the constraint's dual for the marginal exchange rate. The **budget** here is a fixed constraint, but it\n", + "carries a dual too: re-read `budget`'s `.DualValue` for the diversification bought per dollar of budget. And when\n", + "the objective is a dense quadratic, build it from the matrix (`QuadraticExpression(qmatrix=...)`) rather than term\n", + "by term." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## License\n", + "\n", + "SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\n", + "SPDX-License-Identifier: Apache-2.0\n", + "\n", + "Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "you may not use this file except in compliance with the License.\n", + "You may obtain a copy of the License at\n", + "\n", + "http://www.apache.org/licenses/LICENSE-2.0\n", + "\n", + "Unless required by applicable law or agreed to in writing, software\n", + "distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "See the License for the specific language governing permissions and\n", + "limitations under the License." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}