From 44e0d7a142372a30025003dbc5e5bf66e933866a Mon Sep 17 00:00:00 2001 From: Matthieu Monsch Date: Sun, 8 Sep 2024 10:54:43 -0700 Subject: [PATCH] refactor: sanitize notebooks --- README.md | 6 +- resources/examples/bin-packing.ipynb | 429 +++-- .../consecutive-shift-scheduling.ipynb | 737 +++----- resources/examples/debt-simplification.ipynb | 1307 +++++---------- resources/examples/doctor-scheduling.ipynb | 1023 ++++-------- .../examples/fantasy-premier-league.ipynb | 1484 ++++------------- resources/examples/job-shop-scheduling.ipynb | 483 +++--- resources/examples/lot-sizing.ipynb | 654 +++----- .../examples/portfolio-optimization.ipynb | 825 +++------ resources/examples/product-allocation.ipynb | 862 +++------- resources/examples/sudoku.ipynb | 1289 +++++--------- .../guides/managing-modeling-complexity.ipynb | 630 +++---- resources/guides/uploading-a-model.ipynb | 522 +++--- .../using-a-self-hosted-api-server.ipynb | 200 +-- resources/guides/welcome.ipynb | 650 +++----- resources/templates/simple.ipynb | 232 ++- 16 files changed, 3737 insertions(+), 7596 deletions(-) diff --git a/README.md b/README.md index 6bbf5fc..8b798d1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Optimization notebooks This repository contains various notebooks solving optimization problems with -the [Opvious](https://www.opvious.io). Most can be run without an account directly from -your browser when accessed via their +[Opvious](https://www.opvious.io). Most can be run directly from your browser +when accessed via their [opvious.io/notebooks](https://www.opvious.io/notebooks/retro) URL. -+ Featured guides: ++ Guides: + [Getting started](https://www.opvious.io/notebooks/retro/notebooks/?path=guides/welcome.ipynb) (start here!) + [Deploying a model](https://www.opvious.io/notebooks/retro/notebooks/?path=guides/uploading-a-model.ipynb) + [Self-hosting the API server](https://www.opvious.io/notebooks/retro/notebooks/?path=guides/using-a-self-hosted-api-server.ipynb) diff --git a/resources/examples/bin-packing.ipynb b/resources/examples/bin-packing.ipynb index 14c5d70..119b0d2 100644 --- a/resources/examples/bin-packing.ipynb +++ b/resources/examples/bin-packing.ipynb @@ -1,235 +1,210 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "b684e857", - "metadata": {}, - "source": [ - "# Bin packing\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser.\n", - "
\n", - "\n", - "This notebook contains a mixed-integer program implementation of the canonical [bin packing problem](https://en.wikipedia.org/wiki/Bin_packing_problem)." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "4de08d3a", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "943d9b31", - "metadata": {}, - "source": [ - "## Formulation\n", - "\n", - "The first step is to formulate the problem problem using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "ce1a5f1a-f6eb-499a-98b7-bd3aefe5687a", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class BinPacking(om.Model):\n", - " \"\"\"Bin-packing MIP formulation\"\"\"\n", - " \n", - " items = om.Dimension() # Set of items to be put into bins\n", - " weight = om.Parameter.non_negative(items) # Weight of each item\n", - " bins = om.interval(1, om.size(items), name=\"B\") # Set of bins\n", - " max_weight = om.Parameter.non_negative() # Maximum weight allowed in a bin\n", - " assigned = om.Variable.indicator(bins, items, qualifiers=['bins']) # 1 if an item is assigned to a given bin, 0 otherwise\n", - " used = om.Variable.indicator(bins) # 1 if a bin is used, 0 otherwise\n", - "\n", - " @om.constraint\n", - " def each_item_is_assigned_once(self):\n", - " \"\"\"Constrains each item to be assigned to exactly one bin\"\"\"\n", - " for i in self.items:\n", - " yield om.total(self.assigned(b, i) for b in self.bins) == 1\n", - "\n", - " @om.constraint\n", - " def bin_weights_are_below_max(self):\n", - " \"\"\"Constrains each bin's total weight to be below the maximum allowed\"\"\"\n", - " for b in self.bins:\n", - " bin_weight = om.total(self.weight(i) * self.assigned(b, i) for i in self.items)\n", - " yield bin_weight <= self.used(b) * self.max_weight()\n", - "\n", - " @om.objective\n", - " def minimize_bins_used(self):\n", - " \"\"\"Minimizes the total number of bins with at least one item\"\"\"\n", - " return om.total(self.used(b) for b in self.bins)\n", - "\n", - "model = BinPacking()" - ] - }, - { - "cell_type": "markdown", - "id": "836c8e75-c502-4602-bfab-c1be4441b2f7", - "metadata": {}, - "source": [ - "We can view its mathematical definitions by printing its `specification`." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "dc546583-d65d-474d-b84b-e654e7135234", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "b684e857", + "metadata": {}, + "source": [ + "# Bin packing\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser.\n", + "
\n", + "\n", + "This notebook contains a mixed-integer program implementation of the canonical [bin packing problem](https://en.wikipedia.org/wiki/Bin_packing_problem)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4de08d3a", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "BinPacking\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{items}&: I \\\\\n", - " \\S^p_\\mathrm{weight}&: w \\in \\mathbb{R}_+^{I} \\\\\n", - " \\S^a&: B \\doteq \\{ 1 \\ldots \\# I \\} \\\\\n", - " \\S^p_\\mathrm{maxWeight}&: w^\\mathrm{max} \\in \\mathbb{R}_+ \\\\\n", - " \\S^v_\\mathrm{assigned[bins]}&: \\alpha \\in \\{0, 1\\}^{B \\times I} \\\\\n", - " \\S^v_\\mathrm{used}&: \\psi \\in \\{0, 1\\}^{B} \\\\\n", - " \\S^c_\\mathrm{eachItemIsAssignedOnce}&: \\forall i \\in I, \\sum_{b \\in B} \\alpha_{b,i} = 1 \\\\\n", - " \\S^c_\\mathrm{binWeightsAreBelowMax}&: \\forall b \\in B, \\sum_{i \\in I} w_{i} \\alpha_{b,i} \\leq \\psi_{b} w^\\mathrm{max} \\\\\n", - " \\S^o_\\mathrm{minimizeBinsUsed}&: \\min \\sum_{b \\in B} \\psi_{b} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "markdown", + "id": "943d9b31", + "metadata": {}, + "source": [ + "## Formulation\n", + "\n", + "The first step is to formulate the problem problem using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ce1a5f1a-f6eb-499a-98b7-bd3aefe5687a", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious.modeling as om\n", + "\n", + "class BinPacking(om.Model):\n", + " \"\"\"Bin-packing MIP formulation\"\"\"\n", + " \n", + " items = om.Dimension() # Set of items to be put into bins\n", + " weight = om.Parameter.non_negative(items) # Weight of each item\n", + " bins = om.interval(1, om.size(items), name=\"B\") # Set of bins\n", + " max_weight = om.Parameter.non_negative() # Maximum weight allowed in a bin\n", + " assigned = om.Variable.indicator(bins, items, qualifiers=['bins']) # 1 if an item is assigned to a given bin, 0 otherwise\n", + " used = om.Variable.indicator(bins) # 1 if a bin is used, 0 otherwise\n", + "\n", + " @om.constraint\n", + " def each_item_is_assigned_once(self):\n", + " \"\"\"Constrains each item to be assigned to exactly one bin\"\"\"\n", + " for i in self.items:\n", + " yield om.total(self.assigned(b, i) for b in self.bins) == 1\n", + "\n", + " @om.constraint\n", + " def bin_weights_are_below_max(self):\n", + " \"\"\"Constrains each bin's total weight to be below the maximum allowed\"\"\"\n", + " for b in self.bins:\n", + " bin_weight = om.total(self.weight(i) * self.assigned(b, i) for i in self.items)\n", + " yield bin_weight <= self.used(b) * self.max_weight()\n", + "\n", + " @om.objective\n", + " def minimize_bins_used(self):\n", + " \"\"\"Minimizes the total number of bins with at least one item\"\"\"\n", + " return om.total(self.used(b) for b in self.bins)\n", + "\n", + "model = BinPacking()" + ] + }, + { + "cell_type": "markdown", + "id": "836c8e75-c502-4602-bfab-c1be4441b2f7", + "metadata": {}, + "source": [ + "We can view its mathematical definitions by printing its `specification`." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "dc546583-d65d-474d-b84b-e654e7135234", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nBinPacking\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{items}&: I \\\\\n \\S^p_\\mathrm{weight}&: w \\in \\mathbb{R}_+^{I} \\\\\n \\S^a&: B \\doteq \\{ 1 \\ldots \\# I \\} \\\\\n \\S^p_\\mathrm{maxWeight}&: w^\\mathrm{max} \\in \\mathbb{R}_+ \\\\\n \\S^v_\\mathrm{assigned[bins]}&: \\alpha \\in \\{0, 1\\}^{B \\times I} \\\\\n \\S^v_\\mathrm{used}&: \\psi \\in \\{0, 1\\}^{B} \\\\\n \\S^c_\\mathrm{eachItemIsAssignedOnce}&: \\forall i \\in I, \\sum_{b \\in B} \\alpha_{b,i} = 1 \\\\\n \\S^c_\\mathrm{binWeightsAreBelowMax}&: \\forall b \\in B, \\sum_{i \\in I} w_{i} \\alpha_{b,i} \\leq \\psi_{b} w^\\mathrm{max} \\\\\n \\S^o_\\mathrm{minimizeBinsUsed}&: \\min \\sum_{b \\in B} \\psi_{b} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{items}&: I \\\\\\\\\\n \\\\S^p_\\\\mathrm{weight}&: w \\\\in \\\\mathbb{R}_+^{I} \\\\\\\\\\n \\\\S^a&: B \\\\doteq \\\\{ 1 \\\\ldots \\\\# I \\\\} \\\\\\\\\\n \\\\S^p_\\\\mathrm{maxWeight}&: w^\\\\mathrm{max} \\\\in \\\\mathbb{R}_+ \\\\\\\\\\n \\\\S^v_\\\\mathrm{assigned[bins]}&: \\\\alpha \\\\in \\\\{0, 1\\\\}^{B \\\\times I} \\\\\\\\\\n \\\\S^v_\\\\mathrm{used}&: \\\\psi \\\\in \\\\{0, 1\\\\}^{B} \\\\\\\\\\n \\\\S^c_\\\\mathrm{eachItemIsAssignedOnce}&: \\\\forall i \\\\in I, \\\\sum_{b \\\\in B} \\\\alpha_{b,i} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{binWeightsAreBelowMax}&: \\\\forall b \\\\in B, \\\\sum_{i \\\\in I} w_{i} \\\\alpha_{b,i} \\\\leq \\\\psi_{b} w^\\\\mathrm{max} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeBinsUsed}&: \\\\min \\\\sum_{b \\\\in B} \\\\psi_{b} \\\\\\\\\\n\\\\end{align*}\\n$$', title='BinPacking')], description='Bin-packing MIP formulation', annotation=None)" + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{items}&: I \\\\\\\\\\n \\\\S^p_\\\\mathrm{weight}&: w \\\\in \\\\mathbb{R}_+^{I} \\\\\\\\\\n \\\\S^a&: B \\\\doteq \\\\{ 1 \\\\ldots \\\\# I \\\\} \\\\\\\\\\n \\\\S^p_\\\\mathrm{maxWeight}&: w^\\\\mathrm{max} \\\\in \\\\mathbb{R}_+ \\\\\\\\\\n \\\\S^v_\\\\mathrm{assigned[bins]}&: \\\\alpha \\\\in \\\\{0, 1\\\\}^{B \\\\times I} \\\\\\\\\\n \\\\S^v_\\\\mathrm{used}&: \\\\psi \\\\in \\\\{0, 1\\\\}^{B} \\\\\\\\\\n \\\\S^c_\\\\mathrm{eachItemIsAssignedOnce}&: \\\\forall i \\\\in I, \\\\sum_{b \\\\in B} \\\\alpha_{b,i} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{binWeightsAreBelowMax}&: \\\\forall b \\\\in B, \\\\sum_{i \\\\in I} w_{i} \\\\alpha_{b,i} \\\\leq \\\\psi_{b} w^\\\\mathrm{max} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeBinsUsed}&: \\\\min \\\\sum_{b \\\\in B} \\\\psi_{b} \\\\\\\\\\n\\\\end{align*}\\n$$', title='BinPacking')], description='Bin-packing MIP formulation', annotation=None)" + "source": [ + "model.specification()" ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "7e1a8819", - "metadata": {}, - "source": [ - "## Application\n", - "\n", - "Now that we have formulated the problem, it takes just a few lines to get solutions. Since all solves run remotely--no local solver installation required--we can run it from any browser." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "adad2341", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "async def optimal_assignment(bin_max_weight, item_weights):\n", - " \"\"\"Returns a grouping of items which minimizes the number of bins used\n", - " \n", - " Args:\n", - " bin_max_weight: The maximum allowable total weight for all items assigned to a given bin\n", - " item_weights: Mapping from item name to its (non-negative) weight\n", - " \"\"\"\n", - " problem = opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={'weight': item_weights, 'maxWeight': bin_max_weight},\n", - " )\n", - " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - " solution = await client.solve(problem)\n", - " assignment = solution.outputs.variable('assigned')\n", - " return list(assignment.reset_index().groupby('bins')['items'].agg(tuple))" - ] - }, - { - "cell_type": "markdown", - "id": "8a316944", - "metadata": {}, - "source": [ - "Let's try our implementation on a simple example with 3 items." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "337b269c", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "7e1a8819", + "metadata": {}, + "source": [ + "## Application\n", + "\n", + "Now that we have formulated the problem, it takes just a few lines to get solutions. Since all solves run remotely--no local solver installation required--we can run it from any browser." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "adad2341", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "async def optimal_assignment(bin_max_weight, item_weights):\n", + " \"\"\"Returns a grouping of items which minimizes the number of bins used\n", + " \n", + " Args:\n", + " bin_max_weight: The maximum allowable total weight for all items assigned to a given bin\n", + " item_weights: Mapping from item name to its (non-negative) weight\n", + " \"\"\"\n", + " problem = opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={'weight': item_weights, 'maxWeight': bin_max_weight},\n", + " )\n", + " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + " solution = await client.solve(problem)\n", + " assignment = solution.outputs.variable('assigned')\n", + " return list(assignment.reset_index().groupby('bins')['items'].agg(tuple))" + ] + }, + { + "cell_type": "markdown", + "id": "8a316944", + "metadata": {}, + "source": [ + "Let's try our implementation on a simple example with 3 items." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "337b269c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": "[('heavy',), ('light', 'medium')]" + }, + "execution_count": 57, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await optimal_assignment(15, {\n", + " 'light': 5,\n", + " 'medium': 10,\n", + " 'heavy': 15,\n", + "})" + ] + }, { - "data": { - "text/plain": [ - "[('heavy',), ('light', 'medium')]" + "cell_type": "markdown", + "id": "def23c1b-ed9a-4f60-a751-6468d620138a", + "metadata": {}, + "source": [ + "That's it for this example! From here, you might be interested in browsing our [other notebooks](https://www.opvious.io/notebooks/retro)." ] - }, - "execution_count": 57, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "eb97d7f7-66f4-4904-aba9-169e1fa26ad6", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await optimal_assignment(15, {\n", - " 'light': 5,\n", - " 'medium': 10,\n", - " 'heavy': 15,\n", - "})" - ] - }, - { - "cell_type": "markdown", - "id": "def23c1b-ed9a-4f60-a751-6468d620138a", - "metadata": {}, - "source": [ - "That's it for this example! From here, you might be interested in browsing our [other notebooks](https://www.opvious.io/notebooks/retro)." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "eb97d7f7-66f4-4904-aba9-169e1fa26ad6", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/consecutive-shift-scheduling.ipynb b/resources/examples/consecutive-shift-scheduling.ipynb index 2bbcdd9..7430a2b 100644 --- a/resources/examples/consecutive-shift-scheduling.ipynb +++ b/resources/examples/consecutive-shift-scheduling.ipynb @@ -1,539 +1,218 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "4070080c", - "metadata": {}, - "source": [ - "# Consecutive shift scheduling\n", - "\n", - "
\n", - " ⓘ The code in this notebook requires a valid Opvious account. You may execute it from your browser here if you update the client's creation below to use an explicit API token corresponding to your account.\n", - "
" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "8810afaa", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "b51d0bcf", - "metadata": {}, - "source": [ - "## Formulation" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "b4314a0c", - "metadata": {}, - "outputs": [ + "cells": [ { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "ConsecutiveShiftScheduling\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{employees}&: E \\\\\n", - " \\S^d_\\mathrm{shifts}&: S \\\\\n", - " \\S^p_\\mathrm{level}&: l \\in \\mathbb{N}^{E} \\\\\n", - " \\S^p_\\mathrm{resource}&: r \\in \\mathbb{N}^{S} \\\\\n", - " \\S^p_\\mathrm{horizon}&: h \\in \\mathbb{N} \\\\\n", - " \\S^a&: D \\doteq \\{ 1 \\ldots h \\} \\\\\n", - " \\S^v_\\mathrm{schedule[days]}&: \\sigma \\in \\{0, 1\\}^{D \\times E \\times S} \\\\\n", - " \\S^o_\\mathrm{maximizeTotalLevel}&: \\max \\sum_{d \\in D, e \\in E, s \\in S} l_{e} \\sigma_{d,e,s} \\\\\n", - " \\S^c_\\mathrm{atMostOneShift}&: \\forall d \\in D, e \\in E, \\sum_{s \\in S} \\sigma_{d,e,s} \\leq 1 \\\\\n", - " \\S^c_\\mathrm{enoughResource}&: \\forall d \\in D, s \\in S, \\sum_{e \\in E} \\sigma_{d,e,s} \\geq r_{s} \\\\\n", - " \\S^c_\\mathrm{sameConsecutiveShift}&: \\forall d \\in D, e \\in E, s \\in S \\mid d < h, \\sigma_{d,e,s} + \\sum_{s' \\in S \\mid s' \\neq s} \\sigma_{d + 1,e,s'} \\leq 1 \\\\\n", - " \\S^c_\\mathrm{rotatingShift}&: \\forall d \\in D, e \\in E, s \\in S \\mid d < h - 13, \\sigma_{d,e,s} + \\sigma_{d + 7,e,s} + \\sigma_{d + 14,e,s} \\leq 2 \\\\\n", - " \\S^c_\\mathrm{atMostFiveShiftsPerWeek}&: \\forall d \\in D, e \\in E \\mid d < h - 5, \\sum_{x \\in \\{ d \\ldots d + 6 \\}} \\lambda_{x,e} \\geq 2 \\\\\n", - " \\S^c_\\mathrm{consecutiveTimeOff}&: \\forall d \\in D, e \\in E \\mid d < h - 1, \\lambda_{d,e} - \\lambda_{d + 1,e} + \\lambda_{d + 2,e} \\geq 0 \\\\\n", - " \\S^a&: \\forall d \\in D, e \\in E, \\lambda_{d,e} \\doteq 1 - \\sum_{s \\in S} \\sigma_{d,e,s} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "markdown", + "id": "4070080c", + "metadata": {}, + "source": [ + "# Consecutive shift scheduling\n", + "\n", + "
\n", + " ⓘ The code in this notebook requires a valid Opvious account. You may execute it from your browser here if you update the client's creation below to use an explicit API token corresponding to your account.\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8810afaa", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "b51d0bcf", + "metadata": {}, + "source": [ + "## Formulation" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b4314a0c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nConsecutiveShiftScheduling\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{employees}&: E \\\\\n \\S^d_\\mathrm{shifts}&: S \\\\\n \\S^p_\\mathrm{level}&: l \\in \\mathbb{N}^{E} \\\\\n \\S^p_\\mathrm{resource}&: r \\in \\mathbb{N}^{S} \\\\\n \\S^p_\\mathrm{horizon}&: h \\in \\mathbb{N} \\\\\n \\S^a&: D \\doteq \\{ 1 \\ldots h \\} \\\\\n \\S^v_\\mathrm{schedule[days]}&: \\sigma \\in \\{0, 1\\}^{D \\times E \\times S} \\\\\n \\S^o_\\mathrm{maximizeTotalLevel}&: \\max \\sum_{d \\in D, e \\in E, s \\in S} l_{e} \\sigma_{d,e,s} \\\\\n \\S^c_\\mathrm{atMostOneShift}&: \\forall d \\in D, e \\in E, \\sum_{s \\in S} \\sigma_{d,e,s} \\leq 1 \\\\\n \\S^c_\\mathrm{enoughResource}&: \\forall d \\in D, s \\in S, \\sum_{e \\in E} \\sigma_{d,e,s} \\geq r_{s} \\\\\n \\S^c_\\mathrm{sameConsecutiveShift}&: \\forall d \\in D, e \\in E, s \\in S \\mid d < h, \\sigma_{d,e,s} + \\sum_{s' \\in S \\mid s' \\neq s} \\sigma_{d + 1,e,s'} \\leq 1 \\\\\n \\S^c_\\mathrm{rotatingShift}&: \\forall d \\in D, e \\in E, s \\in S \\mid d < h - 13, \\sigma_{d,e,s} + \\sigma_{d + 7,e,s} + \\sigma_{d + 14,e,s} \\leq 2 \\\\\n \\S^c_\\mathrm{atMostFiveShiftsPerWeek}&: \\forall d \\in D, e \\in E \\mid d < h - 5, \\sum_{x \\in \\{ d \\ldots d + 6 \\}} \\lambda_{x,e} \\geq 2 \\\\\n \\S^c_\\mathrm{consecutiveTimeOff}&: \\forall d \\in D, e \\in E \\mid d < h - 1, \\lambda_{d,e} - \\lambda_{d + 1,e} + \\lambda_{d + 2,e} \\geq 0 \\\\\n \\S^a&: \\forall d \\in D, e \\in E, \\lambda_{d,e} \\doteq 1 - \\sum_{s \\in S} \\sigma_{d,e,s} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{employees}&: E \\\\\\\\\\n \\\\S^d_\\\\mathrm{shifts}&: S \\\\\\\\\\n \\\\S^p_\\\\mathrm{level}&: l \\\\in \\\\mathbb{N}^{E} \\\\\\\\\\n \\\\S^p_\\\\mathrm{resource}&: r \\\\in \\\\mathbb{N}^{S} \\\\\\\\\\n \\\\S^p_\\\\mathrm{horizon}&: h \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^a&: D \\\\doteq \\\\{ 1 \\\\ldots h \\\\} \\\\\\\\\\n \\\\S^v_\\\\mathrm{schedule[days]}&: \\\\sigma \\\\in \\\\{0, 1\\\\}^{D \\\\times E \\\\times S} \\\\\\\\\\n \\\\S^o_\\\\mathrm{maximizeTotalLevel}&: \\\\max \\\\sum_{d \\\\in D, e \\\\in E, s \\\\in S} l_{e} \\\\sigma_{d,e,s} \\\\\\\\\\n \\\\S^c_\\\\mathrm{atMostOneShift}&: \\\\forall d \\\\in D, e \\\\in E, \\\\sum_{s \\\\in S} \\\\sigma_{d,e,s} \\\\leq 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{enoughResource}&: \\\\forall d \\\\in D, s \\\\in S, \\\\sum_{e \\\\in E} \\\\sigma_{d,e,s} \\\\geq r_{s} \\\\\\\\\\n \\\\S^c_\\\\mathrm{sameConsecutiveShift}&: \\\\forall d \\\\in D, e \\\\in E, s \\\\in S \\\\mid d < h, \\\\sigma_{d,e,s} + \\\\sum_{s' \\\\in S \\\\mid s' \\\\neq s} \\\\sigma_{d + 1,e,s'} \\\\leq 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{rotatingShift}&: \\\\forall d \\\\in D, e \\\\in E, s \\\\in S \\\\mid d < h - 13, \\\\sigma_{d,e,s} + \\\\sigma_{d + 7,e,s} + \\\\sigma_{d + 14,e,s} \\\\leq 2 \\\\\\\\\\n \\\\S^c_\\\\mathrm{atMostFiveShiftsPerWeek}&: \\\\forall d \\\\in D, e \\\\in E \\\\mid d < h - 5, \\\\sum_{x \\\\in \\\\{ d \\\\ldots d + 6 \\\\}} \\\\lambda_{x,e} \\\\geq 2 \\\\\\\\\\n \\\\S^c_\\\\mathrm{consecutiveTimeOff}&: \\\\forall d \\\\in D, e \\\\in E \\\\mid d < h - 1, \\\\lambda_{d,e} - \\\\lambda_{d + 1,e} + \\\\lambda_{d + 2,e} \\\\geq 0 \\\\\\\\\\n \\\\S^a&: \\\\forall d \\\\in D, e \\\\in E, \\\\lambda_{d,e} \\\\doteq 1 - \\\\sum_{s \\\\in S} \\\\sigma_{d,e,s} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='ConsecutiveShiftScheduling')], description='MIP model to match employees to shifts', annotation=None)" + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{employees}&: E \\\\\\\\\\n \\\\S^d_\\\\mathrm{shifts}&: S \\\\\\\\\\n \\\\S^p_\\\\mathrm{level}&: l \\\\in \\\\mathbb{N}^{E} \\\\\\\\\\n \\\\S^p_\\\\mathrm{resource}&: r \\\\in \\\\mathbb{N}^{S} \\\\\\\\\\n \\\\S^p_\\\\mathrm{horizon}&: h \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^a&: D \\\\doteq \\\\{ 1 \\\\ldots h \\\\} \\\\\\\\\\n \\\\S^v_\\\\mathrm{schedule[days]}&: \\\\sigma \\\\in \\\\{0, 1\\\\}^{D \\\\times E \\\\times S} \\\\\\\\\\n \\\\S^o_\\\\mathrm{maximizeTotalLevel}&: \\\\max \\\\sum_{d \\\\in D, e \\\\in E, s \\\\in S} l_{e} \\\\sigma_{d,e,s} \\\\\\\\\\n \\\\S^c_\\\\mathrm{atMostOneShift}&: \\\\forall d \\\\in D, e \\\\in E, \\\\sum_{s \\\\in S} \\\\sigma_{d,e,s} \\\\leq 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{enoughResource}&: \\\\forall d \\\\in D, s \\\\in S, \\\\sum_{e \\\\in E} \\\\sigma_{d,e,s} \\\\geq r_{s} \\\\\\\\\\n \\\\S^c_\\\\mathrm{sameConsecutiveShift}&: \\\\forall d \\\\in D, e \\\\in E, s \\\\in S \\\\mid d < h, \\\\sigma_{d,e,s} + \\\\sum_{s' \\\\in S \\\\mid s' \\\\neq s} \\\\sigma_{d + 1,e,s'} \\\\leq 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{rotatingShift}&: \\\\forall d \\\\in D, e \\\\in E, s \\\\in S \\\\mid d < h - 13, \\\\sigma_{d,e,s} + \\\\sigma_{d + 7,e,s} + \\\\sigma_{d + 14,e,s} \\\\leq 2 \\\\\\\\\\n \\\\S^c_\\\\mathrm{atMostFiveShiftsPerWeek}&: \\\\forall d \\\\in D, e \\\\in E \\\\mid d < h - 5, \\\\sum_{x \\\\in \\\\{ d \\\\ldots d + 6 \\\\}} \\\\lambda_{x,e} \\\\geq 2 \\\\\\\\\\n \\\\S^c_\\\\mathrm{consecutiveTimeOff}&: \\\\forall d \\\\in D, e \\\\in E \\\\mid d < h - 1, \\\\lambda_{d,e} - \\\\lambda_{d + 1,e} + \\\\lambda_{d + 2,e} \\\\geq 0 \\\\\\\\\\n \\\\S^a&: \\\\forall d \\\\in D, e \\\\in E, \\\\lambda_{d,e} \\\\doteq 1 - \\\\sum_{s \\\\in S} \\\\sigma_{d,e,s} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='ConsecutiveShiftScheduling')], description='MIP model to match employees to shifts', annotation=None)" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class ConsecutiveShiftScheduling(om.Model):\n", + " \"\"\"MIP model to match employees to shifts\"\"\"\n", + " \n", + " employees = om.Dimension()\n", + " shifts = om.Dimension()\n", + " level = om.Parameter.natural(employees) # Employee level\n", + " resource = om.Parameter.natural(shifts) # Minimum number of employees per shift\n", + " horizon = om.Parameter.natural() # Number of days to schedule\n", + " days = om.interval(1, horizon(), name=\"D\")\n", + " schedule = om.Variable.indicator(days, employees, shifts, qualifiers=[\"days\"])\n", + "\n", + " @om.objective\n", + " def maximize_total_level(self):\n", + " return om.total(\n", + " self.level(e) * self.schedule(d, e, s)\n", + " for d, e, s in self.days * self.employees * self.shifts\n", + " )\n", + " \n", + " @om.constraint\n", + " def at_most_one_shift(self):\n", + " \"\"\"Each employee works at most one shift per day\"\"\"\n", + " for d, e in self.days * self.employees:\n", + " yield om.total(self.schedule(d, e, s) <= 1 for s in self.shifts)\n", + " \n", + " @om.constraint\n", + " def enough_resource(self):\n", + " \"\"\"We have enough employees for each shift\"\"\"\n", + " for d, s in self.days * self.shifts:\n", + " yield om.total(self.schedule(d, e, s) >= self.resource(s) for e in self.employees)\n", + " \n", + " @om.constraint\n", + " def same_consecutive_shift(self):\n", + " \"\"\"Employees keep the same shift on consecutive work days\"\"\"\n", + " for d, e, s in self.days * self.employees * self.shifts:\n", + " if d < self.horizon():\n", + " yield self.schedule(d, e, s) + om.total(self.schedule(d+1, e, t) for t in self.shifts if t != s) <= 1\n", + "\n", + " @om.constraint\n", + " def rotating_shift(self):\n", + " \"\"\"Employees rotate shifts at least once every two weeks\"\"\"\n", + " for d, e, s in self.days * self.employees * self.shifts:\n", + " if d < self.horizon() - 13:\n", + " yield self.schedule(d, e, s) + self.schedule(d+7, e, s) + self.schedule(d+14, e, s) <= 2\n", + "\n", + " @om.alias(r\"\\lambda\", days, employees)\n", + " def unscheduled(self, d, e):\n", + " \"\"\"Convenience expression indicating whether an employee is off on a given day\"\"\"\n", + " return 1 - om.total(self.schedule(d, e, s) for s in self.shifts)\n", + " \n", + " @om.constraint\n", + " def at_most_five_shifts_per_week(self):\n", + " \"\"\"Each employee works at most 5 days per week\"\"\"\n", + " for d, e in self.days * self.employees:\n", + " if d < self.horizon() - 5:\n", + " yield om.total(self.unscheduled(f, e) for f in om.interval(d, d+6)) >= 2\n", + " \n", + " @om.constraint\n", + " def consecutive_time_off(self):\n", + " \"\"\"Employees have at least two days off at a time\"\"\"\n", + " for d, e in self.days * self.employees:\n", + " if d < self.horizon() - 1:\n", + " yield self.unscheduled(d, e) - self.unscheduled(d+1, e) + self.unscheduled(d+2,e) >= 0\n", + "\n", + " \n", + "model = ConsecutiveShiftScheduling()\n", + "model.specification()" ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class ConsecutiveShiftScheduling(om.Model):\n", - " \"\"\"MIP model to match employees to shifts\"\"\"\n", - " \n", - " employees = om.Dimension()\n", - " shifts = om.Dimension()\n", - " level = om.Parameter.natural(employees) # Employee level\n", - " resource = om.Parameter.natural(shifts) # Minimum number of employees per shift\n", - " horizon = om.Parameter.natural() # Number of days to schedule\n", - " days = om.interval(1, horizon(), name=\"D\")\n", - " schedule = om.Variable.indicator(days, employees, shifts, qualifiers=[\"days\"])\n", - "\n", - " @om.objective\n", - " def maximize_total_level(self):\n", - " return om.total(\n", - " self.level(e) * self.schedule(d, e, s)\n", - " for d, e, s in self.days * self.employees * self.shifts\n", - " )\n", - " \n", - " @om.constraint\n", - " def at_most_one_shift(self):\n", - " \"\"\"Each employee works at most one shift per day\"\"\"\n", - " for d, e in self.days * self.employees:\n", - " yield om.total(self.schedule(d, e, s) <= 1 for s in self.shifts)\n", - " \n", - " @om.constraint\n", - " def enough_resource(self):\n", - " \"\"\"We have enough employees for each shift\"\"\"\n", - " for d, s in self.days * self.shifts:\n", - " yield om.total(self.schedule(d, e, s) >= self.resource(s) for e in self.employees)\n", - " \n", - " @om.constraint\n", - " def same_consecutive_shift(self):\n", - " \"\"\"Employees keep the same shift on consecutive work days\"\"\"\n", - " for d, e, s in self.days * self.employees * self.shifts:\n", - " if d < self.horizon():\n", - " yield self.schedule(d, e, s) + om.total(self.schedule(d+1, e, t) for t in self.shifts if t != s) <= 1\n", - "\n", - " @om.constraint\n", - " def rotating_shift(self):\n", - " \"\"\"Employees rotate shifts at least once every two weeks\"\"\"\n", - " for d, e, s in self.days * self.employees * self.shifts:\n", - " if d < self.horizon() - 13:\n", - " yield self.schedule(d, e, s) + self.schedule(d+7, e, s) + self.schedule(d+14, e, s) <= 2\n", - "\n", - " @om.alias(r\"\\lambda\", days, employees)\n", - " def unscheduled(self, d, e):\n", - " \"\"\"Convenience expression indicating whether an employee is off on a given day\"\"\"\n", - " return 1 - om.total(self.schedule(d, e, s) for s in self.shifts)\n", - " \n", - " @om.constraint\n", - " def at_most_five_shifts_per_week(self):\n", - " \"\"\"Each employee works at most 5 days per week\"\"\"\n", - " for d, e in self.days * self.employees:\n", - " if d < self.horizon() - 5:\n", - " yield om.total(self.unscheduled(f, e) for f in om.interval(d, d+6)) >= 2\n", - " \n", - " @om.constraint\n", - " def consecutive_time_off(self):\n", - " \"\"\"Employees have at least two days off at a time\"\"\"\n", - " for d, e in self.days * self.employees:\n", - " if d < self.horizon() - 1:\n", - " yield self.unscheduled(d, e) - self.unscheduled(d+1, e) + self.unscheduled(d+2,e) >= 0\n", - "\n", - " \n", - "model = ConsecutiveShiftScheduling()\n", - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "dd8abe2d", - "metadata": {}, - "source": [ - "## Application" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "08513f87", - "metadata": {}, - "outputs": [ + }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:opvious.client.handlers:Validated inputs. [parameters=10]\n", - "INFO:opvious.client.handlers:QueuedSolve is running... [elapsed=31 milliseconds]\n", - "INFO:opvious.client.handlers:QueuedSolve is running... [elapsed=261 milliseconds, gap=inf, cuts=0, iterations=588]\n", - "INFO:opvious.client.handlers:QueuedSolve is running... [elapsed=312 milliseconds, gap=inf, cuts=0, iterations=588]\n", - "INFO:opvious.client.handlers:QueuedSolve is running... [elapsed=a second, gap=inf, cuts=0, iterations=588]\n", - "INFO:opvious.client.handlers:QueuedSolve completed with status OPTIMAL. [objective=315.00000000000006]\n" - ] - } - ], - "source": [ - "import logging\n", - "import opvious\n", - "\n", - "logging.basicConfig(level=logging.INFO)\n", - "\n", - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "# Store the formulation on the server to be able to queue a solve below\n", - "specification = await client.register_specification(model.specification(), \"consecutive-shift-scheduling\")\n", - "\n", - "# Queue a solve attempt\n", - "problem = opvious.Problem(\n", - " specification,\n", - " parameters={\n", - " \"horizon\": 21,\n", - " \"resource\": {\"open\": 3, \"close\": 2},\n", - " \"level\": {chr(65+i): i for i in range(7)}, # A, B, C, ...\n", - " },\n", - ")\n", - "solve = await client.queue_solve(problem)\n", - "\n", - "# Wait for the solve to complete\n", - "await client.wait_for_solve_outcome(solve)\n", - "outputs = await client.fetch_solve_outputs(solve)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "08003201", - "metadata": {}, - "outputs": [ + "cell_type": "markdown", + "id": "dd8abe2d", + "metadata": {}, + "source": [ + "## Application" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "08513f87", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": "INFO:opvious.client.handlers:Validated inputs. [parameters=10]\nINFO:opvious.client.handlers:QueuedSolve is running... [elapsed=31 milliseconds]\nINFO:opvious.client.handlers:QueuedSolve is running... [elapsed=261 milliseconds, gap=inf, cuts=0, iterations=588]\nINFO:opvious.client.handlers:QueuedSolve is running... [elapsed=312 milliseconds, gap=inf, cuts=0, iterations=588]\nINFO:opvious.client.handlers:QueuedSolve is running... [elapsed=a second, gap=inf, cuts=0, iterations=588]\nINFO:opvious.client.handlers:QueuedSolve completed with status OPTIMAL. [objective=315.00000000000006]\n" + } + ], + "source": [ + "import logging\n", + "import opvious\n", + "\n", + "logging.basicConfig(level=logging.INFO)\n", + "\n", + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "# Store the formulation on the server to be able to queue a solve below\n", + "specification = await client.register_specification(model.specification(), \"consecutive-shift-scheduling\")\n", + "\n", + "# Queue a solve attempt\n", + "problem = opvious.Problem(\n", + " specification,\n", + " parameters={\n", + " \"horizon\": 21,\n", + " \"resource\": {\"open\": 3, \"close\": 2},\n", + " \"level\": {chr(65+i): i for i in range(7)}, # A, B, C, ...\n", + " },\n", + ")\n", + "solve = await client.queue_solve(problem)\n", + "\n", + "# Wait for the solve to complete\n", + "await client.wait_for_solve_outcome(solve)\n", + "outputs = await client.fetch_solve_outputs(solve)" + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
shifts
employeesABCDEFG
days
1openclosecloseopenopen
2opencloseopenopenclose
3opencloseopenopenclose
4opencloseopenopenclose
5closeopenopenopenclose
6openopenopencloseclose
7closeopenopenopenclose
8closeopenopenopenclose
9closeopenopencloseopen
10closeopenopencloseopen
11closeopencloseopenopen
12openclosecloseopenopen
13closecloseopenopenopen
14openclosecloseopenopen
15openopenclosecloseopen
16openopencloseopenclose
17openopencloseopenclose
18openopenopencloseclose
19openopenopencloseclose
20openopencloseopenclose
21closeopenopencloseopen
\n", - "
" + "cell_type": "code", + "execution_count": 4, + "id": "08003201", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
shifts
employeesABCDEFG
days
1openclosecloseopenopen
2opencloseopenopenclose
3opencloseopenopenclose
4opencloseopenopenclose
5closeopenopenopenclose
6openopenopencloseclose
7closeopenopenopenclose
8closeopenopenopenclose
9closeopenopencloseopen
10closeopenopencloseopen
11closeopencloseopenopen
12openclosecloseopenopen
13closecloseopenopenopen
14openclosecloseopenopen
15openopenclosecloseopen
16openopencloseopenclose
17openopencloseopenclose
18openopenopencloseclose
19openopenopencloseclose
20openopencloseopenclose
21closeopenopencloseopen
\n
", + "text/plain": " shifts \nemployees A B C D E F G\ndays \n1 open close close open open \n2 open close open open close\n3 open close open open close\n4 open close open open close\n5 close open open open close\n6 open open open close close\n7 close open open open close \n8 close open open open close \n9 close open open close open\n10 close open open close open\n11 close open close open open\n12 open close close open open\n13 close close open open open\n14 open close close open open \n15 open open close close open \n16 open open close open close\n17 open open close open close\n18 open open open close close\n19 open open open close close\n20 open open close open close\n21 close open open close open " + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " shifts \n", - "employees A B C D E F G\n", - "days \n", - "1 open close close open open \n", - "2 open close open open close\n", - "3 open close open open close\n", - "4 open close open open close\n", - "5 close open open open close\n", - "6 open open open close close\n", - "7 close open open open close \n", - "8 close open open open close \n", - "9 close open open close open\n", - "10 close open open close open\n", - "11 close open close open open\n", - "12 open close close open open\n", - "13 close close open open open\n", - "14 open close close open open \n", - "15 open open close close open \n", - "16 open open close open close\n", - "17 open open close open close\n", - "18 open open open close close\n", - "19 open open open close close\n", - "20 open open close open close\n", - "21 close open open close open " + "source": [ + "schedule = outputs.variable(\"schedule\")\n", + "schedule.reset_index().pivot(index=[\"days\"], columns=[\"employees\"], values=[\"shifts\"]).fillna(\"\")" ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "766f3086", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "schedule = outputs.variable(\"schedule\")\n", - "schedule.reset_index().pivot(index=[\"days\"], columns=[\"employees\"], values=[\"shifts\"]).fillna(\"\")" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "766f3086", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/debt-simplification.ipynb b/resources/examples/debt-simplification.ipynb index ac4ea83..e29973e 100644 --- a/resources/examples/debt-simplification.ipynb +++ b/resources/examples/debt-simplification.ipynb @@ -1,962 +1,399 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "b684e857", - "metadata": {}, - "source": [ - "# Debt simplification\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser.\n", - "
\n", - "\n", - "A [mathematical programming](https://en.wikipedia.org/wiki/Mathematical_optimization) approach for settling debts within a group, similar to [Splitwise's debt simplification](https://blog.splitwise.com/2012/09/14/debts-made-simple/)." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "4de08d3a", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "943d9b31", - "metadata": {}, - "source": [ - "## Problem formulation\n", - "\n", - "We first define our model using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "e7d077c8", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "b684e857", + "metadata": {}, + "source": [ + "# Debt simplification\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser.\n", + "
\n", + "\n", + "A [mathematical programming](https://en.wikipedia.org/wiki/Mathematical_optimization) approach for settling debts within a group, similar to [Splitwise's debt simplification](https://blog.splitwise.com/2012/09/14/debts-made-simple/)." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titleGroupExpenses
category
CONSTRAINT3
DIMENSION2
OBJECTIVE2
PARAMETER2
VARIABLE3
\n", - "
" + "cell_type": "code", + "execution_count": 1, + "id": "4de08d3a", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "943d9b31", + "metadata": {}, + "source": [ + "## Problem formulation\n", + "\n", + "We first define our model using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e7d077c8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
titleGroupExpenses
category
CONSTRAINT3
DIMENSION2
OBJECTIVE2
PARAMETER2
VARIABLE3
\n
", + "text/plain": "title GroupExpenses\ncategory \nCONSTRAINT 3\nDIMENSION 2\nOBJECTIVE 2\nPARAMETER 2\nVARIABLE 3" + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "title GroupExpenses\n", - "category \n", - "CONSTRAINT 3\n", - "DIMENSION 2\n", - "OBJECTIVE 2\n", - "PARAMETER 2\n", - "VARIABLE 3" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class GroupExpenses(om.Model):\n", + " \"\"\"A mixed-integer model for settling debts within a group\n", + " \n", + " The solution will represent the optimal transfers between group members in order to achieve fairness: each\n", + " member will end up having paid a total amount proportional to their involvement in the group's transactions.\n", + " \"\"\"\n", + " \n", + " members = om.Dimension() # Participants\n", + " transactions = om.Dimension() # Expenses\n", + " is_participating = om.Parameter.indicator(transactions, members) # 1 if a member is involved in a transaction\n", + " payment = om.Parameter.non_negative(transactions, members) # Amount paid by each member per transaction\n", + "\n", + " # Amount to be transferred by one member to another to achieve fairness\n", + " transfer = om.Variable.non_negative(members, members, qualifiers=['sender', 'recipient'])\n", + " # Indicator variable representing a transfer from one member to another (1 if transfer > 0, 0 otherwise)\n", + " is_transferring = om.fragments.ActivationVariable(transfer, upper_bound=payment.total())\n", + "\n", + " def fair_payment(self, t, m):\n", + " \"\"\"Fair payment in a transaction for a given member\"\"\"\n", + " share = self.is_participating(t, m) / om.total(self.is_participating(t, o) for o in self.members)\n", + " return share * om.total(self.payment(t, m) for m in self.members)\n", + " \n", + " @om.constraint\n", + " def zero_sum_transfers(self):\n", + " \"\"\"After netting transfers, each member should have paid the sum of their fair payments\"\"\"\n", + " for m in self.members:\n", + " received = om.total(self.transfer(s, m) for s in self.members)\n", + " sent = om.total(self.transfer(m, r) for r in self.members)\n", + " owed = om.total(self.payment(t, m) - self.fair_payment(t, m) for t in self.transactions)\n", + " yield received - sent == owed\n", + " \n", + " @om.objective\n", + " def minimize_total_transferred(self):\n", + " \"\"\"First objective: minimize the total amount of money transferred between members\"\"\"\n", + " return om.total(self.transfer(s, r) for s, r in self.members * self.members)\n", + " \n", + " @om.fragments.magnitude_variable(members, projection=0, lower_bound=False)\n", + " def max_transfers_sent(self, m):\n", + " \"\"\"Number of transfers sent by any member\"\"\"\n", + " return om.total(self.is_transferring(m, r) for r in self.members)\n", + " \n", + " @om.objective\n", + " def minimize_max_transfers_sent(self):\n", + " \"\"\"Second objective: minimize the maximum number of transfers sent by any member\"\"\"\n", + " return self.max_transfers_sent()\n", + "\n", + "\n", + "model = GroupExpenses()\n", + "model.definition_counts().T # Summary of the model's components" ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class GroupExpenses(om.Model):\n", - " \"\"\"A mixed-integer model for settling debts within a group\n", - " \n", - " The solution will represent the optimal transfers between group members in order to achieve fairness: each\n", - " member will end up having paid a total amount proportional to their involvement in the group's transactions.\n", - " \"\"\"\n", - " \n", - " members = om.Dimension() # Participants\n", - " transactions = om.Dimension() # Expenses\n", - " is_participating = om.Parameter.indicator(transactions, members) # 1 if a member is involved in a transaction\n", - " payment = om.Parameter.non_negative(transactions, members) # Amount paid by each member per transaction\n", - "\n", - " # Amount to be transferred by one member to another to achieve fairness\n", - " transfer = om.Variable.non_negative(members, members, qualifiers=['sender', 'recipient'])\n", - " # Indicator variable representing a transfer from one member to another (1 if transfer > 0, 0 otherwise)\n", - " is_transferring = om.fragments.ActivationVariable(transfer, upper_bound=payment.total())\n", - "\n", - " def fair_payment(self, t, m):\n", - " \"\"\"Fair payment in a transaction for a given member\"\"\"\n", - " share = self.is_participating(t, m) / om.total(self.is_participating(t, o) for o in self.members)\n", - " return share * om.total(self.payment(t, m) for m in self.members)\n", - " \n", - " @om.constraint\n", - " def zero_sum_transfers(self):\n", - " \"\"\"After netting transfers, each member should have paid the sum of their fair payments\"\"\"\n", - " for m in self.members:\n", - " received = om.total(self.transfer(s, m) for s in self.members)\n", - " sent = om.total(self.transfer(m, r) for r in self.members)\n", - " owed = om.total(self.payment(t, m) - self.fair_payment(t, m) for t in self.transactions)\n", - " yield received - sent == owed\n", - " \n", - " @om.objective\n", - " def minimize_total_transferred(self):\n", - " \"\"\"First objective: minimize the total amount of money transferred between members\"\"\"\n", - " return om.total(self.transfer(s, r) for s, r in self.members * self.members)\n", - " \n", - " @om.fragments.magnitude_variable(members, projection=0, lower_bound=False)\n", - " def max_transfers_sent(self, m):\n", - " \"\"\"Number of transfers sent by any member\"\"\"\n", - " return om.total(self.is_transferring(m, r) for r in self.members)\n", - " \n", - " @om.objective\n", - " def minimize_max_transfers_sent(self):\n", - " \"\"\"Second objective: minimize the maximum number of transfers sent by any member\"\"\"\n", - " return self.max_transfers_sent()\n", - "\n", - "\n", - "model = GroupExpenses()\n", - "model.definition_counts().T # Summary of the model's components" - ] - }, - { - "cell_type": "markdown", - "id": "43d96b97", - "metadata": {}, - "source": [ - "For the mathematically inclined, models can generate their LaTeX specification." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "4a2768cf", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "43d96b97", + "metadata": {}, + "source": [ + "For the mathematically inclined, models can generate their LaTeX specification." + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "GroupExpenses\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{members}&: M \\\\\n", - " \\S^d_\\mathrm{transactions}&: T \\\\\n", - " \\S^p_\\mathrm{isParticipating}&: p^\\mathrm{is} \\in \\{0, 1\\}^{T \\times M} \\\\\n", - " \\S^p_\\mathrm{payment}&: p \\in \\mathbb{R}_+^{T \\times M} \\\\\n", - " \\S^v_\\mathrm{transfer[sender,recipient]}&: \\tau \\in \\mathbb{R}_+^{M \\times M} \\\\\n", - " \\S^v_\\mathrm{isTransferring}&: \\tau^\\mathrm{is} \\in \\{0, 1\\}^{M \\times M} \\\\\n", - " \\S^c_\\mathrm{isTransferringActivates}&: \\forall m, m' \\in M, \\sum_{t \\in T, m'' \\in M} p_{t,m''} \\tau^\\mathrm{is}_{m,m'} \\geq \\tau_{m,m'} \\\\\n", - " \\S^c_\\mathrm{zeroSumTransfers}&: \\forall m \\in M, \\sum_{m' \\in M} \\tau_{m',m} - \\sum_{m' \\in M} \\tau_{m,m'} = \\sum_{t \\in T} \\left(p_{t,m} - \\frac{p^\\mathrm{is}_{t,m}}{\\sum_{m' \\in M} p^\\mathrm{is}_{t,m'}} \\sum_{m' \\in M} p_{t,m'}\\right) \\\\\n", - " \\S^o_\\mathrm{minimizeTotalTransferred}&: \\min \\sum_{m, m' \\in M} \\tau_{m,m'} \\\\\n", - " \\S^v_\\mathrm{maxTransfersSent}&: \\sigma^\\mathrm{maxTransfers} \\in \\mathbb{R}_+ \\\\\n", - " \\S^c_\\mathrm{maxTransfersSentUpperBounds}&: \\forall m \\in M, \\sigma^\\mathrm{maxTransfers} \\geq \\sum_{m' \\in M} \\tau^\\mathrm{is}_{m,m'} \\\\\n", - " \\S^o_\\mathrm{minimizeMaxTransfersSent}&: \\min \\sigma^\\mathrm{maxTransfers} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "code", + "execution_count": 3, + "id": "4a2768cf", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nGroupExpenses\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{members}&: M \\\\\n \\S^d_\\mathrm{transactions}&: T \\\\\n \\S^p_\\mathrm{isParticipating}&: p^\\mathrm{is} \\in \\{0, 1\\}^{T \\times M} \\\\\n \\S^p_\\mathrm{payment}&: p \\in \\mathbb{R}_+^{T \\times M} \\\\\n \\S^v_\\mathrm{transfer[sender,recipient]}&: \\tau \\in \\mathbb{R}_+^{M \\times M} \\\\\n \\S^v_\\mathrm{isTransferring}&: \\tau^\\mathrm{is} \\in \\{0, 1\\}^{M \\times M} \\\\\n \\S^c_\\mathrm{isTransferringActivates}&: \\forall m, m' \\in M, \\sum_{t \\in T, m'' \\in M} p_{t,m''} \\tau^\\mathrm{is}_{m,m'} \\geq \\tau_{m,m'} \\\\\n \\S^c_\\mathrm{zeroSumTransfers}&: \\forall m \\in M, \\sum_{m' \\in M} \\tau_{m',m} - \\sum_{m' \\in M} \\tau_{m,m'} = \\sum_{t \\in T} \\left(p_{t,m} - \\frac{p^\\mathrm{is}_{t,m}}{\\sum_{m' \\in M} p^\\mathrm{is}_{t,m'}} \\sum_{m' \\in M} p_{t,m'}\\right) \\\\\n \\S^o_\\mathrm{minimizeTotalTransferred}&: \\min \\sum_{m, m' \\in M} \\tau_{m,m'} \\\\\n \\S^v_\\mathrm{maxTransfersSent}&: \\sigma^\\mathrm{maxTransfers} \\in \\mathbb{R}_+ \\\\\n \\S^c_\\mathrm{maxTransfersSentUpperBounds}&: \\forall m \\in M, \\sigma^\\mathrm{maxTransfers} \\geq \\sum_{m' \\in M} \\tau^\\mathrm{is}_{m,m'} \\\\\n \\S^o_\\mathrm{minimizeMaxTransfersSent}&: \\min \\sigma^\\mathrm{maxTransfers} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{members}&: M \\\\\\\\\\n \\\\S^d_\\\\mathrm{transactions}&: T \\\\\\\\\\n \\\\S^p_\\\\mathrm{isParticipating}&: p^\\\\mathrm{is} \\\\in \\\\{0, 1\\\\}^{T \\\\times M} \\\\\\\\\\n \\\\S^p_\\\\mathrm{payment}&: p \\\\in \\\\mathbb{R}_+^{T \\\\times M} \\\\\\\\\\n \\\\S^v_\\\\mathrm{transfer[sender,recipient]}&: \\\\tau \\\\in \\\\mathbb{R}_+^{M \\\\times M} \\\\\\\\\\n \\\\S^v_\\\\mathrm{isTransferring}&: \\\\tau^\\\\mathrm{is} \\\\in \\\\{0, 1\\\\}^{M \\\\times M} \\\\\\\\\\n \\\\S^c_\\\\mathrm{isTransferringActivates}&: \\\\forall m, m' \\\\in M, \\\\sum_{t \\\\in T, m'' \\\\in M} p_{t,m''} \\\\tau^\\\\mathrm{is}_{m,m'} \\\\geq \\\\tau_{m,m'} \\\\\\\\\\n \\\\S^c_\\\\mathrm{zeroSumTransfers}&: \\\\forall m \\\\in M, \\\\sum_{m' \\\\in M} \\\\tau_{m',m} - \\\\sum_{m' \\\\in M} \\\\tau_{m,m'} = \\\\sum_{t \\\\in T} \\\\left(p_{t,m} - \\\\frac{p^\\\\mathrm{is}_{t,m}}{\\\\sum_{m' \\\\in M} p^\\\\mathrm{is}_{t,m'}} \\\\sum_{m' \\\\in M} p_{t,m'}\\\\right) \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeTotalTransferred}&: \\\\min \\\\sum_{m, m' \\\\in M} \\\\tau_{m,m'} \\\\\\\\\\n \\\\S^v_\\\\mathrm{maxTransfersSent}&: \\\\sigma^\\\\mathrm{maxTransfers} \\\\in \\\\mathbb{R}_+ \\\\\\\\\\n \\\\S^c_\\\\mathrm{maxTransfersSentUpperBounds}&: \\\\forall m \\\\in M, \\\\sigma^\\\\mathrm{maxTransfers} \\\\geq \\\\sum_{m' \\\\in M} \\\\tau^\\\\mathrm{is}_{m,m'} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeMaxTransfersSent}&: \\\\min \\\\sigma^\\\\mathrm{maxTransfers} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='GroupExpenses')], description=\"A mixed-integer model for settling debts within a group\\n \\n The solution will represent the optimal transfers between group members in order to achieve fairness: each\\n member will end up having paid a total amount proportional to their involvement in the group's transactions.\\n \", annotation=None)" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{members}&: M \\\\\\\\\\n \\\\S^d_\\\\mathrm{transactions}&: T \\\\\\\\\\n \\\\S^p_\\\\mathrm{isParticipating}&: p^\\\\mathrm{is} \\\\in \\\\{0, 1\\\\}^{T \\\\times M} \\\\\\\\\\n \\\\S^p_\\\\mathrm{payment}&: p \\\\in \\\\mathbb{R}_+^{T \\\\times M} \\\\\\\\\\n \\\\S^v_\\\\mathrm{transfer[sender,recipient]}&: \\\\tau \\\\in \\\\mathbb{R}_+^{M \\\\times M} \\\\\\\\\\n \\\\S^v_\\\\mathrm{isTransferring}&: \\\\tau^\\\\mathrm{is} \\\\in \\\\{0, 1\\\\}^{M \\\\times M} \\\\\\\\\\n \\\\S^c_\\\\mathrm{isTransferringActivates}&: \\\\forall m, m' \\\\in M, \\\\sum_{t \\\\in T, m'' \\\\in M} p_{t,m''} \\\\tau^\\\\mathrm{is}_{m,m'} \\\\geq \\\\tau_{m,m'} \\\\\\\\\\n \\\\S^c_\\\\mathrm{zeroSumTransfers}&: \\\\forall m \\\\in M, \\\\sum_{m' \\\\in M} \\\\tau_{m',m} - \\\\sum_{m' \\\\in M} \\\\tau_{m,m'} = \\\\sum_{t \\\\in T} \\\\left(p_{t,m} - \\\\frac{p^\\\\mathrm{is}_{t,m}}{\\\\sum_{m' \\\\in M} p^\\\\mathrm{is}_{t,m'}} \\\\sum_{m' \\\\in M} p_{t,m'}\\\\right) \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeTotalTransferred}&: \\\\min \\\\sum_{m, m' \\\\in M} \\\\tau_{m,m'} \\\\\\\\\\n \\\\S^v_\\\\mathrm{maxTransfersSent}&: \\\\sigma^\\\\mathrm{maxTransfers} \\\\in \\\\mathbb{R}_+ \\\\\\\\\\n \\\\S^c_\\\\mathrm{maxTransfersSentUpperBounds}&: \\\\forall m \\\\in M, \\\\sigma^\\\\mathrm{maxTransfers} \\\\geq \\\\sum_{m' \\\\in M} \\\\tau^\\\\mathrm{is}_{m,m'} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeMaxTransfersSent}&: \\\\min \\\\sigma^\\\\mathrm{maxTransfers} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='GroupExpenses')], description=\"A mixed-integer model for settling debts within a group\\n \\n The solution will represent the optimal transfers between group members in order to achieve fairness: each\\n member will end up having paid a total amount proportional to their involvement in the group's transactions.\\n \", annotation=None)" + "source": [ + "model.specification()" ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "7e1a8819", - "metadata": {}, - "source": [ - "## Application\n", - "\n", - "We wrap the formulation defined above into a simple function which returns the optimal transfers given input data.\n", - "\n", - "A few things to note:\n", - "\n", - "+ Solves run remotely--no local solver installation required--and can be configured via `opvious` [client](https://opvious.readthedocs.io/en/stable/overview.html#creating-a-client) instances.\n", - "+ We leverage `pandas` utilities directly thanks to the SDK's native support for dataframes.\n", - "+ We specify a custom [multi-objective strategy](https://opvious.readthedocs.io/en/stable/strategies.html) to efficiently pick a robust optimal solution." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "adad2341", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "async def compute_optimal_transfers(payments, tolerance=0.1):\n", - " \"\"\"Computes optimal transfers to settle expenses fairly within a group\n", - " \n", - " Args:\n", - " payments: Dataframe of payments indexed by transaction where each column is a group member.\n", - " Each member with a non-zero payment will be considered a participant in the transaction.\n", - " tolerance: Relative slack bound on the total amount of money transferred during settlement\n", - " used to minimize the number of outbound transfers for any one member. For example, the\n", - " default value of 0.1 will allow transfering up to 10% more overall.\n", - " \"\"\"\n", - " se = payments.stack()['payment'] # Payments keyed by (transaction, member)\n", - " problem = opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={\n", - " 'payment': se,\n", - " 'isParticipating': (se > 0).astype(int),\n", - " },\n", - " strategy=opvious.SolveStrategy( # Multi-objective strategy\n", - " epsilon_constraints=[\n", - " # Within tolerance of the smallest total transfer amount\n", - " opvious.EpsilonConstraint('minimizeTotalTransferred', relative_tolerance=tolerance),\n", - " # Using the smallest possible number of transfers per member\n", - " opvious.EpsilonConstraint('minimizeMaxTransfersSent'),\n", - " ],\n", - " target='minimizeTotalTransferred', # Final target: minimize total transfer amount\n", - " ),\n", - " )\n", - " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - " solution = await client.solve(problem)\n", - " return solution.outputs.variable('transfer').unstack(level=1).fillna(0).round(2)" - ] - }, - { - "cell_type": "markdown", - "id": "764ca2ad", - "metadata": {}, - "source": [ - "## Testing\n", - "\n", - "We test our implementation on some representative data." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "9d79fa40", - "metadata": { - "scrolled": true - }, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "7e1a8819", + "metadata": {}, + "source": [ + "## Application\n", + "\n", + "We wrap the formulation defined above into a simple function which returns the optimal transfers given input data.\n", + "\n", + "A few things to note:\n", + "\n", + "+ Solves run remotely--no local solver installation required--and can be configured via `opvious` [client](https://opvious.readthedocs.io/en/stable/overview.html#creating-a-client) instances.\n", + "+ We leverage `pandas` utilities directly thanks to the SDK's native support for dataframes.\n", + "+ We specify a custom [multi-objective strategy](https://opvious.readthedocs.io/en/stable/strategies.html) to efficiently pick a robust optimal solution." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "adad2341", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "async def compute_optimal_transfers(payments, tolerance=0.1):\n", + " \"\"\"Computes optimal transfers to settle expenses fairly within a group\n", + " \n", + " Args:\n", + " payments: Dataframe of payments indexed by transaction where each column is a group member.\n", + " Each member with a non-zero payment will be considered a participant in the transaction.\n", + " tolerance: Relative slack bound on the total amount of money transferred during settlement\n", + " used to minimize the number of outbound transfers for any one member. For example, the\n", + " default value of 0.1 will allow transfering up to 10% more overall.\n", + " \"\"\"\n", + " se = payments.stack()['payment'] # Payments keyed by (transaction, member)\n", + " problem = opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={\n", + " 'payment': se,\n", + " 'isParticipating': (se > 0).astype(int),\n", + " },\n", + " strategy=opvious.SolveStrategy( # Multi-objective strategy\n", + " epsilon_constraints=[\n", + " # Within tolerance of the smallest total transfer amount\n", + " opvious.EpsilonConstraint('minimizeTotalTransferred', relative_tolerance=tolerance),\n", + " # Using the smallest possible number of transfers per member\n", + " opvious.EpsilonConstraint('minimizeMaxTransfersSent'),\n", + " ],\n", + " target='minimizeTotalTransferred', # Final target: minimize total transfer amount\n", + " ),\n", + " )\n", + " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + " solution = await client.solve(problem)\n", + " return solution.outputs.variable('transfer').unstack(level=1).fillna(0).round(2)" + ] + }, + { + "cell_type": "markdown", + "id": "764ca2ad", + "metadata": {}, + "source": [ + "## Testing\n", + "\n", + "We test our implementation on some representative data." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
payment
nameavaemmaisabellaliamlucasmasonmianoahsophia
transaction
t019.190.0072.860.000.000.005.5129.850.00
t020.0065.7466.930.000.0063.320.0015.010.00
t0318.730.000.0034.600.000.000.000.000.00
t0492.420.0010.7269.380.0088.440.000.0020.19
t0551.6664.4443.8259.3449.8161.370.000.000.00
\n", - "
" + "cell_type": "code", + "execution_count": 5, + "id": "9d79fa40", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
payment
nameavaemmaisabellaliamlucasmasonmianoahsophia
transaction
t019.190.0072.860.000.000.005.5129.850.00
t020.0065.7466.930.000.0063.320.0015.010.00
t0318.730.000.0034.600.000.000.000.000.00
t0492.420.0010.7269.380.0088.440.000.0020.19
t0551.6664.4443.8259.3449.8161.370.000.000.00
\n
", + "text/plain": " payment \nname ava emma isabella liam lucas mason mia noah sophia\ntransaction \nt01 9.19 0.00 72.86 0.00 0.00 0.00 5.51 29.85 0.00\nt02 0.00 65.74 66.93 0.00 0.00 63.32 0.00 15.01 0.00\nt03 18.73 0.00 0.00 34.60 0.00 0.00 0.00 0.00 0.00\nt04 92.42 0.00 10.72 69.38 0.00 88.44 0.00 0.00 20.19\nt05 51.66 64.44 43.82 59.34 49.81 61.37 0.00 0.00 0.00" + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " payment \n", - "name ava emma isabella liam lucas mason mia noah sophia\n", - "transaction \n", - "t01 9.19 0.00 72.86 0.00 0.00 0.00 5.51 29.85 0.00\n", - "t02 0.00 65.74 66.93 0.00 0.00 63.32 0.00 15.01 0.00\n", - "t03 18.73 0.00 0.00 34.60 0.00 0.00 0.00 0.00 0.00\n", - "t04 92.42 0.00 10.72 69.38 0.00 88.44 0.00 0.00 20.19\n", - "t05 51.66 64.44 43.82 59.34 49.81 61.37 0.00 0.00 0.00" + "source": [ + "import logging\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "logging.basicConfig(level=logging.INFO)\n", + "\n", + "_names = [\"emma\", \"noah\", \"ava\", \"liam\", \"isabella\", \"sophia\", \"mason\", \"mia\", \"lucas\"]\n", + "\n", + "def generate_random_payments(count=25, seed=2):\n", + " \"\"\"Generates a random dataframe of non-negative payments\"\"\"\n", + " rng = np.random.default_rng(seed)\n", + " tuples = []\n", + " for i in range(count):\n", + " tid = f't{i+1:02}'\n", + " for name in _names:\n", + " if rng.integers(2):\n", + " continue\n", + " tuples.append({\n", + " 'transaction': tid, \n", + " 'name': name, \n", + " 'payment': round(100 * rng.random(), 2),\n", + " })\n", + " df = pd.DataFrame(tuples).set_index(['transaction', 'name'])\n", + " return df.unstack().fillna(0)\n", + "\n", + "payments_df = generate_random_payments()\n", + "payments_df.head()" ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import logging\n", - "import numpy as np\n", - "import pandas as pd\n", - "\n", - "logging.basicConfig(level=logging.INFO)\n", - "\n", - "_names = [\"emma\", \"noah\", \"ava\", \"liam\", \"isabella\", \"sophia\", \"mason\", \"mia\", \"lucas\"]\n", - "\n", - "def generate_random_payments(count=25, seed=2):\n", - " \"\"\"Generates a random dataframe of non-negative payments\"\"\"\n", - " rng = np.random.default_rng(seed)\n", - " tuples = []\n", - " for i in range(count):\n", - " tid = f't{i+1:02}'\n", - " for name in _names:\n", - " if rng.integers(2):\n", - " continue\n", - " tuples.append({\n", - " 'transaction': tid, \n", - " 'name': name, \n", - " 'payment': round(100 * rng.random(), 2),\n", - " })\n", - " df = pd.DataFrame(tuples).set_index(['transaction', 'name'])\n", - " return df.unstack().fillna(0)\n", - "\n", - "payments_df = generate_random_payments()\n", - "payments_df.head()" - ] - }, - { - "cell_type": "markdown", - "id": "a10eb47a", - "metadata": {}, - "source": [ - "Using the default tolerance of 10%, we get the following optimals transfers:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "13e7121e", - "metadata": {}, - "outputs": [ + }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:opvious.client.handlers:Validated inputs. [parameters=450]\n", - "INFO:opvious.client.handlers:Solving problem... [columns=163, rows=99]\n", - "INFO:opvious.client.handlers:Added epsilon constraint. [objective_value=358.48607142857134]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=8, gap=100.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=45, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=159, gap=75.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=267, gap=50.0%]\n", - "INFO:opvious.client.handlers:Added epsilon constraint. [objective_value=2]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=21, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=191, gap=3.37%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=883, gap=3.19%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=958, gap=1.07%]\n", - "INFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=362.3471428571429]\n" - ] + "cell_type": "markdown", + "id": "a10eb47a", + "metadata": {}, + "source": [ + "Using the default tolerance of 10%, we get the following optimals transfers:" + ] }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
value
recipientemmaisabellaliamlucasmasonnoah
sender
ava0.0036.8412.350.000.000.00
liam2.340.000.000.001.520.00
mia0.000.000.000.00162.2516.36
sophia48.660.000.0082.030.000.00
\n", - "
" + "cell_type": "code", + "execution_count": 6, + "id": "13e7121e", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": "INFO:opvious.client.handlers:Validated inputs. [parameters=450]\nINFO:opvious.client.handlers:Solving problem... [columns=163, rows=99]\nINFO:opvious.client.handlers:Added epsilon constraint. [objective_value=358.48607142857134]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=8, gap=100.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=45, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=159, gap=75.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=267, gap=50.0%]\nINFO:opvious.client.handlers:Added epsilon constraint. [objective_value=2]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=21, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=191, gap=3.37%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=883, gap=3.19%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=958, gap=1.07%]\nINFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=362.3471428571429]\n" + }, + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
value
recipientemmaisabellaliamlucasmasonnoah
sender
ava0.0036.8412.350.000.000.00
liam2.340.000.000.001.520.00
mia0.000.000.000.00162.2516.36
sophia48.660.000.0082.030.000.00
\n
", + "text/plain": " value \nrecipient emma isabella liam lucas mason noah\nsender \nava 0.00 36.84 12.35 0.00 0.00 0.00\nliam 2.34 0.00 0.00 0.00 1.52 0.00\nmia 0.00 0.00 0.00 0.00 162.25 16.36\nsophia 48.66 0.00 0.00 82.03 0.00 0.00" + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " value \n", - "recipient emma isabella liam lucas mason noah\n", - "sender \n", - "ava 0.00 36.84 12.35 0.00 0.00 0.00\n", - "liam 2.34 0.00 0.00 0.00 1.52 0.00\n", - "mia 0.00 0.00 0.00 0.00 162.25 16.36\n", - "sophia 48.66 0.00 0.00 82.03 0.00 0.00" + "source": [ + "await compute_optimal_transfers(payments_df)" ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await compute_optimal_transfers(payments_df)" - ] - }, - { - "cell_type": "markdown", - "id": "2f9779b9", - "metadata": {}, - "source": [ - "In the solution above, the total amount of money transferred comes up to ~$362 and each person sends at most 2 transfers (Ava, Liam, Mia, and Sophia all send 2).\n", - "\n", - "Let's see what happens if we reduce the tolerance to 0, forcing the solution to focus on minimizing total transfer amount." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "6d150c34", - "metadata": {}, - "outputs": [ + }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:opvious.client.handlers:Validated inputs. [parameters=450]\n", - "INFO:opvious.client.handlers:Solving problem... [columns=163, rows=99]\n", - "INFO:opvious.client.handlers:Added epsilon constraint. [objective_value=358.48607142857134]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=8, gap=100.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=44, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=194, gap=83.33%]\n", - "INFO:opvious.client.handlers:Added epsilon constraint. [objective_value=3]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=349, gap=0.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=21, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=60, gap=0.0%]\n", - "INFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=358.4860714285714]\n" - ] + "cell_type": "markdown", + "id": "2f9779b9", + "metadata": {}, + "source": [ + "In the solution above, the total amount of money transferred comes up to ~$362 and each person sends at most 2 transfers (Ava, Liam, Mia, and Sophia all send 2).\n", + "\n", + "Let's see what happens if we reduce the tolerance to 0, forcing the solution to focus on minimizing total transfer amount." + ] }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
value
recipientemmaisabellaliamlucasmasonnoah
sender
ava18.730.490.000.000.000.00
mia0.06.358.480.00163.770.00
sophia32.30.000.0082.030.0016.36
\n", - "
" + "cell_type": "code", + "execution_count": 7, + "id": "6d150c34", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": "INFO:opvious.client.handlers:Validated inputs. [parameters=450]\nINFO:opvious.client.handlers:Solving problem... [columns=163, rows=99]\nINFO:opvious.client.handlers:Added epsilon constraint. [objective_value=358.48607142857134]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=8, gap=100.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=44, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=194, gap=83.33%]\nINFO:opvious.client.handlers:Added epsilon constraint. [objective_value=3]\nINFO:opvious.client.handlers:Solve in progress... [iterations=349, gap=0.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=21, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=60, gap=0.0%]\nINFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=358.4860714285714]\n" + }, + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
value
recipientemmaisabellaliamlucasmasonnoah
sender
ava18.730.490.000.000.000.00
mia0.06.358.480.00163.770.00
sophia32.30.000.0082.030.0016.36
\n
", + "text/plain": " value \nrecipient emma isabella liam lucas mason noah\nsender \nava 18.7 30.49 0.00 0.00 0.00 0.00\nmia 0.0 6.35 8.48 0.00 163.77 0.00\nsophia 32.3 0.00 0.00 82.03 0.00 16.36" + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " value \n", - "recipient emma isabella liam lucas mason noah\n", - "sender \n", - "ava 18.7 30.49 0.00 0.00 0.00 0.00\n", - "mia 0.0 6.35 8.48 0.00 163.77 0.00\n", - "sophia 32.3 0.00 0.00 82.03 0.00 16.36" + "source": [ + "await compute_optimal_transfers(payments_df, tolerance=0)" ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await compute_optimal_transfers(payments_df, tolerance=0)" - ] - }, - { - "cell_type": "markdown", - "id": "428ed845", - "metadata": {}, - "source": [ - "As expected the total transfer amount is lower (slightly, ~$358), however our other objective has increased: Mia and Sophia send three transfers each.\n", - "\n", - "Let's try increasing the tolerance instead to find a solution where each person sends at most a single transfer." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "5bfbf5d9", - "metadata": {}, - "outputs": [ + }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:opvious.client.handlers:Validated inputs. [parameters=450]\n", - "INFO:opvious.client.handlers:Solving problem... [columns=163, rows=99]\n", - "INFO:opvious.client.handlers:Added epsilon constraint. [objective_value=358.48607142857134]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=8, gap=100.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=42, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=135, gap=75.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=307, gap=50.0%]\n", - "INFO:opvious.client.handlers:Added epsilon constraint. [objective_value=1]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=779, gap=0.0%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=22, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=6171, gap=4.51%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=6208, gap=3.85%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=6409, gap=3.8%]\n", - "INFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=438.65235714285706]\n" - ] + "cell_type": "markdown", + "id": "428ed845", + "metadata": {}, + "source": [ + "As expected the total transfer amount is lower (slightly, ~$358), however our other objective has increased: Mia and Sophia send three transfers each.\n", + "\n", + "Let's try increasing the tolerance instead to find a solution where each person sends at most a single transfer." + ] }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
value
recipientemmaisabellaliamlucasmasonnoah
sender
ava49.190.000.000.000.000.00
emma0.000.000.000.000.001.52
isabella0.000.0011.820.000.000.00
liam3.330.000.000.000.000.00
lucas0.0048.660.000.000.000.00
mason0.000.000.000.000.0014.84
mia0.000.000.000.00178.610.00
sophia0.000.000.00130.690.000.00
\n", - "
" + "cell_type": "code", + "execution_count": 8, + "id": "5bfbf5d9", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": "INFO:opvious.client.handlers:Validated inputs. [parameters=450]\nINFO:opvious.client.handlers:Solving problem... [columns=163, rows=99]\nINFO:opvious.client.handlers:Added epsilon constraint. [objective_value=358.48607142857134]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=8, gap=100.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=42, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=135, gap=75.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=307, gap=50.0%]\nINFO:opvious.client.handlers:Added epsilon constraint. [objective_value=1]\nINFO:opvious.client.handlers:Solve in progress... [iterations=779, gap=0.0%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=22, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=6171, gap=4.51%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=6208, gap=3.85%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=6409, gap=3.8%]\nINFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=438.65235714285706]\n" + }, + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
value
recipientemmaisabellaliamlucasmasonnoah
sender
ava49.190.000.000.000.000.00
emma0.000.000.000.000.001.52
isabella0.000.0011.820.000.000.00
liam3.330.000.000.000.000.00
lucas0.0048.660.000.000.000.00
mason0.000.000.000.000.0014.84
mia0.000.000.000.00178.610.00
sophia0.000.000.00130.690.000.00
\n
", + "text/plain": " value \nrecipient emma isabella liam lucas mason noah\nsender \nava 49.19 0.00 0.00 0.00 0.00 0.00\nemma 0.00 0.00 0.00 0.00 0.00 1.52\nisabella 0.00 0.00 11.82 0.00 0.00 0.00\nliam 3.33 0.00 0.00 0.00 0.00 0.00\nlucas 0.00 48.66 0.00 0.00 0.00 0.00\nmason 0.00 0.00 0.00 0.00 0.00 14.84\nmia 0.00 0.00 0.00 0.00 178.61 0.00\nsophia 0.00 0.00 0.00 130.69 0.00 0.00" + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " value \n", - "recipient emma isabella liam lucas mason noah\n", - "sender \n", - "ava 49.19 0.00 0.00 0.00 0.00 0.00\n", - "emma 0.00 0.00 0.00 0.00 0.00 1.52\n", - "isabella 0.00 0.00 11.82 0.00 0.00 0.00\n", - "liam 3.33 0.00 0.00 0.00 0.00 0.00\n", - "lucas 0.00 48.66 0.00 0.00 0.00 0.00\n", - "mason 0.00 0.00 0.00 0.00 0.00 14.84\n", - "mia 0.00 0.00 0.00 0.00 178.61 0.00\n", - "sophia 0.00 0.00 0.00 130.69 0.00 0.00" + "source": [ + "await compute_optimal_transfers(payments_df, tolerance=0.25)" + ] + }, + { + "cell_type": "markdown", + "id": "ed652c60", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "This notebook showed how [Opvious](https://www.opvious.io) can be used to define and apply optimization to concrete data, highlighting a few key features along the way (declarative modeling, remote solving, multi-objective support).\n", + "\n", + "Check out the [SDK's documentation](https://opvious.readthedocs.io) to learn more or give one of the following extension ideas a try!\n", + "\n", + "+ The formulation above assumes that all members involved in a transaction have equal share. How can we extend it to support arbitrary shares?\n", + "+ Which other multi-objective strategies would make sense and how would their solutions compare to the one above?\n", + "+ How can we extend the model to handle prior settlements (for example if Ava had already transferred money to Noah) or minimum transfer thresholds?" ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b2b32b31", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await compute_optimal_transfers(payments_df, tolerance=0.25)" - ] - }, - { - "cell_type": "markdown", - "id": "ed652c60", - "metadata": {}, - "source": [ - "## Next steps\n", - "\n", - "This notebook showed how [Opvious](https://www.opvious.io) can be used to define and apply optimization to concrete data, highlighting a few key features along the way (declarative modeling, remote solving, multi-objective support).\n", - "\n", - "Check out the [SDK's documentation](https://opvious.readthedocs.io) to learn more or give one of the following extension ideas a try!\n", - "\n", - "+ The formulation above assumes that all members involved in a transaction have equal share. How can we extend it to support arbitrary shares?\n", - "+ Which other multi-objective strategies would make sense and how would their solutions compare to the one above?\n", - "+ How can we extend the model to handle prior settlements (for example if Ava had already transferred money to Noah) or minimum transfer thresholds?" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "b2b32b31", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/doctor-scheduling.ipynb b/resources/examples/doctor-scheduling.ipynb index 6682c97..ef6dc40 100644 --- a/resources/examples/doctor-scheduling.ipynb +++ b/resources/examples/doctor-scheduling.ipynb @@ -1,726 +1,339 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "c058969a", - "metadata": {}, - "source": [ - "# Doctor scheduling" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "a7a063ec", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "345b2381", - "metadata": {}, - "source": [ - "## Modeling\n", - "\n", - "We start with a few common model components." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "6159292a", - "metadata": {}, - "outputs": [ + "cells": [ { - "data": { - "text/markdown": [ - "\n", - "
\n", - "Common\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^p_\\mathrm{horizon}&: h \\in \\mathbb{N} \\\\\n", - " \\S^d_\\mathrm{doctors}&: I \\\\\n", - " \\S^a&: T \\doteq \\{ 1 \\ldots h \\} \\\\\n", - " \\S^d_\\mathrm{shifts}&: K \\\\\n", - " \\S^v_\\mathrm{assigned}&: \\alpha \\in \\{0, 1\\}^{I \\times T \\times K} \\\\\n", - " \\S^c_\\mathrm{atMostOneShiftPerDay}&: \\forall i \\in I, t \\in T, \\sum_{k \\in K} \\alpha_{i,t,k} \\leq 1 \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n" + "cell_type": "markdown", + "id": "c058969a", + "metadata": {}, + "source": [ + "# Doctor scheduling" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "a7a063ec", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "345b2381", + "metadata": {}, + "source": [ + "## Modeling\n", + "\n", + "We start with a few common model components." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6159292a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "\n
\nCommon\n
\n$$\n\\begin{align*}\n \\S^p_\\mathrm{horizon}&: h \\in \\mathbb{N} \\\\\n \\S^d_\\mathrm{doctors}&: I \\\\\n \\S^a&: T \\doteq \\{ 1 \\ldots h \\} \\\\\n \\S^d_\\mathrm{shifts}&: K \\\\\n \\S^v_\\mathrm{assigned}&: \\alpha \\in \\{0, 1\\}^{I \\times T \\times K} \\\\\n \\S^c_\\mathrm{atMostOneShiftPerDay}&: \\forall i \\in I, t \\in T, \\sum_{k \\in K} \\alpha_{i,t,k} \\leq 1 \\\\\n\\end{align*}\n$$\n
\n
\n", + "text/plain": "LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{horizon}&: h \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^d_\\\\mathrm{doctors}&: I \\\\\\\\\\n \\\\S^a&: T \\\\doteq \\\\{ 1 \\\\ldots h \\\\} \\\\\\\\\\n \\\\S^d_\\\\mathrm{shifts}&: K \\\\\\\\\\n \\\\S^v_\\\\mathrm{assigned}&: \\\\alpha \\\\in \\\\{0, 1\\\\}^{I \\\\times T \\\\times K} \\\\\\\\\\n \\\\S^c_\\\\mathrm{atMostOneShiftPerDay}&: \\\\forall i \\\\in I, t \\\\in T, \\\\sum_{k \\\\in K} \\\\alpha_{i,t,k} \\\\leq 1 \\\\\\\\\\n\\\\end{align*}\\n$$', title='Common')" + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{horizon}&: h \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^d_\\\\mathrm{doctors}&: I \\\\\\\\\\n \\\\S^a&: T \\\\doteq \\\\{ 1 \\\\ldots h \\\\} \\\\\\\\\\n \\\\S^d_\\\\mathrm{shifts}&: K \\\\\\\\\\n \\\\S^v_\\\\mathrm{assigned}&: \\\\alpha \\\\in \\\\{0, 1\\\\}^{I \\\\times T \\\\times K} \\\\\\\\\\n \\\\S^c_\\\\mathrm{atMostOneShiftPerDay}&: \\\\forall i \\\\in I, t \\\\in T, \\\\sum_{k \\\\in K} \\\\alpha_{i,t,k} \\\\leq 1 \\\\\\\\\\n\\\\end{align*}\\n$$', title='Common')" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class Common(om.Model):\n", + " horizon = om.Parameter.natural() # Number of days to schedule for\n", + " doctors = om.Dimension(name=\"I\") # Set of doctors\n", + " days = om.interval(1, horizon(), name=\"T\") # Set of days\n", + " shifts = om.Dimension(name=\"K\") # Set of shifts\n", + " assigned = om.Variable.indicator(doctors, days, shifts) # Shift assignment\n", + " \n", + " @om.constraint\n", + " def at_most_one_shift_per_day(self):\n", + " for i, t in self.doctors * self.days:\n", + " yield om.total(self.assigned(i, t, k) for k in self.shifts) <= 1\n", + " \n", + "common = Common()\n", + "common.specification().source()" ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class Common(om.Model):\n", - " horizon = om.Parameter.natural() # Number of days to schedule for\n", - " doctors = om.Dimension(name=\"I\") # Set of doctors\n", - " days = om.interval(1, horizon(), name=\"T\") # Set of days\n", - " shifts = om.Dimension(name=\"K\") # Set of shifts\n", - " assigned = om.Variable.indicator(doctors, days, shifts) # Shift assignment\n", - " \n", - " @om.constraint\n", - " def at_most_one_shift_per_day(self):\n", - " for i, t in self.doctors * self.days:\n", - " yield om.total(self.assigned(i, t, k) for k in self.shifts) <= 1\n", - " \n", - "common = Common()\n", - "common.specification().source()" - ] - }, - { - "cell_type": "markdown", - "id": "d47a985c", - "metadata": {}, - "source": [ - "### Consecutive changes\n", - "\n", - "One way to model switches is to only count changes when a doctor is assigned different shifts on _consecutive_ days as a switch." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "c950fb63", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "d47a985c", + "metadata": {}, + "source": [ + "### Consecutive changes\n", + "\n", + "One way to model switches is to only count changes when a doctor is assigned different shifts on _consecutive_ days as a switch." + ] + }, { - "data": { - "text/markdown": [ - "\n", - "
\n", - "SwitchOnConsecutiveShiftChanges\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^v_\\mathrm{switched}&: \\sigma \\in \\{0, 1\\}^{I \\times T} \\\\\n", - " \\S^c_\\mathrm{consecutiveShiftChangeForcesSwitch}&: \\forall i \\in I, t \\in T, k \\in K \\mid t > 1, \\sigma_{i,t} \\geq \\alpha_{i,t,k} + \\sum_{k' \\in K \\mid k' \\neq k} \\alpha_{i,t - 1,k'} - 1 \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n" + "cell_type": "code", + "execution_count": 3, + "id": "c950fb63", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "\n
\nSwitchOnConsecutiveShiftChanges\n
\n$$\n\\begin{align*}\n \\S^v_\\mathrm{switched}&: \\sigma \\in \\{0, 1\\}^{I \\times T} \\\\\n \\S^c_\\mathrm{consecutiveShiftChangeForcesSwitch}&: \\forall i \\in I, t \\in T, k \\in K \\mid t > 1, \\sigma_{i,t} \\geq \\alpha_{i,t,k} + \\sum_{k' \\in K \\mid k' \\neq k} \\alpha_{i,t - 1,k'} - 1 \\\\\n\\end{align*}\n$$\n
\n
\n", + "text/plain": "LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^v_\\\\mathrm{switched}&: \\\\sigma \\\\in \\\\{0, 1\\\\}^{I \\\\times T} \\\\\\\\\\n \\\\S^c_\\\\mathrm{consecutiveShiftChangeForcesSwitch}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K \\\\mid t > 1, \\\\sigma_{i,t} \\\\geq \\\\alpha_{i,t,k} + \\\\sum_{k' \\\\in K \\\\mid k' \\\\neq k} \\\\alpha_{i,t - 1,k'} - 1 \\\\\\\\\\n\\\\end{align*}\\n$$\", title='SwitchOnConsecutiveShiftChanges')" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^v_\\\\mathrm{switched}&: \\\\sigma \\\\in \\\\{0, 1\\\\}^{I \\\\times T} \\\\\\\\\\n \\\\S^c_\\\\mathrm{consecutiveShiftChangeForcesSwitch}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K \\\\mid t > 1, \\\\sigma_{i,t} \\\\geq \\\\alpha_{i,t,k} + \\\\sum_{k' \\\\in K \\\\mid k' \\\\neq k} \\\\alpha_{i,t - 1,k'} - 1 \\\\\\\\\\n\\\\end{align*}\\n$$\", title='SwitchOnConsecutiveShiftChanges')" + "source": [ + "class SwitchOnConsecutiveShiftChanges(om.Model):\n", + " def __init__(self):\n", + " super().__init__(dependencies=[common])\n", + " self.switched = om.Variable.indicator(common.doctors, common.days)\n", + " \n", + " def assigned_to_other(self, i, t, k): # 1 if i assigned to a shift different than k on t, 0 otherwise\n", + " return om.total(common.assigned(i, t, kk) for kk in common.shifts if kk != k)\n", + " \n", + " @om.constraint\n", + " def consecutive_shift_change_forces_switch(self):\n", + " for i, t, k in common.assigned.space():\n", + " if t > 1:\n", + " yield self.switched(i, t) >= common.assigned(i, t, k) + self.assigned_to_other(i, t-1, k) - 1\n", + " \n", + "SwitchOnConsecutiveShiftChanges().specification().source()" ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "class SwitchOnConsecutiveShiftChanges(om.Model):\n", - " def __init__(self):\n", - " super().__init__(dependencies=[common])\n", - " self.switched = om.Variable.indicator(common.doctors, common.days)\n", - " \n", - " def assigned_to_other(self, i, t, k): # 1 if i assigned to a shift different than k on t, 0 otherwise\n", - " return om.total(common.assigned(i, t, kk) for kk in common.shifts if kk != k)\n", - " \n", - " @om.constraint\n", - " def consecutive_shift_change_forces_switch(self):\n", - " for i, t, k in common.assigned.space():\n", - " if t > 1:\n", - " yield self.switched(i, t) >= common.assigned(i, t, k) + self.assigned_to_other(i, t-1, k) - 1\n", - " \n", - "SwitchOnConsecutiveShiftChanges().specification().source()" - ] - }, - { - "cell_type": "markdown", - "id": "18a8fc7e", - "metadata": {}, - "source": [ - "### All changes\n", - "\n", - "Yet another way to model this is to count all shift changes as switches, including when a doctor starts or ends a shift. In this case, the switch variable is equivalent to the magnitude (absolute value) of assignment changes." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "e044d1c4", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/markdown": [ - "\n", - "
\n", - "SwitchOnAllShiftChanges\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^v_\\mathrm{switched}&: \\sigma \\in \\mathbb{R}_+^{I \\times T} \\\\\n", - " \\S^c_\\mathrm{switchedLowerBounds}&: \\forall i \\in I, t \\in T, k \\in K, {-\\sigma_{i,t}} \\leq \\alpha_{i,t,k} - \\begin{cases} \\alpha_{i,t - 1,k} \\mid t > 1, \\\\ 0 \\end{cases} \\\\\n", - " \\S^c_\\mathrm{switchedUpperBounds}&: \\forall i \\in I, t \\in T, k \\in K, \\sigma_{i,t} \\geq \\alpha_{i,t,k} - \\begin{cases} \\alpha_{i,t - 1,k} \\mid t > 1, \\\\ 0 \\end{cases} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n" + "cell_type": "markdown", + "id": "18a8fc7e", + "metadata": {}, + "source": [ + "### All changes\n", + "\n", + "Yet another way to model this is to count all shift changes as switches, including when a doctor starts or ends a shift. In this case, the switch variable is equivalent to the magnitude (absolute value) of assignment changes." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "e044d1c4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "\n
\nSwitchOnAllShiftChanges\n
\n$$\n\\begin{align*}\n \\S^v_\\mathrm{switched}&: \\sigma \\in \\mathbb{R}_+^{I \\times T} \\\\\n \\S^c_\\mathrm{switchedLowerBounds}&: \\forall i \\in I, t \\in T, k \\in K, {-\\sigma_{i,t}} \\leq \\alpha_{i,t,k} - \\begin{cases} \\alpha_{i,t - 1,k} \\mid t > 1, \\\\ 0 \\end{cases} \\\\\n \\S^c_\\mathrm{switchedUpperBounds}&: \\forall i \\in I, t \\in T, k \\in K, \\sigma_{i,t} \\geq \\alpha_{i,t,k} - \\begin{cases} \\alpha_{i,t - 1,k} \\mid t > 1, \\\\ 0 \\end{cases} \\\\\n\\end{align*}\n$$\n
\n
\n", + "text/plain": "LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^v_\\\\mathrm{switched}&: \\\\sigma \\\\in \\\\mathbb{R}_+^{I \\\\times T} \\\\\\\\\\n \\\\S^c_\\\\mathrm{switchedLowerBounds}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K, {-\\\\sigma_{i,t}} \\\\leq \\\\alpha_{i,t,k} - \\\\begin{cases} \\\\alpha_{i,t - 1,k} \\\\mid t > 1, \\\\\\\\ 0 \\\\end{cases} \\\\\\\\\\n \\\\S^c_\\\\mathrm{switchedUpperBounds}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K, \\\\sigma_{i,t} \\\\geq \\\\alpha_{i,t,k} - \\\\begin{cases} \\\\alpha_{i,t - 1,k} \\\\mid t > 1, \\\\\\\\ 0 \\\\end{cases} \\\\\\\\\\n\\\\end{align*}\\n$$', title='SwitchOnAllShiftChanges')" + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^v_\\\\mathrm{switched}&: \\\\sigma \\\\in \\\\mathbb{R}_+^{I \\\\times T} \\\\\\\\\\n \\\\S^c_\\\\mathrm{switchedLowerBounds}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K, {-\\\\sigma_{i,t}} \\\\leq \\\\alpha_{i,t,k} - \\\\begin{cases} \\\\alpha_{i,t - 1,k} \\\\mid t > 1, \\\\\\\\ 0 \\\\end{cases} \\\\\\\\\\n \\\\S^c_\\\\mathrm{switchedUpperBounds}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K, \\\\sigma_{i,t} \\\\geq \\\\alpha_{i,t,k} - \\\\begin{cases} \\\\alpha_{i,t - 1,k} \\\\mid t > 1, \\\\\\\\ 0 \\\\end{cases} \\\\\\\\\\n\\\\end{align*}\\n$$', title='SwitchOnAllShiftChanges')" + "source": [ + "class SwitchOnAllShiftChanges(om.Model):\n", + " def __init__(self):\n", + " super().__init__(dependencies=[common])\n", + " \n", + " @om.fragments.magnitude_variable(*common.assigned.quantifiables(), projection=0b11)\n", + " def switched(self, i, t, k): # 1 if a different switch started, 0 if same shift, -1 if shift ended\n", + " return common.assigned(i, t, k) - om.switch((t > 1, common.assigned(i, t-1, k)), 0)\n", + " \n", + "SwitchOnAllShiftChanges().specification().source()" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "class SwitchOnAllShiftChanges(om.Model):\n", - " def __init__(self):\n", - " super().__init__(dependencies=[common])\n", - " \n", - " @om.fragments.magnitude_variable(*common.assigned.quantifiables(), projection=0b11)\n", - " def switched(self, i, t, k): # 1 if a different switch started, 0 if same shift, -1 if shift ended\n", - " return common.assigned(i, t, k) - om.switch((t > 1, common.assigned(i, t-1, k)), 0)\n", - " \n", - "SwitchOnAllShiftChanges().specification().source()" - ] - }, - { - "cell_type": "markdown", - "id": "1d376b01", - "metadata": {}, - "source": [ - "## Solution comparison\n", - "\n", - "In this section we compare the two approaches on a small example.\n", - "\n", - "Our overall objective will be to minimize the total number of switches while ensuring that there is at least one doctor per shift. We also introduce a parameter allowing doctors to mark themselves as unavailable for a given shift." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "8839ab10", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/markdown": [ - "\n", - "
\n", - "Scheduling\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^p_\\mathrm{unavailable}&: u \\in \\{0, 1\\}^{I \\times T \\times K} \\\\\n", - " \\S^o_\\mathrm{minimizeSwitches}&: \\min \\sum_{i \\in I, t \\in T} \\sigma_{i,t} \\\\\n", - " \\S^c_\\mathrm{atLeastOneDoctorPerShift}&: \\forall t \\in T, k \\in K, \\sum_{i \\in I} \\alpha_{i,t,k} \\geq 1 \\\\\n", - " \\S^c_\\mathrm{onlyAssignedWhenAvailable}&: \\forall i \\in I, t \\in T, k \\in K \\mid u_{i,t,k} \\neq 0, \\alpha_{i,t,k} = 0 \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n" + "cell_type": "markdown", + "id": "1d376b01", + "metadata": {}, + "source": [ + "## Solution comparison\n", + "\n", + "In this section we compare the two approaches on a small example.\n", + "\n", + "Our overall objective will be to minimize the total number of switches while ensuring that there is at least one doctor per shift. We also introduce a parameter allowing doctors to mark themselves as unavailable for a given shift." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "8839ab10", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "\n
\nScheduling\n
\n$$\n\\begin{align*}\n \\S^p_\\mathrm{unavailable}&: u \\in \\{0, 1\\}^{I \\times T \\times K} \\\\\n \\S^o_\\mathrm{minimizeSwitches}&: \\min \\sum_{i \\in I, t \\in T} \\sigma_{i,t} \\\\\n \\S^c_\\mathrm{atLeastOneDoctorPerShift}&: \\forall t \\in T, k \\in K, \\sum_{i \\in I} \\alpha_{i,t,k} \\geq 1 \\\\\n \\S^c_\\mathrm{onlyAssignedWhenAvailable}&: \\forall i \\in I, t \\in T, k \\in K \\mid u_{i,t,k} \\neq 0, \\alpha_{i,t,k} = 0 \\\\\n\\end{align*}\n$$\n
\n
\n", + "text/plain": "LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{unavailable}&: u \\\\in \\\\{0, 1\\\\}^{I \\\\times T \\\\times K} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeSwitches}&: \\\\min \\\\sum_{i \\\\in I, t \\\\in T} \\\\sigma_{i,t} \\\\\\\\\\n \\\\S^c_\\\\mathrm{atLeastOneDoctorPerShift}&: \\\\forall t \\\\in T, k \\\\in K, \\\\sum_{i \\\\in I} \\\\alpha_{i,t,k} \\\\geq 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{onlyAssignedWhenAvailable}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K \\\\mid u_{i,t,k} \\\\neq 0, \\\\alpha_{i,t,k} = 0 \\\\\\\\\\n\\\\end{align*}\\n$$', title='Scheduling')" + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{unavailable}&: u \\\\in \\\\{0, 1\\\\}^{I \\\\times T \\\\times K} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeSwitches}&: \\\\min \\\\sum_{i \\\\in I, t \\\\in T} \\\\sigma_{i,t} \\\\\\\\\\n \\\\S^c_\\\\mathrm{atLeastOneDoctorPerShift}&: \\\\forall t \\\\in T, k \\\\in K, \\\\sum_{i \\\\in I} \\\\alpha_{i,t,k} \\\\geq 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{onlyAssignedWhenAvailable}&: \\\\forall i \\\\in I, t \\\\in T, k \\\\in K \\\\mid u_{i,t,k} \\\\neq 0, \\\\alpha_{i,t,k} = 0 \\\\\\\\\\n\\\\end{align*}\\n$$', title='Scheduling')" + "source": [ + "class Scheduling(om.Model):\n", + " unavailable = om.Parameter.indicator(common.assigned.quantifiables())\n", + " \n", + " def __init__(self, sm):\n", + " super().__init__([sm])\n", + " self._sm = sm\n", + " \n", + " @om.objective\n", + " def minimize_switches(self):\n", + " return om.total(self._sm.switched(i, t) for i, t in common.doctors * common.days)\n", + " \n", + " @om.constraint\n", + " def at_least_one_doctor_per_shift(self):\n", + " for t, k in common.days * common.shifts:\n", + " yield om.total(common.assigned(i, t, k) >= 1 for i in common.doctors)\n", + " \n", + " @om.constraint\n", + " def only_assigned_when_available(self):\n", + " for i, t, k in common.assigned.space():\n", + " if self.unavailable(i, t, k):\n", + " yield common.assigned(i, t, k) == 0\n", + " \n", + "Scheduling(SwitchOnAllShiftChanges()).specification().source()" ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "class Scheduling(om.Model):\n", - " unavailable = om.Parameter.indicator(common.assigned.quantifiables())\n", - " \n", - " def __init__(self, sm):\n", - " super().__init__([sm])\n", - " self._sm = sm\n", - " \n", - " @om.objective\n", - " def minimize_switches(self):\n", - " return om.total(self._sm.switched(i, t) for i, t in common.doctors * common.days)\n", - " \n", - " @om.constraint\n", - " def at_least_one_doctor_per_shift(self):\n", - " for t, k in common.days * common.shifts:\n", - " yield om.total(common.assigned(i, t, k) >= 1 for i in common.doctors)\n", - " \n", - " @om.constraint\n", - " def only_assigned_when_available(self):\n", - " for i, t, k in common.assigned.space():\n", - " if self.unavailable(i, t, k):\n", - " yield common.assigned(i, t, k) == 0\n", - " \n", - "Scheduling(SwitchOnAllShiftChanges()).specification().source()" - ] - }, - { - "cell_type": "markdown", - "id": "f0a863f8", - "metadata": {}, - "source": [ - "We now just need to create a small wrapper which will trigger a solve on toy data and pretty-print the optimal schedule." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "1d356245", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "horizon = 14\n", - "shifts = ['early', 'midday', 'night']\n", - "unavailabilities = {\n", - " 'ann': [(1, 'midday'), (8, 'early'), (8, 'night')],\n", - " 'bob': [(2, 'night'), (14, 'midday')],\n", - " 'cat': [(1, 'early'), (10, 'midday'), (10, 'night')],\n", - " 'dan': [(5, 'midday')],\n", - "}\n", - "\n", - "async def find_optimal_schedule(shifts_model):\n", - " \"\"\"Pretty-prints an optimal assignment schedule\"\"\"\n", - " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - " problem = opvious.Problem(\n", - " specification=Scheduling(shifts_model).specification(),\n", - " parameters={\n", - " 'horizon': horizon,\n", - " 'unavailable': [(d, t, k) for d, arr in unavailabilities.items() for t, k in arr]\n", - " },\n", - " dimensions={\n", - " 'doctors': unavailabilities.keys(),\n", - " 'shifts': shifts,\n", - " },\n", - " )\n", - " solution = await client.solve(problem, assert_feasible=True)\n", - " assignment = solution.outputs.variable('assigned') # Flat assignment dataframe\n", - " return (\n", - " assignment.reset_index()\n", - " .drop('value', axis=1)\n", - " .set_axis(['doctor', 'day', 'shift'], axis=1)\n", - " .pivot(index=['day'], columns=['doctor'], values=['shift'])\n", - " .fillna('')\n", - " )" - ] - }, - { - "cell_type": "markdown", - "id": "dde66c3a", - "metadata": {}, - "source": [ - "Running it using the first option, we can see that the doctors change shifts often, but never consecutively. This makes sense since the model isn't penalized for shifts that happen on either side of a break." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "d7f6d618", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "f0a863f8", + "metadata": {}, + "source": [ + "We now just need to create a small wrapper which will trigger a solve on toy data and pretty-print the optimal schedule." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "1d356245", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "horizon = 14\n", + "shifts = ['early', 'midday', 'night']\n", + "unavailabilities = {\n", + " 'ann': [(1, 'midday'), (8, 'early'), (8, 'night')],\n", + " 'bob': [(2, 'night'), (14, 'midday')],\n", + " 'cat': [(1, 'early'), (10, 'midday'), (10, 'night')],\n", + " 'dan': [(5, 'midday')],\n", + "}\n", + "\n", + "async def find_optimal_schedule(shifts_model):\n", + " \"\"\"Pretty-prints an optimal assignment schedule\"\"\"\n", + " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + " problem = opvious.Problem(\n", + " specification=Scheduling(shifts_model).specification(),\n", + " parameters={\n", + " 'horizon': horizon,\n", + " 'unavailable': [(d, t, k) for d, arr in unavailabilities.items() for t, k in arr]\n", + " },\n", + " dimensions={\n", + " 'doctors': unavailabilities.keys(),\n", + " 'shifts': shifts,\n", + " },\n", + " )\n", + " solution = await client.solve(problem, assert_feasible=True)\n", + " assignment = solution.outputs.variable('assigned') # Flat assignment dataframe\n", + " return (\n", + " assignment.reset_index()\n", + " .drop('value', axis=1)\n", + " .set_axis(['doctor', 'day', 'shift'], axis=1)\n", + " .pivot(index=['day'], columns=['doctor'], values=['shift'])\n", + " .fillna('')\n", + " )" + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
shift
doctorannbobcatdan
day
1nightmiddayearly
2nightearlymidday
3nightearlymidday
4nightearlymidday
5nightearlymidday
6earlymiddaynight
7earlymiddaynight
8earlymiddaynight
9earlymiddaynight
10middayearlynight
11earlymiddaynight
12earlymiddaynight
13middayearlynight
14middayearlynight
\n", - "
" + "cell_type": "markdown", + "id": "dde66c3a", + "metadata": {}, + "source": [ + "Running it using the first option, we can see that the doctors change shifts often, but never consecutively. This makes sense since the model isn't penalized for shifts that happen on either side of a break." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d7f6d618", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
shift
doctorannbobcatdan
day
1nightmiddayearly
2nightearlymidday
3nightearlymidday
4nightearlymidday
5nightearlymidday
6earlymiddaynight
7earlymiddaynight
8earlymiddaynight
9earlymiddaynight
10middayearlynight
11earlymiddaynight
12earlymiddaynight
13middayearlynight
14middayearlynight
\n
", + "text/plain": " shift \ndoctor ann bob cat dan\nday \n1 night midday early\n2 night early midday \n3 night early midday \n4 night early midday \n5 night early midday \n6 early midday night\n7 early midday night\n8 early midday night\n9 early midday night\n10 midday early night\n11 early midday night\n12 early midday night\n13 midday early night\n14 midday early night" + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " shift \n", - "doctor ann bob cat dan\n", - "day \n", - "1 night midday early\n", - "2 night early midday \n", - "3 night early midday \n", - "4 night early midday \n", - "5 night early midday \n", - "6 early midday night\n", - "7 early midday night\n", - "8 early midday night\n", - "9 early midday night\n", - "10 midday early night\n", - "11 early midday night\n", - "12 early midday night\n", - "13 midday early night\n", - "14 midday early night" + "source": [ + "await find_optimal_schedule(SwitchOnConsecutiveShiftChanges())" ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await find_optimal_schedule(SwitchOnConsecutiveShiftChanges())" - ] - }, - { - "cell_type": "markdown", - "id": "de2fb116", - "metadata": {}, - "source": [ - "If we use the second option, shift assignments are sticky: the model only changes a doctor's assignment if the doctor is unavailable. (In practice, we could add a constraint which ensures that doctors have a minimum number of days off within a rolling window of days.)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "1f240f13", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
shift
doctorannbobcatdan
day
1earlymiddaynight
2earlymiddaynight
3middayearlynight
4middayearlynight
5middayearlynight
6middayearlynight
7middayearlynight
8middayearlynight
9middayearlynight
10middayearlynight
11middayearlynight
12middayearlynight
13middayearlynight
14middayearlynight
\n", - "
" + "cell_type": "markdown", + "id": "de2fb116", + "metadata": {}, + "source": [ + "If we use the second option, shift assignments are sticky: the model only changes a doctor's assignment if the doctor is unavailable. (In practice, we could add a constraint which ensures that doctors have a minimum number of days off within a rolling window of days.)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1f240f13", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
shift
doctorannbobcatdan
day
1earlymiddaynight
2earlymiddaynight
3middayearlynight
4middayearlynight
5middayearlynight
6middayearlynight
7middayearlynight
8middayearlynight
9middayearlynight
10middayearlynight
11middayearlynight
12middayearlynight
13middayearlynight
14middayearlynight
\n
", + "text/plain": " shift \ndoctor ann bob cat dan\nday \n1 early midday night\n2 early midday night\n3 midday early night\n4 midday early night\n5 midday early night\n6 midday early night\n7 midday early night\n8 midday early night\n9 midday early night\n10 midday early night\n11 midday early night\n12 midday early night\n13 midday early night\n14 midday early night" + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " shift \n", - "doctor ann bob cat dan\n", - "day \n", - "1 early midday night\n", - "2 early midday night\n", - "3 midday early night\n", - "4 midday early night\n", - "5 midday early night\n", - "6 midday early night\n", - "7 midday early night\n", - "8 midday early night\n", - "9 midday early night\n", - "10 midday early night\n", - "11 midday early night\n", - "12 midday early night\n", - "13 midday early night\n", - "14 midday early night" + "source": [ + "await find_optimal_schedule(SwitchOnAllShiftChanges())" ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5469db0d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await find_optimal_schedule(SwitchOnAllShiftChanges())" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "5469db0d", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/fantasy-premier-league.ipynb b/resources/examples/fantasy-premier-league.ipynb index cfb2fac..feb4889 100644 --- a/resources/examples/fantasy-premier-league.ipynb +++ b/resources/examples/fantasy-premier-league.ipynb @@ -1,1146 +1,362 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "6a7471e2", - "metadata": {}, - "source": [ - "# Fantasy Premier League team selection\n", - "\n", - "Let's find an optimal team for the [Fantasy Premier League](https://fantasy.premierleague.com/) using [mathematical programming](https://en.wikipedia.org/wiki/Mathematical_optimization) with [Opvious](https://www.opvious.io)!\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser when accessed via opvious.io/notebooks.\n", - "
\n", - "\n", - "## Setup\n", - "\n", - "We start by downloading player statistics (team, cost, total points, etc.). The data is available in table format [here](https://gist.github.com/mtth/f59bdde8694223c06f77089b71b48d17)." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "8fc47213", - "metadata": { - "scrolled": true - }, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "37c8ff1c", - "metadata": {}, - "outputs": [ + "cells": [ { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
nameteampositioncoststatusminutestotal_pointsbonuspoints_per_gameselected_by_percent
id
Balogun-ARSBalogunARSFWD4.5Available0000.01.5
Cédric-ARSCédricARSDEF4.0Available2231001.20.4
M.Elneny-ARSM.ElnenyARSMID4.5Available111601.20.2
Fábio Vieira-ARSFábio VieiraARSMID5.5Available5004021.80.1
Gabriel-ARSGabrielARSDEF5.0Available3409146153.819.2
.................................
N.Semedo-WOLN.SemedoWOLDEF4.5Available26337552.10.3
Toti-WOLTotiWOLDEF4.5Available9784342.50.2
Boubacar Traore-WOLBoubacar TraoreWOLMID4.5Available4051401.40.6
Cunha-WOLCunhaWOLFWD5.5Available9613962.30.1
Doherty-WOLDohertyWOLDEF4.5Available6633522.90.3
\n", - "

605 rows × 10 columns

\n", - "
" + "cell_type": "markdown", + "id": "6a7471e2", + "metadata": {}, + "source": [ + "# Fantasy Premier League team selection\n", + "\n", + "Let's find an optimal team for the [Fantasy Premier League](https://fantasy.premierleague.com/) using [mathematical programming](https://en.wikipedia.org/wiki/Mathematical_optimization) with [Opvious](https://www.opvious.io)!\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser when accessed via opvious.io/notebooks.\n", + "
\n", + "\n", + "## Setup\n", + "\n", + "We start by downloading player statistics (team, cost, total points, etc.). The data is available in table format [here](https://gist.github.com/mtth/f59bdde8694223c06f77089b71b48d17)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8fc47213", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "37c8ff1c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
nameteampositioncoststatusminutestotal_pointsbonuspoints_per_gameselected_by_percent
id
Balogun-ARSBalogunARSFWD4.5Available0000.01.5
C\u00e9dric-ARSC\u00e9dricARSDEF4.0Available2231001.20.4
M.Elneny-ARSM.ElnenyARSMID4.5Available111601.20.2
F\u00e1bio Vieira-ARSF\u00e1bio VieiraARSMID5.5Available5004021.80.1
Gabriel-ARSGabrielARSDEF5.0Available3409146153.819.2
.................................
N.Semedo-WOLN.SemedoWOLDEF4.5Available26337552.10.3
Toti-WOLTotiWOLDEF4.5Available9784342.50.2
Boubacar Traore-WOLBoubacar TraoreWOLMID4.5Available4051401.40.6
Cunha-WOLCunhaWOLFWD5.5Available9613962.30.1
Doherty-WOLDohertyWOLDEF4.5Available6633522.90.3
\n

605 rows \u00d7 10 columns

\n
", + "text/plain": " name team position cost status minutes \\\nid \nBalogun-ARS Balogun ARS FWD 4.5 Available 0 \nC\u00e9dric-ARS C\u00e9dric ARS DEF 4.0 Available 223 \nM.Elneny-ARS M.Elneny ARS MID 4.5 Available 111 \nF\u00e1bio Vieira-ARS F\u00e1bio Vieira ARS MID 5.5 Available 500 \nGabriel-ARS Gabriel ARS DEF 5.0 Available 3409 \n... ... ... ... ... ... ... \nN.Semedo-WOL N.Semedo WOL DEF 4.5 Available 2633 \nToti-WOL Toti WOL DEF 4.5 Available 978 \nBoubacar Traore-WOL Boubacar Traore WOL MID 4.5 Available 405 \nCunha-WOL Cunha WOL FWD 5.5 Available 961 \nDoherty-WOL Doherty WOL DEF 4.5 Available 663 \n\n total_points bonus points_per_game selected_by_percent \nid \nBalogun-ARS 0 0 0.0 1.5 \nC\u00e9dric-ARS 10 0 1.2 0.4 \nM.Elneny-ARS 6 0 1.2 0.2 \nF\u00e1bio Vieira-ARS 40 2 1.8 0.1 \nGabriel-ARS 146 15 3.8 19.2 \n... ... ... ... ... \nN.Semedo-WOL 75 5 2.1 0.3 \nToti-WOL 43 4 2.5 0.2 \nBoubacar Traore-WOL 14 0 1.4 0.6 \nCunha-WOL 39 6 2.3 0.1 \nDoherty-WOL 35 2 2.9 0.3 \n\n[605 rows x 10 columns]" + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " name team position cost status minutes \\\n", - "id \n", - "Balogun-ARS Balogun ARS FWD 4.5 Available 0 \n", - "Cédric-ARS Cédric ARS DEF 4.0 Available 223 \n", - "M.Elneny-ARS M.Elneny ARS MID 4.5 Available 111 \n", - "Fábio Vieira-ARS Fábio Vieira ARS MID 5.5 Available 500 \n", - "Gabriel-ARS Gabriel ARS DEF 5.0 Available 3409 \n", - "... ... ... ... ... ... ... \n", - "N.Semedo-WOL N.Semedo WOL DEF 4.5 Available 2633 \n", - "Toti-WOL Toti WOL DEF 4.5 Available 978 \n", - "Boubacar Traore-WOL Boubacar Traore WOL MID 4.5 Available 405 \n", - "Cunha-WOL Cunha WOL FWD 5.5 Available 961 \n", - "Doherty-WOL Doherty WOL DEF 4.5 Available 663 \n", - "\n", - " total_points bonus points_per_game selected_by_percent \n", - "id \n", - "Balogun-ARS 0 0 0.0 1.5 \n", - "Cédric-ARS 10 0 1.2 0.4 \n", - "M.Elneny-ARS 6 0 1.2 0.2 \n", - "Fábio Vieira-ARS 40 2 1.8 0.1 \n", - "Gabriel-ARS 146 15 3.8 19.2 \n", - "... ... ... ... ... \n", - "N.Semedo-WOL 75 5 2.1 0.3 \n", - "Toti-WOL 43 4 2.5 0.2 \n", - "Boubacar Traore-WOL 14 0 1.4 0.6 \n", - "Cunha-WOL 39 6 2.3 0.1 \n", - "Doherty-WOL 35 2 2.9 0.3 \n", - "\n", - "[605 rows x 10 columns]" + "source": [ + "import opvious\n", + "\n", + "_PLAYER_DATA_URL = \"https://gist.githubusercontent.com/mtth/f59bdde8694223c06f77089b71b48d17/raw/6f1568cb2ff69450f06e3b8045d504af74bb701f/fpl-2023-07-26.csv\"\n", + "\n", + "async def _download_player_data():\n", + " \"\"\"Downloads a dataframe of player statistics\"\"\"\n", + " df = await opvious.executors.fetch_csv(_PLAYER_DATA_URL)\n", + " # Some player names are not unique, we disambiguate by suffixing with the team's name\n", + " df[\"id\"] = df.apply(lambda r: f\"{r['name']}-{r['team']}\", axis=1)\n", + " return df.set_index(\"id\", verify_integrity=True)\n", + "\n", + "player_data = await _download_player_data()\n", + "player_data" ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious\n", - "\n", - "_PLAYER_DATA_URL = \"https://gist.githubusercontent.com/mtth/f59bdde8694223c06f77089b71b48d17/raw/6f1568cb2ff69450f06e3b8045d504af74bb701f/fpl-2023-07-26.csv\"\n", - "\n", - "async def _download_player_data():\n", - " \"\"\"Downloads a dataframe of player statistics\"\"\"\n", - " df = await opvious.executors.fetch_csv(_PLAYER_DATA_URL)\n", - " # Some player names are not unique, we disambiguate by suffixing with the team's name\n", - " df[\"id\"] = df.apply(lambda r: f\"{r['name']}-{r['team']}\", axis=1)\n", - " return df.set_index(\"id\", verify_integrity=True)\n", - "\n", - "player_data = await _download_player_data()\n", - "player_data" - ] - }, - { - "cell_type": "markdown", - "id": "05a5cd48", - "metadata": {}, - "source": [ - "## Formulation\n", - "\n", - "The next step is to formulate team selection as an [integer program](https://en.wikipedia.org/wiki/Integer_programming) using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html). For simplicity we omit transfers and use a multiplication factor to estimate the value of substitute players.\n", - "\n", - "\n", - "
\n", - " ⓘ You do not need to understand the code below to use it for selecting a team. Feel free to skip ahead to the next section to see it in action!\n", - "
" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "bba831ab", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class TeamSelection(om.Model):\n", - " \"\"\"Fantasy Premier League team selection integer program\"\"\"\n", - " \n", - " players = om.Dimension()\n", - " positions = om.Dimension()\n", - " teams = om.Dimension()\n", - " \n", - " # Player data\n", - " player_cost = om.Parameter.non_negative(players)\n", - " player_value = om.Parameter.non_negative(players)\n", - " player_team = om.Parameter.indicator(players, teams)\n", - " player_position = om.Parameter.indicator(players, positions)\n", - " \n", - " # Number of players per position\n", - " squad_formation = om.Parameter.natural(positions)\n", - " starter_min_formation = om.Parameter.natural(positions)\n", - " starter_max_formation = om.Parameter.natural(positions)\n", - " \n", - " # Outputs\n", - " is_picked = om.Variable.indicator(players)\n", - " is_starter = om.Variable.indicator(players)\n", - " is_captain = om.Variable.indicator(players)\n", - " is_vice_captain = om.Variable.indicator(players)\n", - " \n", - " def __init__(self, substitution_factor=0.1):\n", - " self.substitution_factor = substitution_factor\n", - " \n", - " @om.constraint\n", - " def total_picked_cost_is_within_budget(self):\n", - " yield om.total(self.is_picked(p) * self.player_cost(p) for p in self.players) <= 100\n", - " \n", - " @om.constraint\n", - " def at_most_3_picked_per_team(self):\n", - " for t in self.teams:\n", - " yield om.total(self.is_picked(p) * self.player_team(p, t) for p in self.players) <= 3\n", - "\n", - " @om.constraint\n", - " def exactly_11_starters(self):\n", - " yield self.is_starter.total() == 11\n", - " \n", - " @om.constraint\n", - " def starters_are_picked(self):\n", - " for p in self.players:\n", - " yield self.is_starter(p) <= self.is_picked(p)\n", - " \n", - " @om.constraint\n", - " def captain_is_starter(self):\n", - " for p in self.players:\n", - " yield self.is_captain(p) <= self.is_starter(p)\n", - " \n", - " @om.constraint\n", - " def vice_captain_is_starter(self):\n", - " for p in self.players:\n", - " yield self.is_vice_captain(p) <= self.is_starter(p)\n", - " \n", - " @om.constraint\n", - " def exactly_one_captain(self):\n", - " yield self.is_captain.total() == 1\n", - " \n", - " @om.constraint\n", - " def exactly_one_vice_captain(self):\n", - " yield self.is_vice_captain.total() == 1\n", - " \n", - " @om.constraint\n", - " def captain_is_not_vice_captain(self):\n", - " for p in self.players:\n", - " yield self.is_captain(p) + self.is_vice_captain(p) <= 1\n", - " \n", - " @om.constraint\n", - " def picked_positions_match_formation(self):\n", - " for q in self.positions:\n", - " count = om.total(self.is_picked(p) * self.player_position(p, q) for p in self.players)\n", - " yield count == self.squad_formation(q)\n", - " \n", - " @om.constraint\n", - " def starter_positions_match_min_formation(self):\n", - " for q in self.positions:\n", - " count = om.total(self.is_starter(p) * self.player_position(p, q) for p in self.players)\n", - " yield count >= self.starter_min_formation(q)\n", - "\n", - " @om.constraint\n", - " def starter_positions_match_max_formation(self):\n", - " for q in self.positions:\n", - " count = om.total(self.is_starter(p) * self.player_position(p, q) for p in self.players)\n", - " yield count <= self.starter_max_formation(q)\n", - "\n", - " def picked_player_value(self, p):\n", - " return (\n", - " self.substitution_factor * (self.is_picked(p) + self.is_vice_captain(p)) +\n", - " (1 - self.substitution_factor) * self.is_starter(p) + self.is_captain(p)\n", - " ) * self.player_value(p)\n", - "\n", - " @om.objective\n", - " def maximize_total_value_of_picked_players(self):\n", - " return om.total(self.picked_player_value(p) for p in self.players)" - ] - }, - { - "cell_type": "markdown", - "id": "10063da7", - "metadata": {}, - "source": [ - "## Application\n", - "\n", - "We are now ready to find an optimal squad!\n", - "\n", - "_Optimal_ is defined as maximizing the team's value, computed as:\n", - "\n", - "* the sum of its starter players' values, plus\n", - "* the sum of its substitute player's values multiplied by a `substitution_factor` (0.1 by default), plus\n", - "* the captain's value (achieving the bonus effect since the captain is always a starter), plus\n", - "* the vice-captain's value multiplied by the `substitution_factor`.\n", - "\n", - "Each individual player's value is computed as a weighted average of their total points and points per\n", - "game. The weight is controlled by `total_vs_per_game_ratio`: setting this to 1 will\n", - "only consider total points, setting it to 0 will only consider points per game, 0.5 will use the mean.\n", - "\n", - "To allow for personal preferences and judgments, it's also possible to change players' values by specifying per-player multipliers. Setting a high value for a player will make them more likely to be picked, setting a 0 multiplier will prevent them from being picked." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "4e0e4842", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "import pandas as pd\n", - "\n", - "_client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "async def find_optimal_squad(\n", - " substitution_factor=0.1,\n", - " total_vs_per_game_ratio=1,\n", - " player_multipliers=None,\n", - "):\n", - " \"\"\"Returns a squad which maximizes team value (see above) while respecting FPL rules\"\"\"\n", - " players = player_data[player_data['status'] == 'Available'].drop(\"status\", axis=1)\n", - " multipliers = player_multipliers or {}\n", - " solution = await _client.solve(\n", - " opvious.Problem(\n", - " TeamSelection(substitution_factor).specification(),\n", - " parameters={\n", - " \"playerCost\": players[\"cost\"],\n", - " \"playerValue\": players.apply(\n", - " lambda r: max(1, # New/transferred players have a value of 0\n", - " total_vs_per_game_ratio * r[\"total_points\"] +\n", - " (1 - total_vs_per_game_ratio) * r[\"points_per_game\"]\n", - " ) * multipliers.get(r.name, 1),\n", - " axis=1,\n", - " ),\n", - " \"playerTeam\": players[\"team\"],\n", - " \"playerPosition\": players[\"position\"],\n", - " \"squadFormation\": {\"GKP\": 2, \"DEF\": 5, \"MID\": 5, \"FWD\": 3},\n", - " \"starterMinFormation\": {\"GKP\": 1, \"DEF\": 3, \"FWD\": 1},\n", - " \"starterMaxFormation\": {\"GKP\": 1, \"DEF\": 5, \"MID\": 5, \"FWD\": 3},\n", - " }\n", - " )\n", - " )\n", - " selected = pd.concat({\n", - " key: solution.outputs.variable(key)[\"value\"]\n", - " for key in [\"isPicked\", \"isStarter\", \"isCaptain\", \"isViceCaptain\"]\n", - " }, axis=1).fillna(0).astype(int)\n", - " return pd.concat([players, selected], axis=1, join=\"inner\").drop([\"isPicked\", \"name\", \"team\"], axis=1)" - ] - }, - { - "cell_type": "markdown", - "id": "764a70a6", - "metadata": {}, - "source": [ - "
\n", - " ⚠ You will need an Opvious API access token to run the function above since the data size exceeds the limit for guest solves. Once you've created one here (signing up is free), simply edit the cell above and insert it where indicated.\n", - "
\n", - "\n", - "Let's see what we get when solving with the default parameters (note the three columns on the right which indicate whether a player is on the starting roster, is captain, and is vice-captain)." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "1d92be00", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "05a5cd48", + "metadata": {}, + "source": [ + "## Formulation\n", + "\n", + "The next step is to formulate team selection as an [integer program](https://en.wikipedia.org/wiki/Integer_programming) using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html). For simplicity we omit transfers and use a multiplication factor to estimate the value of substitute players.\n", + "\n", + "\n", + "
\n", + " ⓘ You do not need to understand the code below to use it for selecting a team. Feel free to skip ahead to the next section to see it in action!\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "bba831ab", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious.modeling as om\n", + "\n", + "class TeamSelection(om.Model):\n", + " \"\"\"Fantasy Premier League team selection integer program\"\"\"\n", + " \n", + " players = om.Dimension()\n", + " positions = om.Dimension()\n", + " teams = om.Dimension()\n", + " \n", + " # Player data\n", + " player_cost = om.Parameter.non_negative(players)\n", + " player_value = om.Parameter.non_negative(players)\n", + " player_team = om.Parameter.indicator(players, teams)\n", + " player_position = om.Parameter.indicator(players, positions)\n", + " \n", + " # Number of players per position\n", + " squad_formation = om.Parameter.natural(positions)\n", + " starter_min_formation = om.Parameter.natural(positions)\n", + " starter_max_formation = om.Parameter.natural(positions)\n", + " \n", + " # Outputs\n", + " is_picked = om.Variable.indicator(players)\n", + " is_starter = om.Variable.indicator(players)\n", + " is_captain = om.Variable.indicator(players)\n", + " is_vice_captain = om.Variable.indicator(players)\n", + " \n", + " def __init__(self, substitution_factor=0.1):\n", + " self.substitution_factor = substitution_factor\n", + " \n", + " @om.constraint\n", + " def total_picked_cost_is_within_budget(self):\n", + " yield om.total(self.is_picked(p) * self.player_cost(p) for p in self.players) <= 100\n", + " \n", + " @om.constraint\n", + " def at_most_3_picked_per_team(self):\n", + " for t in self.teams:\n", + " yield om.total(self.is_picked(p) * self.player_team(p, t) for p in self.players) <= 3\n", + "\n", + " @om.constraint\n", + " def exactly_11_starters(self):\n", + " yield self.is_starter.total() == 11\n", + " \n", + " @om.constraint\n", + " def starters_are_picked(self):\n", + " for p in self.players:\n", + " yield self.is_starter(p) <= self.is_picked(p)\n", + " \n", + " @om.constraint\n", + " def captain_is_starter(self):\n", + " for p in self.players:\n", + " yield self.is_captain(p) <= self.is_starter(p)\n", + " \n", + " @om.constraint\n", + " def vice_captain_is_starter(self):\n", + " for p in self.players:\n", + " yield self.is_vice_captain(p) <= self.is_starter(p)\n", + " \n", + " @om.constraint\n", + " def exactly_one_captain(self):\n", + " yield self.is_captain.total() == 1\n", + " \n", + " @om.constraint\n", + " def exactly_one_vice_captain(self):\n", + " yield self.is_vice_captain.total() == 1\n", + " \n", + " @om.constraint\n", + " def captain_is_not_vice_captain(self):\n", + " for p in self.players:\n", + " yield self.is_captain(p) + self.is_vice_captain(p) <= 1\n", + " \n", + " @om.constraint\n", + " def picked_positions_match_formation(self):\n", + " for q in self.positions:\n", + " count = om.total(self.is_picked(p) * self.player_position(p, q) for p in self.players)\n", + " yield count == self.squad_formation(q)\n", + " \n", + " @om.constraint\n", + " def starter_positions_match_min_formation(self):\n", + " for q in self.positions:\n", + " count = om.total(self.is_starter(p) * self.player_position(p, q) for p in self.players)\n", + " yield count >= self.starter_min_formation(q)\n", + "\n", + " @om.constraint\n", + " def starter_positions_match_max_formation(self):\n", + " for q in self.positions:\n", + " count = om.total(self.is_starter(p) * self.player_position(p, q) for p in self.players)\n", + " yield count <= self.starter_max_formation(q)\n", + "\n", + " def picked_player_value(self, p):\n", + " return (\n", + " self.substitution_factor * (self.is_picked(p) + self.is_vice_captain(p)) +\n", + " (1 - self.substitution_factor) * self.is_starter(p) + self.is_captain(p)\n", + " ) * self.player_value(p)\n", + "\n", + " @om.objective\n", + " def maximize_total_value_of_picked_players(self):\n", + " return om.total(self.picked_player_value(p) for p in self.players)" + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
positioncostminutestotal_pointsbonuspoints_per_gameselected_by_percentisStarterisCaptainisViceCaptain
Martinelli-ARSMID8.02789198185.514.3100
Ødegaard-ARSMID8.53132212305.720.2100
White-ARSDEF5.53054156124.19.7100
Douglas Luiz-AVLMID5.52922142173.82.9100
Mings-AVLDEF4.53150130173.715.4000
Semenyo-BOUFWD4.52501811.61.7000
Mee-BREDEF5.03269143113.97.5100
Raya-BREGKP5.03420166204.49.6100
Gross-BHAMID6.53240159144.34.7100
Leno-FULGKP4.53240142173.99.0000
Haaland-MCIFWD14.02767272407.886.2110
Anderson-NEWMID4.53953021.42.7000
Schär-NEWDEF5.0320713963.95.1100
Trippier-NEWDEF6.53342198395.234.6100
Kane-TOTFWD12.53406263486.913.9101
\n", - "
" + "cell_type": "markdown", + "id": "10063da7", + "metadata": {}, + "source": [ + "## Application\n", + "\n", + "We are now ready to find an optimal squad!\n", + "\n", + "_Optimal_ is defined as maximizing the team's value, computed as:\n", + "\n", + "* the sum of its starter players' values, plus\n", + "* the sum of its substitute player's values multiplied by a `substitution_factor` (0.1 by default), plus\n", + "* the captain's value (achieving the bonus effect since the captain is always a starter), plus\n", + "* the vice-captain's value multiplied by the `substitution_factor`.\n", + "\n", + "Each individual player's value is computed as a weighted average of their total points and points per\n", + "game. The weight is controlled by `total_vs_per_game_ratio`: setting this to 1 will\n", + "only consider total points, setting it to 0 will only consider points per game, 0.5 will use the mean.\n", + "\n", + "To allow for personal preferences and judgments, it's also possible to change players' values by specifying per-player multipliers. Setting a high value for a player will make them more likely to be picked, setting a 0 multiplier will prevent them from being picked." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "4e0e4842", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "import pandas as pd\n", + "\n", + "_client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "async def find_optimal_squad(\n", + " substitution_factor=0.1,\n", + " total_vs_per_game_ratio=1,\n", + " player_multipliers=None,\n", + "):\n", + " \"\"\"Returns a squad which maximizes team value (see above) while respecting FPL rules\"\"\"\n", + " players = player_data[player_data['status'] == 'Available'].drop(\"status\", axis=1)\n", + " multipliers = player_multipliers or {}\n", + " solution = await _client.solve(\n", + " opvious.Problem(\n", + " TeamSelection(substitution_factor).specification(),\n", + " parameters={\n", + " \"playerCost\": players[\"cost\"],\n", + " \"playerValue\": players.apply(\n", + " lambda r: max(1, # New/transferred players have a value of 0\n", + " total_vs_per_game_ratio * r[\"total_points\"] +\n", + " (1 - total_vs_per_game_ratio) * r[\"points_per_game\"]\n", + " ) * multipliers.get(r.name, 1),\n", + " axis=1,\n", + " ),\n", + " \"playerTeam\": players[\"team\"],\n", + " \"playerPosition\": players[\"position\"],\n", + " \"squadFormation\": {\"GKP\": 2, \"DEF\": 5, \"MID\": 5, \"FWD\": 3},\n", + " \"starterMinFormation\": {\"GKP\": 1, \"DEF\": 3, \"FWD\": 1},\n", + " \"starterMaxFormation\": {\"GKP\": 1, \"DEF\": 5, \"MID\": 5, \"FWD\": 3},\n", + " }\n", + " )\n", + " )\n", + " selected = pd.concat({\n", + " key: solution.outputs.variable(key)[\"value\"]\n", + " for key in [\"isPicked\", \"isStarter\", \"isCaptain\", \"isViceCaptain\"]\n", + " }, axis=1).fillna(0).astype(int)\n", + " return pd.concat([players, selected], axis=1, join=\"inner\").drop([\"isPicked\", \"name\", \"team\"], axis=1)" + ] + }, + { + "cell_type": "markdown", + "id": "764a70a6", + "metadata": {}, + "source": [ + "
\n", + " ⚠ You will need an Opvious API access token to run the function above since the data size exceeds the limit for guest solves. Once you've created one here (signing up is free), simply edit the cell above and insert it where indicated.\n", + "
\n", + "\n", + "Let's see what we get when solving with the default parameters (note the three columns on the right which indicate whether a player is on the starting roster, is captain, and is vice-captain)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "1d92be00", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
positioncostminutestotal_pointsbonuspoints_per_gameselected_by_percentisStarterisCaptainisViceCaptain
Martinelli-ARSMID8.02789198185.514.3100
\u00d8degaard-ARSMID8.53132212305.720.2100
White-ARSDEF5.53054156124.19.7100
Douglas Luiz-AVLMID5.52922142173.82.9100
Mings-AVLDEF4.53150130173.715.4000
Semenyo-BOUFWD4.52501811.61.7000
Mee-BREDEF5.03269143113.97.5100
Raya-BREGKP5.03420166204.49.6100
Gross-BHAMID6.53240159144.34.7100
Leno-FULGKP4.53240142173.99.0000
Haaland-MCIFWD14.02767272407.886.2110
Anderson-NEWMID4.53953021.42.7000
Sch\u00e4r-NEWDEF5.0320713963.95.1100
Trippier-NEWDEF6.53342198395.234.6100
Kane-TOTFWD12.53406263486.913.9101
\n
", + "text/plain": " position cost minutes total_points bonus \\\nMartinelli-ARS MID 8.0 2789 198 18 \n\u00d8degaard-ARS MID 8.5 3132 212 30 \nWhite-ARS DEF 5.5 3054 156 12 \nDouglas Luiz-AVL MID 5.5 2922 142 17 \nMings-AVL DEF 4.5 3150 130 17 \nSemenyo-BOU FWD 4.5 250 18 1 \nMee-BRE DEF 5.0 3269 143 11 \nRaya-BRE GKP 5.0 3420 166 20 \nGross-BHA MID 6.5 3240 159 14 \nLeno-FUL GKP 4.5 3240 142 17 \nHaaland-MCI FWD 14.0 2767 272 40 \nAnderson-NEW MID 4.5 395 30 2 \nSch\u00e4r-NEW DEF 5.0 3207 139 6 \nTrippier-NEW DEF 6.5 3342 198 39 \nKane-TOT FWD 12.5 3406 263 48 \n\n points_per_game selected_by_percent isStarter isCaptain \\\nMartinelli-ARS 5.5 14.3 1 0 \n\u00d8degaard-ARS 5.7 20.2 1 0 \nWhite-ARS 4.1 9.7 1 0 \nDouglas Luiz-AVL 3.8 2.9 1 0 \nMings-AVL 3.7 15.4 0 0 \nSemenyo-BOU 1.6 1.7 0 0 \nMee-BRE 3.9 7.5 1 0 \nRaya-BRE 4.4 9.6 1 0 \nGross-BHA 4.3 4.7 1 0 \nLeno-FUL 3.9 9.0 0 0 \nHaaland-MCI 7.8 86.2 1 1 \nAnderson-NEW 1.4 2.7 0 0 \nSch\u00e4r-NEW 3.9 5.1 1 0 \nTrippier-NEW 5.2 34.6 1 0 \nKane-TOT 6.9 13.9 1 0 \n\n isViceCaptain \nMartinelli-ARS 0 \n\u00d8degaard-ARS 0 \nWhite-ARS 0 \nDouglas Luiz-AVL 0 \nMings-AVL 0 \nSemenyo-BOU 0 \nMee-BRE 0 \nRaya-BRE 0 \nGross-BHA 0 \nLeno-FUL 0 \nHaaland-MCI 0 \nAnderson-NEW 0 \nSch\u00e4r-NEW 0 \nTrippier-NEW 0 \nKane-TOT 1 " + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " position cost minutes total_points bonus \\\n", - "Martinelli-ARS MID 8.0 2789 198 18 \n", - "Ødegaard-ARS MID 8.5 3132 212 30 \n", - "White-ARS DEF 5.5 3054 156 12 \n", - "Douglas Luiz-AVL MID 5.5 2922 142 17 \n", - "Mings-AVL DEF 4.5 3150 130 17 \n", - "Semenyo-BOU FWD 4.5 250 18 1 \n", - "Mee-BRE DEF 5.0 3269 143 11 \n", - "Raya-BRE GKP 5.0 3420 166 20 \n", - "Gross-BHA MID 6.5 3240 159 14 \n", - "Leno-FUL GKP 4.5 3240 142 17 \n", - "Haaland-MCI FWD 14.0 2767 272 40 \n", - "Anderson-NEW MID 4.5 395 30 2 \n", - "Schär-NEW DEF 5.0 3207 139 6 \n", - "Trippier-NEW DEF 6.5 3342 198 39 \n", - "Kane-TOT FWD 12.5 3406 263 48 \n", - "\n", - " points_per_game selected_by_percent isStarter isCaptain \\\n", - "Martinelli-ARS 5.5 14.3 1 0 \n", - "Ødegaard-ARS 5.7 20.2 1 0 \n", - "White-ARS 4.1 9.7 1 0 \n", - "Douglas Luiz-AVL 3.8 2.9 1 0 \n", - "Mings-AVL 3.7 15.4 0 0 \n", - "Semenyo-BOU 1.6 1.7 0 0 \n", - "Mee-BRE 3.9 7.5 1 0 \n", - "Raya-BRE 4.4 9.6 1 0 \n", - "Gross-BHA 4.3 4.7 1 0 \n", - "Leno-FUL 3.9 9.0 0 0 \n", - "Haaland-MCI 7.8 86.2 1 1 \n", - "Anderson-NEW 1.4 2.7 0 0 \n", - "Schär-NEW 3.9 5.1 1 0 \n", - "Trippier-NEW 5.2 34.6 1 0 \n", - "Kane-TOT 6.9 13.9 1 0 \n", - "\n", - " isViceCaptain \n", - "Martinelli-ARS 0 \n", - "Ødegaard-ARS 0 \n", - "White-ARS 0 \n", - "Douglas Luiz-AVL 0 \n", - "Mings-AVL 0 \n", - "Semenyo-BOU 0 \n", - "Mee-BRE 0 \n", - "Raya-BRE 0 \n", - "Gross-BHA 0 \n", - "Leno-FUL 0 \n", - "Haaland-MCI 0 \n", - "Anderson-NEW 0 \n", - "Schär-NEW 0 \n", - "Trippier-NEW 0 \n", - "Kane-TOT 1 " + "source": [ + "await find_optimal_squad()" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await find_optimal_squad()" - ] - }, - { - "cell_type": "markdown", - "id": "7e941f4a", - "metadata": {}, - "source": [ - "We also tweak the parameters to get a different team. For example if we:\n", - "\n", - "* think that Mohamed Salah is undervalued in the current statistics, and\n", - "* want to also consider points per game (instead of only total points),\n", - "\n", - "we would run it with the following arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "5451a58d", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
positioncostminutestotal_pointsbonuspoints_per_gameselected_by_percentisStarterisCaptainisViceCaptain
Gabriel-ARSDEF5.03409146153.819.2100
Martinelli-ARSMID8.02789198185.514.3100
Ødegaard-ARSMID8.53132212305.720.2100
Archer-AVLFWD4.543601.06.9000
Douglas Luiz-AVLMID5.52922142173.82.9100
Mings-AVLDEF4.53150130173.715.4100
Semenyo-BOUFWD4.52501811.61.7000
Mee-BREDEF5.03269143113.97.5100
Raya-BREGKP5.03420166204.49.6100
Leno-FULGKP4.53240142173.99.0000
Salah-LIVMID12.53290239236.324.5110
Rashford-MUNMID9.02880205215.943.2100
Botman-NEWDEF4.5312712983.625.9000
Trippier-NEWDEF6.53342198395.234.6100
Kane-TOTFWD12.53406263486.913.9101
\n", - "
" + "cell_type": "markdown", + "id": "7e941f4a", + "metadata": {}, + "source": [ + "We also tweak the parameters to get a different team. For example if we:\n", + "\n", + "* think that Mohamed Salah is undervalued in the current statistics, and\n", + "* want to also consider points per game (instead of only total points),\n", + "\n", + "we would run it with the following arguments:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5451a58d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
positioncostminutestotal_pointsbonuspoints_per_gameselected_by_percentisStarterisCaptainisViceCaptain
Gabriel-ARSDEF5.03409146153.819.2100
Martinelli-ARSMID8.02789198185.514.3100
\u00d8degaard-ARSMID8.53132212305.720.2100
Archer-AVLFWD4.543601.06.9000
Douglas Luiz-AVLMID5.52922142173.82.9100
Mings-AVLDEF4.53150130173.715.4100
Semenyo-BOUFWD4.52501811.61.7000
Mee-BREDEF5.03269143113.97.5100
Raya-BREGKP5.03420166204.49.6100
Leno-FULGKP4.53240142173.99.0000
Salah-LIVMID12.53290239236.324.5110
Rashford-MUNMID9.02880205215.943.2100
Botman-NEWDEF4.5312712983.625.9000
Trippier-NEWDEF6.53342198395.234.6100
Kane-TOTFWD12.53406263486.913.9101
\n
", + "text/plain": " position cost minutes total_points bonus \\\nGabriel-ARS DEF 5.0 3409 146 15 \nMartinelli-ARS MID 8.0 2789 198 18 \n\u00d8degaard-ARS MID 8.5 3132 212 30 \nArcher-AVL FWD 4.5 43 6 0 \nDouglas Luiz-AVL MID 5.5 2922 142 17 \nMings-AVL DEF 4.5 3150 130 17 \nSemenyo-BOU FWD 4.5 250 18 1 \nMee-BRE DEF 5.0 3269 143 11 \nRaya-BRE GKP 5.0 3420 166 20 \nLeno-FUL GKP 4.5 3240 142 17 \nSalah-LIV MID 12.5 3290 239 23 \nRashford-MUN MID 9.0 2880 205 21 \nBotman-NEW DEF 4.5 3127 129 8 \nTrippier-NEW DEF 6.5 3342 198 39 \nKane-TOT FWD 12.5 3406 263 48 \n\n points_per_game selected_by_percent isStarter isCaptain \\\nGabriel-ARS 3.8 19.2 1 0 \nMartinelli-ARS 5.5 14.3 1 0 \n\u00d8degaard-ARS 5.7 20.2 1 0 \nArcher-AVL 1.0 6.9 0 0 \nDouglas Luiz-AVL 3.8 2.9 1 0 \nMings-AVL 3.7 15.4 1 0 \nSemenyo-BOU 1.6 1.7 0 0 \nMee-BRE 3.9 7.5 1 0 \nRaya-BRE 4.4 9.6 1 0 \nLeno-FUL 3.9 9.0 0 0 \nSalah-LIV 6.3 24.5 1 1 \nRashford-MUN 5.9 43.2 1 0 \nBotman-NEW 3.6 25.9 0 0 \nTrippier-NEW 5.2 34.6 1 0 \nKane-TOT 6.9 13.9 1 0 \n\n isViceCaptain \nGabriel-ARS 0 \nMartinelli-ARS 0 \n\u00d8degaard-ARS 0 \nArcher-AVL 0 \nDouglas Luiz-AVL 0 \nMings-AVL 0 \nSemenyo-BOU 0 \nMee-BRE 0 \nRaya-BRE 0 \nLeno-FUL 0 \nSalah-LIV 0 \nRashford-MUN 0 \nBotman-NEW 0 \nTrippier-NEW 0 \nKane-TOT 1 " + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " position cost minutes total_points bonus \\\n", - "Gabriel-ARS DEF 5.0 3409 146 15 \n", - "Martinelli-ARS MID 8.0 2789 198 18 \n", - "Ødegaard-ARS MID 8.5 3132 212 30 \n", - "Archer-AVL FWD 4.5 43 6 0 \n", - "Douglas Luiz-AVL MID 5.5 2922 142 17 \n", - "Mings-AVL DEF 4.5 3150 130 17 \n", - "Semenyo-BOU FWD 4.5 250 18 1 \n", - "Mee-BRE DEF 5.0 3269 143 11 \n", - "Raya-BRE GKP 5.0 3420 166 20 \n", - "Leno-FUL GKP 4.5 3240 142 17 \n", - "Salah-LIV MID 12.5 3290 239 23 \n", - "Rashford-MUN MID 9.0 2880 205 21 \n", - "Botman-NEW DEF 4.5 3127 129 8 \n", - "Trippier-NEW DEF 6.5 3342 198 39 \n", - "Kane-TOT FWD 12.5 3406 263 48 \n", - "\n", - " points_per_game selected_by_percent isStarter isCaptain \\\n", - "Gabriel-ARS 3.8 19.2 1 0 \n", - "Martinelli-ARS 5.5 14.3 1 0 \n", - "Ødegaard-ARS 5.7 20.2 1 0 \n", - "Archer-AVL 1.0 6.9 0 0 \n", - "Douglas Luiz-AVL 3.8 2.9 1 0 \n", - "Mings-AVL 3.7 15.4 1 0 \n", - "Semenyo-BOU 1.6 1.7 0 0 \n", - "Mee-BRE 3.9 7.5 1 0 \n", - "Raya-BRE 4.4 9.6 1 0 \n", - "Leno-FUL 3.9 9.0 0 0 \n", - "Salah-LIV 6.3 24.5 1 1 \n", - "Rashford-MUN 5.9 43.2 1 0 \n", - "Botman-NEW 3.6 25.9 0 0 \n", - "Trippier-NEW 5.2 34.6 1 0 \n", - "Kane-TOT 6.9 13.9 1 0 \n", - "\n", - " isViceCaptain \n", - "Gabriel-ARS 0 \n", - "Martinelli-ARS 0 \n", - "Ødegaard-ARS 0 \n", - "Archer-AVL 0 \n", - "Douglas Luiz-AVL 0 \n", - "Mings-AVL 0 \n", - "Semenyo-BOU 0 \n", - "Mee-BRE 0 \n", - "Raya-BRE 0 \n", - "Leno-FUL 0 \n", - "Salah-LIV 0 \n", - "Rashford-MUN 0 \n", - "Botman-NEW 0 \n", - "Trippier-NEW 0 \n", - "Kane-TOT 1 " + "source": [ + "await find_optimal_squad(\n", + " player_multipliers={\"Salah-LIV\": 1.5}, # 50% value boost\n", + " total_vs_per_game_ratio=0.5, # Evaluate players on average of total points and per-game points\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "46bc2ac4", + "metadata": {}, + "source": [ + "Up to you to try it out with different parameters and see if you can find your squad!" ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "9414b155", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await find_optimal_squad(\n", - " player_multipliers={\"Salah-LIV\": 1.5}, # 50% value boost\n", - " total_vs_per_game_ratio=0.5, # Evaluate players on average of total points and per-game points\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "46bc2ac4", - "metadata": {}, - "source": [ - "Up to you to try it out with different parameters and see if you can find your squad!" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "9414b155", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/job-shop-scheduling.ipynb b/resources/examples/job-shop-scheduling.ipynb index 5b66440..bcc5d1f 100644 --- a/resources/examples/job-shop-scheduling.ipynb +++ b/resources/examples/job-shop-scheduling.ipynb @@ -1,301 +1,200 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "1a5917f2-027e-47bd-b0cd-19e785e5c455", - "metadata": {}, - "source": [ - "# Job shop scheduling\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser.\n", - "
\n", - "\n", - "In this notebook we implement the job shop scheduling problem described in https://jckantor.github.io/ND-Pyomo-Cookbook/notebooks/04.03-Job-Shop-Scheduling.html. We show in particular how [activation variable fragments](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.modeling.fragments.ActivationVariable) can be used to implement disjunctive constraints." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "9b75dc93-8b84-4dbe-b6fc-a81b45f6b8fb", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "0187a406-d58f-4f9a-badc-548315e7e23c", - "metadata": {}, - "source": [ - "## Formulation\n", - "\n", - "The first step is to formulate the problem problem using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "6803054a-6db7-4eee-aa39-8b4fec995836", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "1a5917f2-027e-47bd-b0cd-19e785e5c455", + "metadata": {}, + "source": [ + "# Job shop scheduling\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser.\n", + "
\n", + "\n", + "In this notebook we implement the job shop scheduling problem described in https://jckantor.github.io/ND-Pyomo-Cookbook/notebooks/04.03-Job-Shop-Scheduling.html. We show in particular how [activation variable fragments](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.modeling.fragments.ActivationVariable) can be used to implement disjunctive constraints." + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "JobShopScheduling\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{tasks}&: T \\\\\n", - " \\S^p_\\mathrm{duration}&: d \\in \\mathbb{N}^{T} \\\\\n", - " \\S^p_\\mathrm{machine}&: m \\in \\mathbb{N}^{T} \\\\\n", - " \\S^p_\\mathrm{dependency[child,parent]}&: d' \\in \\{0, 1\\}^{T \\times T} \\\\\n", - " \\S^v_\\mathrm{taskStart}&: \\sigma^\\mathrm{task} \\in \\{1 \\ldots \\infty\\}^{T} \\\\\n", - " \\S^v_\\mathrm{horizon}&: \\eta \\in \\mathbb{N} \\\\\n", - " \\S^c_\\mathrm{allTasksEndWithinHorizon}&: \\forall t \\in T, \\sigma^\\mathrm{task}_{t} + d_{t} \\leq \\eta \\\\\n", - " \\S^c_\\mathrm{childStartsAfterParentEnds}&: \\forall t, t' \\in T \\mid d'_{t,t'} \\neq 0, \\sigma^\\mathrm{task}_{t} \\geq \\sigma^\\mathrm{task}_{t'} + d_{t'} \\\\\n", - " \\S^v_\\mathrm{mustStartAfter}&: \\alpha^\\mathrm{mustStart} \\in \\{0, 1\\}^{\\{ t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'} \\}} \\\\\n", - " \\S^c_\\mathrm{mustStartAfterActivates}&: \\forall t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'}, \\sum_{t'' \\in T} d_{t''} \\left(1 - \\alpha^\\mathrm{mustStart}_{t,t'}\\right) \\geq \\sigma^\\mathrm{task}_{t'} + d_{t'} - \\sigma^\\mathrm{task}_{t} \\\\\n", - " \\S^v_\\mathrm{mustEndBefore}&: \\beta^\\mathrm{mustEnd} \\in \\{0, 1\\}^{\\{ t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'} \\}} \\\\\n", - " \\S^c_\\mathrm{mustEndBeforeActivates}&: \\forall t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'}, \\sum_{t'' \\in T} d_{t''} \\left(1 - \\beta^\\mathrm{mustEnd}_{t,t'}\\right) \\geq \\sigma^\\mathrm{task}_{t} + d_{t} - \\sigma^\\mathrm{task}_{t'} \\\\\n", - " \\S^c_\\mathrm{oneActiveTaskPerMachine}&: \\forall t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'}, \\beta^\\mathrm{mustEnd}_{t,t'} + \\alpha^\\mathrm{mustStart}_{t,t'} \\geq 1 \\\\\n", - " \\S^o_\\mathrm{minimizeHorizon}&: \\min \\eta \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "code", + "execution_count": 1, + "id": "9b75dc93-8b84-4dbe-b6fc-a81b45f6b8fb", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "0187a406-d58f-4f9a-badc-548315e7e23c", + "metadata": {}, + "source": [ + "## Formulation\n", + "\n", + "The first step is to formulate the problem problem using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6803054a-6db7-4eee-aa39-8b4fec995836", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nJobShopScheduling\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{tasks}&: T \\\\\n \\S^p_\\mathrm{duration}&: d \\in \\mathbb{N}^{T} \\\\\n \\S^p_\\mathrm{machine}&: m \\in \\mathbb{N}^{T} \\\\\n \\S^p_\\mathrm{dependency[child,parent]}&: d' \\in \\{0, 1\\}^{T \\times T} \\\\\n \\S^v_\\mathrm{taskStart}&: \\sigma^\\mathrm{task} \\in \\{1 \\ldots \\infty\\}^{T} \\\\\n \\S^v_\\mathrm{horizon}&: \\eta \\in \\mathbb{N} \\\\\n \\S^c_\\mathrm{allTasksEndWithinHorizon}&: \\forall t \\in T, \\sigma^\\mathrm{task}_{t} + d_{t} \\leq \\eta \\\\\n \\S^c_\\mathrm{childStartsAfterParentEnds}&: \\forall t, t' \\in T \\mid d'_{t,t'} \\neq 0, \\sigma^\\mathrm{task}_{t} \\geq \\sigma^\\mathrm{task}_{t'} + d_{t'} \\\\\n \\S^v_\\mathrm{mustStartAfter}&: \\alpha^\\mathrm{mustStart} \\in \\{0, 1\\}^{\\{ t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'} \\}} \\\\\n \\S^c_\\mathrm{mustStartAfterActivates}&: \\forall t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'}, \\sum_{t'' \\in T} d_{t''} \\left(1 - \\alpha^\\mathrm{mustStart}_{t,t'}\\right) \\geq \\sigma^\\mathrm{task}_{t'} + d_{t'} - \\sigma^\\mathrm{task}_{t} \\\\\n \\S^v_\\mathrm{mustEndBefore}&: \\beta^\\mathrm{mustEnd} \\in \\{0, 1\\}^{\\{ t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'} \\}} \\\\\n \\S^c_\\mathrm{mustEndBeforeActivates}&: \\forall t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'}, \\sum_{t'' \\in T} d_{t''} \\left(1 - \\beta^\\mathrm{mustEnd}_{t,t'}\\right) \\geq \\sigma^\\mathrm{task}_{t} + d_{t} - \\sigma^\\mathrm{task}_{t'} \\\\\n \\S^c_\\mathrm{oneActiveTaskPerMachine}&: \\forall t, t' \\in T \\mid t \\neq t' \\land m_{t} = m_{t'}, \\beta^\\mathrm{mustEnd}_{t,t'} + \\alpha^\\mathrm{mustStart}_{t,t'} \\geq 1 \\\\\n \\S^o_\\mathrm{minimizeHorizon}&: \\min \\eta \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{tasks}&: T \\\\\\\\\\n \\\\S^p_\\\\mathrm{duration}&: d \\\\in \\\\mathbb{N}^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{machine}&: m \\\\in \\\\mathbb{N}^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{dependency[child,parent]}&: d' \\\\in \\\\{0, 1\\\\}^{T \\\\times T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{taskStart}&: \\\\sigma^\\\\mathrm{task} \\\\in \\\\{1 \\\\ldots \\\\infty\\\\}^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{horizon}&: \\\\eta \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allTasksEndWithinHorizon}&: \\\\forall t \\\\in T, \\\\sigma^\\\\mathrm{task}_{t} + d_{t} \\\\leq \\\\eta \\\\\\\\\\n \\\\S^c_\\\\mathrm{childStartsAfterParentEnds}&: \\\\forall t, t' \\\\in T \\\\mid d'_{t,t'} \\\\neq 0, \\\\sigma^\\\\mathrm{task}_{t} \\\\geq \\\\sigma^\\\\mathrm{task}_{t'} + d_{t'} \\\\\\\\\\n \\\\S^v_\\\\mathrm{mustStartAfter}&: \\\\alpha^\\\\mathrm{mustStart} \\\\in \\\\{0, 1\\\\}^{\\\\{ t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'} \\\\}} \\\\\\\\\\n \\\\S^c_\\\\mathrm{mustStartAfterActivates}&: \\\\forall t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'}, \\\\sum_{t'' \\\\in T} d_{t''} \\\\left(1 - \\\\alpha^\\\\mathrm{mustStart}_{t,t'}\\\\right) \\\\geq \\\\sigma^\\\\mathrm{task}_{t'} + d_{t'} - \\\\sigma^\\\\mathrm{task}_{t} \\\\\\\\\\n \\\\S^v_\\\\mathrm{mustEndBefore}&: \\\\beta^\\\\mathrm{mustEnd} \\\\in \\\\{0, 1\\\\}^{\\\\{ t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'} \\\\}} \\\\\\\\\\n \\\\S^c_\\\\mathrm{mustEndBeforeActivates}&: \\\\forall t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'}, \\\\sum_{t'' \\\\in T} d_{t''} \\\\left(1 - \\\\beta^\\\\mathrm{mustEnd}_{t,t'}\\\\right) \\\\geq \\\\sigma^\\\\mathrm{task}_{t} + d_{t} - \\\\sigma^\\\\mathrm{task}_{t'} \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneActiveTaskPerMachine}&: \\\\forall t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'}, \\\\beta^\\\\mathrm{mustEnd}_{t,t'} + \\\\alpha^\\\\mathrm{mustStart}_{t,t'} \\\\geq 1 \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeHorizon}&: \\\\min \\\\eta \\\\\\\\\\n\\\\end{align*}\\n$$\", title='JobShopScheduling')], description='MIP formulation for scheduling dependent tasks of various durations', annotation=None)" + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{tasks}&: T \\\\\\\\\\n \\\\S^p_\\\\mathrm{duration}&: d \\\\in \\\\mathbb{N}^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{machine}&: m \\\\in \\\\mathbb{N}^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{dependency[child,parent]}&: d' \\\\in \\\\{0, 1\\\\}^{T \\\\times T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{taskStart}&: \\\\sigma^\\\\mathrm{task} \\\\in \\\\{1 \\\\ldots \\\\infty\\\\}^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{horizon}&: \\\\eta \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allTasksEndWithinHorizon}&: \\\\forall t \\\\in T, \\\\sigma^\\\\mathrm{task}_{t} + d_{t} \\\\leq \\\\eta \\\\\\\\\\n \\\\S^c_\\\\mathrm{childStartsAfterParentEnds}&: \\\\forall t, t' \\\\in T \\\\mid d'_{t,t'} \\\\neq 0, \\\\sigma^\\\\mathrm{task}_{t} \\\\geq \\\\sigma^\\\\mathrm{task}_{t'} + d_{t'} \\\\\\\\\\n \\\\S^v_\\\\mathrm{mustStartAfter}&: \\\\alpha^\\\\mathrm{mustStart} \\\\in \\\\{0, 1\\\\}^{\\\\{ t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'} \\\\}} \\\\\\\\\\n \\\\S^c_\\\\mathrm{mustStartAfterActivates}&: \\\\forall t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'}, \\\\sum_{t'' \\\\in T} d_{t''} \\\\left(1 - \\\\alpha^\\\\mathrm{mustStart}_{t,t'}\\\\right) \\\\geq \\\\sigma^\\\\mathrm{task}_{t'} + d_{t'} - \\\\sigma^\\\\mathrm{task}_{t} \\\\\\\\\\n \\\\S^v_\\\\mathrm{mustEndBefore}&: \\\\beta^\\\\mathrm{mustEnd} \\\\in \\\\{0, 1\\\\}^{\\\\{ t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'} \\\\}} \\\\\\\\\\n \\\\S^c_\\\\mathrm{mustEndBeforeActivates}&: \\\\forall t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'}, \\\\sum_{t'' \\\\in T} d_{t''} \\\\left(1 - \\\\beta^\\\\mathrm{mustEnd}_{t,t'}\\\\right) \\\\geq \\\\sigma^\\\\mathrm{task}_{t} + d_{t} - \\\\sigma^\\\\mathrm{task}_{t'} \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneActiveTaskPerMachine}&: \\\\forall t, t' \\\\in T \\\\mid t \\\\neq t' \\\\land m_{t} = m_{t'}, \\\\beta^\\\\mathrm{mustEnd}_{t,t'} + \\\\alpha^\\\\mathrm{mustStart}_{t,t'} \\\\geq 1 \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeHorizon}&: \\\\min \\\\eta \\\\\\\\\\n\\\\end{align*}\\n$$\", title='JobShopScheduling')], description='MIP formulation for scheduling dependent tasks of various durations', annotation=None)" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class JobShopScheduling(om.Model):\n", + " \"\"\"MIP formulation for scheduling dependent tasks of various durations\"\"\"\n", + " \n", + " tasks = om.Dimension()\n", + " duration = om.Parameter.natural(tasks)\n", + " machine = om.Parameter.natural(tasks)\n", + " dependency = om.Parameter.indicator(tasks, tasks, qualifiers=['child', 'parent'])\n", + " task_start = om.Variable.discrete(tasks, lower_bound=1)\n", + " horizon = om.Variable.natural()\n", + " \n", + " def task_end(self, t):\n", + " return self.task_start(t) + self.duration(t)\n", + "\n", + " @om.constraint\n", + " def all_tasks_end_within_horizon(self):\n", + " for t in self.tasks:\n", + " yield self.task_end(t) <= self.horizon()\n", + "\n", + " @om.constraint\n", + " def child_starts_after_parent_ends(self):\n", + " for c, p in self.tasks * self.tasks:\n", + " if self.dependency(c, p):\n", + " yield self.task_start(c) >= self.task_end(p)\n", + "\n", + " @property\n", + " def competing_tasks(self):\n", + " for t1, t2 in self.tasks * self.tasks:\n", + " if t1 != t2 and self.machine(t1) == self.machine(t2):\n", + " yield t1, t2\n", + "\n", + " @om.fragments.activation_variable(lambda init, self: init(self.competing_tasks, negate=True, upper_bound=self.duration.total()))\n", + " def must_start_after(self, t1, t2):\n", + " return self.task_end(t2) - self.task_start(t1)\n", + "\n", + " @om.fragments.activation_variable(lambda init, self: init(self.competing_tasks, negate=True, upper_bound=self.duration.total()))\n", + " def must_end_before(self, t1, t2):\n", + " return self.task_end(t1) - self.task_start(t2)\n", + "\n", + " @om.constraint\n", + " def one_active_task_per_machine(self):\n", + " for t1, t2 in self.competing_tasks:\n", + " yield self.must_end_before(t1, t2) + self.must_start_after(t1, t2) >= 1\n", + "\n", + " @om.objective\n", + " def minimize_horizon(self):\n", + " return self.horizon()\n", + "\n", + "model = JobShopScheduling()\n", + "model.specification()" ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class JobShopScheduling(om.Model):\n", - " \"\"\"MIP formulation for scheduling dependent tasks of various durations\"\"\"\n", - " \n", - " tasks = om.Dimension()\n", - " duration = om.Parameter.natural(tasks)\n", - " machine = om.Parameter.natural(tasks)\n", - " dependency = om.Parameter.indicator(tasks, tasks, qualifiers=['child', 'parent'])\n", - " task_start = om.Variable.discrete(tasks, lower_bound=1)\n", - " horizon = om.Variable.natural()\n", - " \n", - " def task_end(self, t):\n", - " return self.task_start(t) + self.duration(t)\n", - "\n", - " @om.constraint\n", - " def all_tasks_end_within_horizon(self):\n", - " for t in self.tasks:\n", - " yield self.task_end(t) <= self.horizon()\n", - "\n", - " @om.constraint\n", - " def child_starts_after_parent_ends(self):\n", - " for c, p in self.tasks * self.tasks:\n", - " if self.dependency(c, p):\n", - " yield self.task_start(c) >= self.task_end(p)\n", - "\n", - " @property\n", - " def competing_tasks(self):\n", - " for t1, t2 in self.tasks * self.tasks:\n", - " if t1 != t2 and self.machine(t1) == self.machine(t2):\n", - " yield t1, t2\n", - "\n", - " @om.fragments.activation_variable(lambda init, self: init(self.competing_tasks, negate=True, upper_bound=self.duration.total()))\n", - " def must_start_after(self, t1, t2):\n", - " return self.task_end(t2) - self.task_start(t1)\n", - "\n", - " @om.fragments.activation_variable(lambda init, self: init(self.competing_tasks, negate=True, upper_bound=self.duration.total()))\n", - " def must_end_before(self, t1, t2):\n", - " return self.task_end(t1) - self.task_start(t2)\n", - "\n", - " @om.constraint\n", - " def one_active_task_per_machine(self):\n", - " for t1, t2 in self.competing_tasks:\n", - " yield self.must_end_before(t1, t2) + self.must_start_after(t1, t2) >= 1\n", - "\n", - " @om.objective\n", - " def minimize_horizon(self):\n", - " return self.horizon()\n", - "\n", - "model = JobShopScheduling()\n", - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "16e84c56-9b24-4248-9d7d-e9f1b463e62a", - "metadata": {}, - "source": [ - "## Application\n", - "\n", - "Let's try our formulation out on a simple example." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "652785de-f17f-4236-949e-cf2411f01080", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "async def find_optimal_start_times(tasks):\n", - " \"\"\"Returns a dataframe of optimal task start times\"\"\"\n", - " problem = opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={\n", - " 'machine': {str(k): k[1] for k in tasks.keys()},\n", - " 'duration': {str(k): v['dur'] for k, v in tasks.items()},\n", - " 'dependency': [(str(k), str(v['prec'])) for k, v in tasks.items() if v['prec']]\n", - " },\n", - " )\n", - " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - " solution = await client.solve(problem)\n", - " return solution.outputs.variable('taskStart')" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "b8442ebb-e4ec-4285-84e0-04651ece25e8", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "16e84c56-9b24-4248-9d7d-e9f1b463e62a", + "metadata": {}, + "source": [ + "## Application\n", + "\n", + "Let's try our formulation out on a simple example." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
value
tasks
('Paper_1', 1)43
('Paper_1', 2)88
('Paper_2', 1)11
('Paper_2', 2)31
('Paper_2', 3)1
('Paper_3', 1)31
('Paper_3', 2)1
('Paper_3', 3)43
\n", - "
" + "cell_type": "code", + "execution_count": 3, + "id": "652785de-f17f-4236-949e-cf2411f01080", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "async def find_optimal_start_times(tasks):\n", + " \"\"\"Returns a dataframe of optimal task start times\"\"\"\n", + " problem = opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={\n", + " 'machine': {str(k): k[1] for k in tasks.keys()},\n", + " 'duration': {str(k): v['dur'] for k, v in tasks.items()},\n", + " 'dependency': [(str(k), str(v['prec'])) for k, v in tasks.items() if v['prec']]\n", + " },\n", + " )\n", + " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + " solution = await client.solve(problem)\n", + " return solution.outputs.variable('taskStart')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b8442ebb-e4ec-4285-84e0-04651ece25e8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
value
tasks
('Paper_1', 1)43
('Paper_1', 2)88
('Paper_2', 1)11
('Paper_2', 2)31
('Paper_2', 3)1
('Paper_3', 1)31
('Paper_3', 2)1
('Paper_3', 3)43
\n
", + "text/plain": " value\ntasks \n('Paper_1', 1) 43\n('Paper_1', 2) 88\n('Paper_2', 1) 11\n('Paper_2', 2) 31\n('Paper_2', 3) 1\n('Paper_3', 1) 31\n('Paper_3', 2) 1\n('Paper_3', 3) 43" + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " value\n", - "tasks \n", - "('Paper_1', 1) 43\n", - "('Paper_1', 2) 88\n", - "('Paper_2', 1) 11\n", - "('Paper_2', 2) 31\n", - "('Paper_2', 3) 1\n", - "('Paper_3', 1) 31\n", - "('Paper_3', 2) 1\n", - "('Paper_3', 3) 43" + "source": [ + "await find_optimal_start_times({\n", + " ('Paper_1',1) : {'dur': 45, 'prec': None},\n", + " ('Paper_1',2) : {'dur': 10, 'prec': ('Paper_1',1)},\n", + " ('Paper_2',1) : {'dur': 20, 'prec': ('Paper_2',3)},\n", + " ('Paper_2',3) : {'dur': 10, 'prec': None},\n", + " ('Paper_2',2) : {'dur': 34, 'prec': ('Paper_2',1)},\n", + " ('Paper_3',1) : {'dur': 12, 'prec': ('Paper_3',2)},\n", + " ('Paper_3',3) : {'dur': 17, 'prec': ('Paper_3',1)},\n", + " ('Paper_3',2) : {'dur': 28, 'prec': None}, \n", + "})" ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "dff0bd86-30a0-4bad-acc5-b8b6f31bbd08", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await find_optimal_start_times({\n", - " ('Paper_1',1) : {'dur': 45, 'prec': None},\n", - " ('Paper_1',2) : {'dur': 10, 'prec': ('Paper_1',1)},\n", - " ('Paper_2',1) : {'dur': 20, 'prec': ('Paper_2',3)},\n", - " ('Paper_2',3) : {'dur': 10, 'prec': None},\n", - " ('Paper_2',2) : {'dur': 34, 'prec': ('Paper_2',1)},\n", - " ('Paper_3',1) : {'dur': 12, 'prec': ('Paper_3',2)},\n", - " ('Paper_3',3) : {'dur': 17, 'prec': ('Paper_3',1)},\n", - " ('Paper_3',2) : {'dur': 28, 'prec': None}, \n", - "})" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "dff0bd86-30a0-4bad-acc5-b8b6f31bbd08", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/lot-sizing.ipynb b/resources/examples/lot-sizing.ipynb index 0659ace..39fc49e 100644 --- a/resources/examples/lot-sizing.ipynb +++ b/resources/examples/lot-sizing.ipynb @@ -1,447 +1,233 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "735d30f4", - "metadata": {}, - "source": [ - "# Lot sizing\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser.\n", - "
\n", - "\n", - "This notebook implements a dynamic lot sizing model, as described in [this blog post](\n", - "https://towardsdatascience.com/the-dynamic-lot-size-model-a-mixed-integer-programming-approach-4a9440ba124e)." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "27a1bd37", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "55799e99", - "metadata": {}, - "source": [ - "## Model\n", - "\n", - "We start by defining our MIP model using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "d1b57ab5", - "metadata": {}, - "outputs": [ + "cells": [ { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "LotSizing\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^p_\\mathrm{horizon}&: h \\in \\mathbb{N} \\\\\n", - " \\S^a&: T \\doteq \\{ 1 \\ldots h \\} \\\\\n", - " \\S^p_\\mathrm{holdingCost}&: c^\\mathrm{holding} \\in \\mathbb{R}_+^{T} \\\\\n", - " \\S^p_\\mathrm{setupCost}&: c^\\mathrm{setup} \\in \\mathbb{R}_+^{T} \\\\\n", - " \\S^p_\\mathrm{demand}&: d \\in \\mathbb{R}_+^{T} \\\\\n", - " \\S^v_\\mathrm{production}&: \\pi \\in [0, \\sum_{t \\in T} d_{t}]^{T} \\\\\n", - " \\S^v_\\mathrm{isProducing}&: \\pi^\\mathrm{is} \\in \\{0, 1\\}^{T} \\\\\n", - " \\S^c_\\mathrm{isProducingActivates}&: \\forall t \\in T, \\sum_{t' \\in T} d_{t'} \\pi^\\mathrm{is}_{t} \\geq \\pi_{t} \\\\\n", - " \\S^v_\\mathrm{inventory}&: \\iota \\in \\mathbb{R}_+^{T} \\\\\n", - " \\S^o_\\mathrm{minimizeCost}&: \\min \\sum_{t \\in T} \\left(c^\\mathrm{holding}_{t} \\iota_{t} + c^\\mathrm{setup}_{t} \\pi^\\mathrm{is}_{t}\\right) \\\\\n", - " \\S^c_\\mathrm{inventoryPropagation}&: \\forall t \\in T, \\iota_{t} = \\pi_{t} - d_{t} + \\begin{cases} \\iota_{t - 1} \\mid t > 1, \\\\ 0 \\end{cases} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" - ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{horizon}&: h \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^a&: T \\\\doteq \\\\{ 1 \\\\ldots h \\\\} \\\\\\\\\\n \\\\S^p_\\\\mathrm{holdingCost}&: c^\\\\mathrm{holding} \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{setupCost}&: c^\\\\mathrm{setup} \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{demand}&: d \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{production}&: \\\\pi \\\\in [0, \\\\sum_{t \\\\in T} d_{t}]^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{isProducing}&: \\\\pi^\\\\mathrm{is} \\\\in \\\\{0, 1\\\\}^{T} \\\\\\\\\\n \\\\S^c_\\\\mathrm{isProducingActivates}&: \\\\forall t \\\\in T, \\\\sum_{t' \\\\in T} d_{t'} \\\\pi^\\\\mathrm{is}_{t} \\\\geq \\\\pi_{t} \\\\\\\\\\n \\\\S^v_\\\\mathrm{inventory}&: \\\\iota \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeCost}&: \\\\min \\\\sum_{t \\\\in T} \\\\left(c^\\\\mathrm{holding}_{t} \\\\iota_{t} + c^\\\\mathrm{setup}_{t} \\\\pi^\\\\mathrm{is}_{t}\\\\right) \\\\\\\\\\n \\\\S^c_\\\\mathrm{inventoryPropagation}&: \\\\forall t \\\\in T, \\\\iota_{t} = \\\\pi_{t} - d_{t} + \\\\begin{cases} \\\\iota_{t - 1} \\\\mid t > 1, \\\\\\\\ 0 \\\\end{cases} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='LotSizing')], description='Lot sizing MIP model', annotation=None)" + "cell_type": "markdown", + "id": "735d30f4", + "metadata": {}, + "source": [ + "# Lot sizing\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser.\n", + "
\n", + "\n", + "This notebook implements a dynamic lot sizing model, as described in [this blog post](\n", + "https://towardsdatascience.com/the-dynamic-lot-size-model-a-mixed-integer-programming-approach-4a9440ba124e)." ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class LotSizing(om.Model):\n", - " \"\"\"Lot sizing MIP model\"\"\"\n", - " \n", - " # Inputs\n", - " horizon = om.Parameter.natural() # Number of days in schedule\n", - " days = om.interval(1, horizon(), name=\"T\")\n", - " holding_cost = om.Parameter.non_negative(days) # Marginal cost of storing inventory\n", - " setup_cost = om.Parameter.non_negative(days) # Fixed cost of producing in a given day\n", - " demand = om.Parameter.non_negative(days) # Demand per day\n", - " \n", - " # Outputs\n", - " production = om.Variable.non_negative(days, upper_bound=demand.total()) # Production per day\n", - " is_producing = om.fragments.ActivationVariable(production) # 1 if production > 0\n", - " inventory = om.Variable.non_negative(days) # Stored inventory per day\n", - " \n", - " @om.objective\n", - " def minimize_cost(self):\n", - " return om.total(\n", - " self.holding_cost(d) * self.inventory(d) + self.setup_cost(d) * self.is_producing(d)\n", - " for d in self.days\n", - " )\n", - " \n", - " @om.constraint\n", - " def inventory_propagation(self):\n", - " for d in self.days:\n", - " base = om.switch((d > 1, self.inventory(d-1)), 0) # Previous day inventory (0 initially)\n", - " delta = self.production(d) - self.demand(d)\n", - " yield self.inventory(d) == delta + base\n", - "\n", - "\n", - "model = LotSizing()\n", - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "c86040e2", - "metadata": {}, - "source": [ - "## Application\n", - "\n", - "We use the above model to write a function which will return an optimal schedule." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "7372d503", - "metadata": {}, - "outputs": [], - "source": [ - "import logging\n", - "import opvious\n", - "\n", - "logging.basicConfig(level=logging.INFO) # Display live progress notifications\n", - "\n", - "async def optimal_production(inputs):\n", - " \"\"\"Computes an optimal production schedule for the given inputs\"\"\"\n", - " problem = opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={\n", - " 'demand': inputs_df['demand'],\n", - " 'holdingCost': inputs_df['inventory_cost'],\n", - " 'setupCost': inputs_df['setup_cost'],\n", - " 'horizon': len(inputs_df),\n", - " },\n", - " )\n", - " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - " solution = await client.solve(problem)\n", - " return solution.outputs.variable('production').sort_index()" - ] - }, - { - "cell_type": "markdown", - "id": "0c0f4dfc", - "metadata": {}, - "source": [ - "## Example\n", - "\n", - "Let's now introduce some sample data, identical to the [original example's](https://raw.githubusercontent.com/bruscalia/optimization-demo-files/main/mip/dynamic_lot_size/data/input_wagner.csv). " - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "50937038", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
setup_costinventory_costdemand
id
1851.069
21021.029
31021.036
41011.061
5981.061
61141.026
71051.034
8861.067
91191.045
101101.067
11981.079
121141.056
\n", - "
" + "cell_type": "code", + "execution_count": 1, + "id": "27a1bd37", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "55799e99", + "metadata": {}, + "source": [ + "## Model\n", + "\n", + "We start by defining our MIP model using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "d1b57ab5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nLotSizing\n
\n$$\n\\begin{align*}\n \\S^p_\\mathrm{horizon}&: h \\in \\mathbb{N} \\\\\n \\S^a&: T \\doteq \\{ 1 \\ldots h \\} \\\\\n \\S^p_\\mathrm{holdingCost}&: c^\\mathrm{holding} \\in \\mathbb{R}_+^{T} \\\\\n \\S^p_\\mathrm{setupCost}&: c^\\mathrm{setup} \\in \\mathbb{R}_+^{T} \\\\\n \\S^p_\\mathrm{demand}&: d \\in \\mathbb{R}_+^{T} \\\\\n \\S^v_\\mathrm{production}&: \\pi \\in [0, \\sum_{t \\in T} d_{t}]^{T} \\\\\n \\S^v_\\mathrm{isProducing}&: \\pi^\\mathrm{is} \\in \\{0, 1\\}^{T} \\\\\n \\S^c_\\mathrm{isProducingActivates}&: \\forall t \\in T, \\sum_{t' \\in T} d_{t'} \\pi^\\mathrm{is}_{t} \\geq \\pi_{t} \\\\\n \\S^v_\\mathrm{inventory}&: \\iota \\in \\mathbb{R}_+^{T} \\\\\n \\S^o_\\mathrm{minimizeCost}&: \\min \\sum_{t \\in T} \\left(c^\\mathrm{holding}_{t} \\iota_{t} + c^\\mathrm{setup}_{t} \\pi^\\mathrm{is}_{t}\\right) \\\\\n \\S^c_\\mathrm{inventoryPropagation}&: \\forall t \\in T, \\iota_{t} = \\pi_{t} - d_{t} + \\begin{cases} \\iota_{t - 1} \\mid t > 1, \\\\ 0 \\end{cases} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{horizon}&: h \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^a&: T \\\\doteq \\\\{ 1 \\\\ldots h \\\\} \\\\\\\\\\n \\\\S^p_\\\\mathrm{holdingCost}&: c^\\\\mathrm{holding} \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{setupCost}&: c^\\\\mathrm{setup} \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{demand}&: d \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{production}&: \\\\pi \\\\in [0, \\\\sum_{t \\\\in T} d_{t}]^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{isProducing}&: \\\\pi^\\\\mathrm{is} \\\\in \\\\{0, 1\\\\}^{T} \\\\\\\\\\n \\\\S^c_\\\\mathrm{isProducingActivates}&: \\\\forall t \\\\in T, \\\\sum_{t' \\\\in T} d_{t'} \\\\pi^\\\\mathrm{is}_{t} \\\\geq \\\\pi_{t} \\\\\\\\\\n \\\\S^v_\\\\mathrm{inventory}&: \\\\iota \\\\in \\\\mathbb{R}_+^{T} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeCost}&: \\\\min \\\\sum_{t \\\\in T} \\\\left(c^\\\\mathrm{holding}_{t} \\\\iota_{t} + c^\\\\mathrm{setup}_{t} \\\\pi^\\\\mathrm{is}_{t}\\\\right) \\\\\\\\\\n \\\\S^c_\\\\mathrm{inventoryPropagation}&: \\\\forall t \\\\in T, \\\\iota_{t} = \\\\pi_{t} - d_{t} + \\\\begin{cases} \\\\iota_{t - 1} \\\\mid t > 1, \\\\\\\\ 0 \\\\end{cases} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='LotSizing')], description='Lot sizing MIP model', annotation=None)" + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " setup_cost inventory_cost demand\n", - "id \n", - "1 85 1.0 69\n", - "2 102 1.0 29\n", - "3 102 1.0 36\n", - "4 101 1.0 61\n", - "5 98 1.0 61\n", - "6 114 1.0 26\n", - "7 105 1.0 34\n", - "8 86 1.0 67\n", - "9 119 1.0 45\n", - "10 110 1.0 67\n", - "11 98 1.0 79\n", - "12 114 1.0 56" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class LotSizing(om.Model):\n", + " \"\"\"Lot sizing MIP model\"\"\"\n", + " \n", + " # Inputs\n", + " horizon = om.Parameter.natural() # Number of days in schedule\n", + " days = om.interval(1, horizon(), name=\"T\")\n", + " holding_cost = om.Parameter.non_negative(days) # Marginal cost of storing inventory\n", + " setup_cost = om.Parameter.non_negative(days) # Fixed cost of producing in a given day\n", + " demand = om.Parameter.non_negative(days) # Demand per day\n", + " \n", + " # Outputs\n", + " production = om.Variable.non_negative(days, upper_bound=demand.total()) # Production per day\n", + " is_producing = om.fragments.ActivationVariable(production) # 1 if production > 0\n", + " inventory = om.Variable.non_negative(days) # Stored inventory per day\n", + " \n", + " @om.objective\n", + " def minimize_cost(self):\n", + " return om.total(\n", + " self.holding_cost(d) * self.inventory(d) + self.setup_cost(d) * self.is_producing(d)\n", + " for d in self.days\n", + " )\n", + " \n", + " @om.constraint\n", + " def inventory_propagation(self):\n", + " for d in self.days:\n", + " base = om.switch((d > 1, self.inventory(d-1)), 0) # Previous day inventory (0 initially)\n", + " delta = self.production(d) - self.demand(d)\n", + " yield self.inventory(d) == delta + base\n", + "\n", + "\n", + "model = LotSizing()\n", + "model.specification()" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import io\n", - "import pandas as pd\n", - "\n", - "inputs_df = pd.read_csv(io.StringIO(\"\"\"\n", - "id,setup_cost,inventory_cost,demand\n", - "1,85,1.0,69\n", - "2,102,1.0,29\n", - "3,102,1.0,36\n", - "4,101,1.0,61\n", - "5,98,1.0,61\n", - "6,114,1.0,26\n", - "7,105,1.0,34\n", - "8,86,1.0,67\n", - "9,119,1.0,45\n", - "10,110,1.0,67\n", - "11,98,1.0,79\n", - "12,114,1.0,56\n", - "\"\"\")).set_index('id')\n", - "\n", - "inputs_df" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "12494df6", - "metadata": {}, - "outputs": [ + }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "INFO:opvious.client.handlers:Validated inputs. [parameters=37]\n", - "INFO:opvious.client.handlers:Solving problem... [columns=36, rows=24]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=93.11%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=11, gap=85.66%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=22, gap=37.65%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=33, gap=19.23%]\n", - "INFO:opvious.client.handlers:Solve in progress... [iterations=35, gap=0.0%]\n", - "INFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=864]\n" - ] + "cell_type": "markdown", + "id": "c86040e2", + "metadata": {}, + "source": [ + "## Application\n", + "\n", + "We use the above model to write a function which will return an optimal schedule." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7372d503", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import opvious\n", + "\n", + "logging.basicConfig(level=logging.INFO) # Display live progress notifications\n", + "\n", + "async def optimal_production(inputs):\n", + " \"\"\"Computes an optimal production schedule for the given inputs\"\"\"\n", + " problem = opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={\n", + " 'demand': inputs_df['demand'],\n", + " 'holdingCost': inputs_df['inventory_cost'],\n", + " 'setupCost': inputs_df['setup_cost'],\n", + " 'horizon': len(inputs_df),\n", + " },\n", + " )\n", + " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + " solution = await client.solve(problem)\n", + " return solution.outputs.variable('production').sort_index()" + ] }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
value
198
397
5121
8112
1067
11135
\n", - "
" + "cell_type": "markdown", + "id": "0c0f4dfc", + "metadata": {}, + "source": [ + "## Example\n", + "\n", + "Let's now introduce some sample data, identical to the [original example's](https://raw.githubusercontent.com/bruscalia/optimization-demo-files/main/mip/dynamic_lot_size/data/input_wagner.csv). " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "50937038", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
setup_costinventory_costdemand
id
1851.069
21021.029
31021.036
41011.061
5981.061
61141.026
71051.034
8861.067
91191.045
101101.067
11981.079
121141.056
\n
", + "text/plain": " setup_cost inventory_cost demand\nid \n1 85 1.0 69\n2 102 1.0 29\n3 102 1.0 36\n4 101 1.0 61\n5 98 1.0 61\n6 114 1.0 26\n7 105 1.0 34\n8 86 1.0 67\n9 119 1.0 45\n10 110 1.0 67\n11 98 1.0 79\n12 114 1.0 56" + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " value\n", - "1 98\n", - "3 97\n", - "5 121\n", - "8 112\n", - "10 67\n", - "11 135" + "source": [ + "import io\n", + "import pandas as pd\n", + "\n", + "inputs_df = pd.read_csv(io.StringIO(\"\"\"\n", + "id,setup_cost,inventory_cost,demand\n", + "1,85,1.0,69\n", + "2,102,1.0,29\n", + "3,102,1.0,36\n", + "4,101,1.0,61\n", + "5,98,1.0,61\n", + "6,114,1.0,26\n", + "7,105,1.0,34\n", + "8,86,1.0,67\n", + "9,119,1.0,45\n", + "10,110,1.0,67\n", + "11,98,1.0,79\n", + "12,114,1.0,56\n", + "\"\"\")).set_index('id')\n", + "\n", + "inputs_df" ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "12494df6", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": "INFO:opvious.client.handlers:Validated inputs. [parameters=37]\nINFO:opvious.client.handlers:Solving problem... [columns=36, rows=24]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=n/a]\nINFO:opvious.client.handlers:Solve in progress... [iterations=0, gap=93.11%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=11, gap=85.66%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=22, gap=37.65%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=33, gap=19.23%]\nINFO:opvious.client.handlers:Solve in progress... [iterations=35, gap=0.0%]\nINFO:opvious.client.handlers:Solve completed with status OPTIMAL. [objective=864]\n" + }, + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
value
198
397
5121
8112
1067
11135
\n
", + "text/plain": " value\n1 98\n3 97\n5 121\n8 112\n10 67\n11 135" + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await optimal_production(inputs_df)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "fa40c678", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await optimal_production(inputs_df)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "fa40c678", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/portfolio-optimization.ipynb b/resources/examples/portfolio-optimization.ipynb index 04ca8c3..0fa63e9 100644 --- a/resources/examples/portfolio-optimization.ipynb +++ b/resources/examples/portfolio-optimization.ipynb @@ -1,619 +1,238 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "dc8d0415", - "metadata": {}, - "source": [ - "# Portfolio selection" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "ccc87c44", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious yfinance" - ] - }, - { - "cell_type": "markdown", - "id": "41e5f6e1", - "metadata": {}, - "source": [ - "## Formulation\n", - "\n", - "LaTeX equivalent: https://github.com/opvious/examples/blob/main/sources/portfolio-selection.md" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "e58572dd", - "metadata": {}, - "outputs": [ + "cells": [ { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "PortfolioSelection\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{assets}&: A \\\\\n", - " \\S^d_\\mathrm{groups}&: G \\\\\n", - " \\S^p_\\mathrm{covariance}&: c \\in \\mathbb{R}^{A \\times A} \\\\\n", - " \\S^p_\\mathrm{expectedReturn}&: r^\\mathrm{expected} \\in \\mathbb{R}^{A} \\\\\n", - " \\S^p_\\mathrm{minimumReturn}&: r^\\mathrm{minimum} \\in \\mathbb{R} \\\\\n", - " \\S^p_\\mathrm{membership}&: m \\in \\{0, 1\\}^{A \\times G} \\\\\n", - " \\S^p_\\mathrm{minimumAllocation}&: a^\\mathrm{minimum} \\in [0, 1]^{G} \\\\\n", - " \\S^v_\\mathrm{allocation}&: \\alpha \\in [0, 1]^{A} \\\\\n", - " \\S^o_\\mathrm{minimizeRisk}&: \\min \\sum_{a, a' \\in A} c_{a,a'} \\alpha_{a} \\alpha_{a'} \\\\\n", - " \\S^c_\\mathrm{expectedReturnAboveMinimum}&: \\sum_{a \\in A} r^\\mathrm{expected}_{a} \\alpha_{a} \\geq r^\\mathrm{minimum} \\\\\n", - " \\S^c_\\mathrm{allocationIsTotal}&: \\sum_{a \\in A} \\alpha_{a} = 1 \\\\\n", - " \\S^c_\\mathrm{groupAllocationAboveMinimum}&: \\forall g \\in G, \\sum_{a \\in A} m_{a,g} \\alpha_{a} \\geq a^\\mathrm{minimum}_{g} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" - ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{assets}&: A \\\\\\\\\\n \\\\S^d_\\\\mathrm{groups}&: G \\\\\\\\\\n \\\\S^p_\\\\mathrm{covariance}&: c \\\\in \\\\mathbb{R}^{A \\\\times A} \\\\\\\\\\n \\\\S^p_\\\\mathrm{expectedReturn}&: r^\\\\mathrm{expected} \\\\in \\\\mathbb{R}^{A} \\\\\\\\\\n \\\\S^p_\\\\mathrm{minimumReturn}&: r^\\\\mathrm{minimum} \\\\in \\\\mathbb{R} \\\\\\\\\\n \\\\S^p_\\\\mathrm{membership}&: m \\\\in \\\\{0, 1\\\\}^{A \\\\times G} \\\\\\\\\\n \\\\S^p_\\\\mathrm{minimumAllocation}&: a^\\\\mathrm{minimum} \\\\in [0, 1]^{G} \\\\\\\\\\n \\\\S^v_\\\\mathrm{allocation}&: \\\\alpha \\\\in [0, 1]^{A} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeRisk}&: \\\\min \\\\sum_{a, a' \\\\in A} c_{a,a'} \\\\alpha_{a} \\\\alpha_{a'} \\\\\\\\\\n \\\\S^c_\\\\mathrm{expectedReturnAboveMinimum}&: \\\\sum_{a \\\\in A} r^\\\\mathrm{expected}_{a} \\\\alpha_{a} \\\\geq r^\\\\mathrm{minimum} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationIsTotal}&: \\\\sum_{a \\\\in A} \\\\alpha_{a} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{groupAllocationAboveMinimum}&: \\\\forall g \\\\in G, \\\\sum_{a \\\\in A} m_{a,g} \\\\alpha_{a} \\\\geq a^\\\\mathrm{minimum}_{g} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='PortfolioSelection')], description=None, annotation=None)" + "cell_type": "markdown", + "id": "dc8d0415", + "metadata": {}, + "source": [ + "# Portfolio selection" ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class PortfolioSelection(om.Model):\n", - " assets = om.Dimension()\n", - " groups = om.Dimension()\n", - " covariance = om.Parameter.continuous(assets, assets)\n", - " expected_return = om.Parameter.continuous(assets)\n", - " minimum_return = om.Parameter.continuous()\n", - " membership = om.Parameter.indicator(assets, groups)\n", - " minimum_allocation = om.Parameter.unit(groups)\n", - " allocation = om.Variable.unit(assets)\n", - " \n", - " @om.objective\n", - " def minimize_risk(self):\n", - " return om.total(\n", - " self.covariance(l, r) * self.allocation(l) * self.allocation(r)\n", - " for l, r in self.assets * self.assets\n", - " )\n", - " \n", - " @om.constraint\n", - " def expected_return_above_minimum(self):\n", - " yield om.total(self.expected_return(a) * self.allocation(a) for a in self.assets) >= self.minimum_return()\n", - " \n", - " @om.constraint\n", - " def allocation_is_total(self):\n", - " yield self.allocation.total() == 1\n", - " \n", - " @om.constraint\n", - " def group_allocation_above_minimum(self):\n", - " for g in self.groups:\n", - " group_allocation = om.total(self.membership(a, g) * self.allocation(a) for a in self.assets)\n", - " yield group_allocation >= self.minimum_allocation(g)\n", - " \n", - "\n", - "model = PortfolioSelection()\n", - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "c2e770a9", - "metadata": {}, - "source": [ - "## Download input data\n", - "\n", - "We gather tickers from Wikipedia and recent performance data via `yfinance`." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "ea401265", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
SymbolSecurityGICS SectorGICS Sub-IndustryHeadquarters LocationDate addedCIKFounded
0MMM3MIndustrialsIndustrial ConglomeratesSaint Paul, Minnesota1957-03-04667401902
1AOSA. O. SmithIndustrialsBuilding ProductsMilwaukee, Wisconsin2017-07-26911421916
2ABTAbbottHealth CareHealth Care EquipmentNorth Chicago, Illinois1957-03-0418001888
3ABBVAbbVieHealth CarePharmaceuticalsNorth Chicago, Illinois2012-12-3115511522013 (1888)
4ACNAccentureInformation TechnologyIT Consulting & Other ServicesDublin, Ireland2011-07-0614673731989
\n", - "
" + "cell_type": "code", + "execution_count": 1, + "id": "ccc87c44", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious yfinance" + ] + }, + { + "cell_type": "markdown", + "id": "41e5f6e1", + "metadata": {}, + "source": [ + "## Formulation\n", + "\n", + "LaTeX equivalent: https://github.com/opvious/examples/blob/main/sources/portfolio-selection.md" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e58572dd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nPortfolioSelection\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{assets}&: A \\\\\n \\S^d_\\mathrm{groups}&: G \\\\\n \\S^p_\\mathrm{covariance}&: c \\in \\mathbb{R}^{A \\times A} \\\\\n \\S^p_\\mathrm{expectedReturn}&: r^\\mathrm{expected} \\in \\mathbb{R}^{A} \\\\\n \\S^p_\\mathrm{minimumReturn}&: r^\\mathrm{minimum} \\in \\mathbb{R} \\\\\n \\S^p_\\mathrm{membership}&: m \\in \\{0, 1\\}^{A \\times G} \\\\\n \\S^p_\\mathrm{minimumAllocation}&: a^\\mathrm{minimum} \\in [0, 1]^{G} \\\\\n \\S^v_\\mathrm{allocation}&: \\alpha \\in [0, 1]^{A} \\\\\n \\S^o_\\mathrm{minimizeRisk}&: \\min \\sum_{a, a' \\in A} c_{a,a'} \\alpha_{a} \\alpha_{a'} \\\\\n \\S^c_\\mathrm{expectedReturnAboveMinimum}&: \\sum_{a \\in A} r^\\mathrm{expected}_{a} \\alpha_{a} \\geq r^\\mathrm{minimum} \\\\\n \\S^c_\\mathrm{allocationIsTotal}&: \\sum_{a \\in A} \\alpha_{a} = 1 \\\\\n \\S^c_\\mathrm{groupAllocationAboveMinimum}&: \\forall g \\in G, \\sum_{a \\in A} m_{a,g} \\alpha_{a} \\geq a^\\mathrm{minimum}_{g} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{assets}&: A \\\\\\\\\\n \\\\S^d_\\\\mathrm{groups}&: G \\\\\\\\\\n \\\\S^p_\\\\mathrm{covariance}&: c \\\\in \\\\mathbb{R}^{A \\\\times A} \\\\\\\\\\n \\\\S^p_\\\\mathrm{expectedReturn}&: r^\\\\mathrm{expected} \\\\in \\\\mathbb{R}^{A} \\\\\\\\\\n \\\\S^p_\\\\mathrm{minimumReturn}&: r^\\\\mathrm{minimum} \\\\in \\\\mathbb{R} \\\\\\\\\\n \\\\S^p_\\\\mathrm{membership}&: m \\\\in \\\\{0, 1\\\\}^{A \\\\times G} \\\\\\\\\\n \\\\S^p_\\\\mathrm{minimumAllocation}&: a^\\\\mathrm{minimum} \\\\in [0, 1]^{G} \\\\\\\\\\n \\\\S^v_\\\\mathrm{allocation}&: \\\\alpha \\\\in [0, 1]^{A} \\\\\\\\\\n \\\\S^o_\\\\mathrm{minimizeRisk}&: \\\\min \\\\sum_{a, a' \\\\in A} c_{a,a'} \\\\alpha_{a} \\\\alpha_{a'} \\\\\\\\\\n \\\\S^c_\\\\mathrm{expectedReturnAboveMinimum}&: \\\\sum_{a \\\\in A} r^\\\\mathrm{expected}_{a} \\\\alpha_{a} \\\\geq r^\\\\mathrm{minimum} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationIsTotal}&: \\\\sum_{a \\\\in A} \\\\alpha_{a} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{groupAllocationAboveMinimum}&: \\\\forall g \\\\in G, \\\\sum_{a \\\\in A} m_{a,g} \\\\alpha_{a} \\\\geq a^\\\\mathrm{minimum}_{g} \\\\\\\\\\n\\\\end{align*}\\n$$\", title='PortfolioSelection')], description=None, annotation=None)" + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " Symbol Security GICS Sector GICS Sub-Industry \\\n", - "0 MMM 3M Industrials Industrial Conglomerates \n", - "1 AOS A. O. Smith Industrials Building Products \n", - "2 ABT Abbott Health Care Health Care Equipment \n", - "3 ABBV AbbVie Health Care Pharmaceuticals \n", - "4 ACN Accenture Information Technology IT Consulting & Other Services \n", - "\n", - " Headquarters Location Date added CIK Founded \n", - "0 Saint Paul, Minnesota 1957-03-04 66740 1902 \n", - "1 Milwaukee, Wisconsin 2017-07-26 91142 1916 \n", - "2 North Chicago, Illinois 1957-03-04 1800 1888 \n", - "3 North Chicago, Illinois 2012-12-31 1551152 2013 (1888) \n", - "4 Dublin, Ireland 2011-07-06 1467373 1989 " + "source": [ + "import opvious.modeling as om\n", + "\n", + "class PortfolioSelection(om.Model):\n", + " assets = om.Dimension()\n", + " groups = om.Dimension()\n", + " covariance = om.Parameter.continuous(assets, assets)\n", + " expected_return = om.Parameter.continuous(assets)\n", + " minimum_return = om.Parameter.continuous()\n", + " membership = om.Parameter.indicator(assets, groups)\n", + " minimum_allocation = om.Parameter.unit(groups)\n", + " allocation = om.Variable.unit(assets)\n", + " \n", + " @om.objective\n", + " def minimize_risk(self):\n", + " return om.total(\n", + " self.covariance(l, r) * self.allocation(l) * self.allocation(r)\n", + " for l, r in self.assets * self.assets\n", + " )\n", + " \n", + " @om.constraint\n", + " def expected_return_above_minimum(self):\n", + " yield om.total(self.expected_return(a) * self.allocation(a) for a in self.assets) >= self.minimum_return()\n", + " \n", + " @om.constraint\n", + " def allocation_is_total(self):\n", + " yield self.allocation.total() == 1\n", + " \n", + " @om.constraint\n", + " def group_allocation_above_minimum(self):\n", + " for g in self.groups:\n", + " group_allocation = om.total(self.membership(a, g) * self.allocation(a) for a in self.assets)\n", + " yield group_allocation >= self.minimum_allocation(g)\n", + " \n", + "\n", + "model = PortfolioSelection()\n", + "model.specification()" ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import pandas as pd\n", - "import yfinance as yf\n", - "\n", - "tickers_df = pd.read_html('https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')[0]\n", - "tickers_df.head()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "ffe11545", - "metadata": {}, - "outputs": [ + }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "[*********************100%%**********************] 10 of 10 completed\n" - ] + "cell_type": "markdown", + "id": "c2e770a9", + "metadata": {}, + "source": [ + "## Download input data\n", + "\n", + "We gather tickers from Wikipedia and recent performance data via `yfinance`." + ] }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ABBVABTACNADBEADMADPAESAOSATVIMMM
Date
2022-02-010.090681-0.050326-0.103910-0.1246860.046000-0.008391-0.035955-0.0992660.031515-0.104626
2022-03-010.097043-0.0187370.067116-0.0257870.1566530.1129920.211964-0.068387-0.0170550.011229
2022-04-01-0.093948-0.041061-0.109332-0.130964-0.007756-0.036356-0.206374-0.085459-0.056298-0.031300
2022-05-010.0118630.039015-0.0033670.0518500.0140690.0218170.0874770.0335250.0362820.035155
2022-06-010.039289-0.075004-0.069724-0.121062-0.141624-0.057863-0.046733-0.090486-0.000257-0.124404
\n", - "
" + "cell_type": "code", + "execution_count": 3, + "id": "ea401265", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
SymbolSecurityGICS SectorGICS Sub-IndustryHeadquarters LocationDate addedCIKFounded
0MMM3MIndustrialsIndustrial ConglomeratesSaint Paul, Minnesota1957-03-04667401902
1AOSA. O. SmithIndustrialsBuilding ProductsMilwaukee, Wisconsin2017-07-26911421916
2ABTAbbottHealth CareHealth Care EquipmentNorth Chicago, Illinois1957-03-0418001888
3ABBVAbbVieHealth CarePharmaceuticalsNorth Chicago, Illinois2012-12-3115511522013 (1888)
4ACNAccentureInformation TechnologyIT Consulting & Other ServicesDublin, Ireland2011-07-0614673731989
\n
", + "text/plain": " Symbol Security GICS Sector GICS Sub-Industry \\\n0 MMM 3M Industrials Industrial Conglomerates \n1 AOS A. O. Smith Industrials Building Products \n2 ABT Abbott Health Care Health Care Equipment \n3 ABBV AbbVie Health Care Pharmaceuticals \n4 ACN Accenture Information Technology IT Consulting & Other Services \n\n Headquarters Location Date added CIK Founded \n0 Saint Paul, Minnesota 1957-03-04 66740 1902 \n1 Milwaukee, Wisconsin 2017-07-26 91142 1916 \n2 North Chicago, Illinois 1957-03-04 1800 1888 \n3 North Chicago, Illinois 2012-12-31 1551152 2013 (1888) \n4 Dublin, Ireland 2011-07-06 1467373 1989 " + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " ABBV ABT ACN ADBE ADM ADP \\\n", - "Date \n", - "2022-02-01 0.090681 -0.050326 -0.103910 -0.124686 0.046000 -0.008391 \n", - "2022-03-01 0.097043 -0.018737 0.067116 -0.025787 0.156653 0.112992 \n", - "2022-04-01 -0.093948 -0.041061 -0.109332 -0.130964 -0.007756 -0.036356 \n", - "2022-05-01 0.011863 0.039015 -0.003367 0.051850 0.014069 0.021817 \n", - "2022-06-01 0.039289 -0.075004 -0.069724 -0.121062 -0.141624 -0.057863 \n", - "\n", - " AES AOS ATVI MMM \n", - "Date \n", - "2022-02-01 -0.035955 -0.099266 0.031515 -0.104626 \n", - "2022-03-01 0.211964 -0.068387 -0.017055 0.011229 \n", - "2022-04-01 -0.206374 -0.085459 -0.056298 -0.031300 \n", - "2022-05-01 0.087477 0.033525 0.036282 0.035155 \n", - "2022-06-01 -0.046733 -0.090486 -0.000257 -0.124404 " + "source": [ + "import pandas as pd\n", + "import yfinance as yf\n", + "\n", + "tickers_df = pd.read_html('https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')[0]\n", + "tickers_df.head()" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "values_df = yf.download(tickers=list(tickers_df['Symbol'])[:10], start='2022-1-1', interval='1mo')['Adj Close']\n", - "returns_df = values_df.head(100).dropna(axis=1).pct_change().dropna(axis=0, how='all')\n", - "returns_df.head()" - ] - }, - { - "cell_type": "markdown", - "id": "1c6d20e8", - "metadata": {}, - "source": [ - "## Find the optimal allocation\n", - "\n", - "Asset groups are left as exercise to the reader." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "071df5b7", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "async def optimal_allocation(returns):\n", - " \"\"\"Returns an optimal allocation of assets given the input returns\"\"\"\n", - " problem = opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={\n", - " 'covariance': returns.cov().stack(),\n", - " 'expectedReturn': returns.mean(),\n", - " 'minimumReturn': 0.005,\n", - " 'membership': {},\n", - " 'minimumAllocation': {},\n", - " },\n", - " )\n", - " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - " solution = await client.solve(problem)\n", - " allocation = solution.outputs.variable('allocation')\n", - " return allocation.reset_index(names=['ticker']).join(\n", - " returns_df.agg(['mean', 'var']).T,\n", - " on='ticker',\n", - " validate='one_to_one'\n", - " ).sort_values(by=['value'], ascending=False)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "c9d8ddd7", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
tickervaluedual_valuemeanvar
8ATVI0.3751300.0000000.0098820.002479
1ABT0.2579020.000000-0.0075830.002929
0ABBV0.1625530.0000000.0113960.004645
5ADP0.0939890.0000000.0132660.004956
2ACN0.0754390.000000-0.0016960.005608
4ADM0.0301480.0000000.0092690.008055
7AOS0.0048400.000000-0.0004470.009701
3ADBE0.0000000.0012030.0090860.015262
6AES0.0000000.000552-0.0038230.012922
9MMM0.0000000.000363-0.0165470.006696
\n", - "
" + "cell_type": "code", + "execution_count": 4, + "id": "ffe11545", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": "[*********************100%%**********************] 10 of 10 completed\n" + }, + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
ABBVABTACNADBEADMADPAESAOSATVIMMM
Date
2022-02-010.090681-0.050326-0.103910-0.1246860.046000-0.008391-0.035955-0.0992660.031515-0.104626
2022-03-010.097043-0.0187370.067116-0.0257870.1566530.1129920.211964-0.068387-0.0170550.011229
2022-04-01-0.093948-0.041061-0.109332-0.130964-0.007756-0.036356-0.206374-0.085459-0.056298-0.031300
2022-05-010.0118630.039015-0.0033670.0518500.0140690.0218170.0874770.0335250.0362820.035155
2022-06-010.039289-0.075004-0.069724-0.121062-0.141624-0.057863-0.046733-0.090486-0.000257-0.124404
\n
", + "text/plain": " ABBV ABT ACN ADBE ADM ADP \\\nDate \n2022-02-01 0.090681 -0.050326 -0.103910 -0.124686 0.046000 -0.008391 \n2022-03-01 0.097043 -0.018737 0.067116 -0.025787 0.156653 0.112992 \n2022-04-01 -0.093948 -0.041061 -0.109332 -0.130964 -0.007756 -0.036356 \n2022-05-01 0.011863 0.039015 -0.003367 0.051850 0.014069 0.021817 \n2022-06-01 0.039289 -0.075004 -0.069724 -0.121062 -0.141624 -0.057863 \n\n AES AOS ATVI MMM \nDate \n2022-02-01 -0.035955 -0.099266 0.031515 -0.104626 \n2022-03-01 0.211964 -0.068387 -0.017055 0.011229 \n2022-04-01 -0.206374 -0.085459 -0.056298 -0.031300 \n2022-05-01 0.087477 0.033525 0.036282 0.035155 \n2022-06-01 -0.046733 -0.090486 -0.000257 -0.124404 " + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " ticker value dual_value mean var\n", - "8 ATVI 0.375130 0.000000 0.009882 0.002479\n", - "1 ABT 0.257902 0.000000 -0.007583 0.002929\n", - "0 ABBV 0.162553 0.000000 0.011396 0.004645\n", - "5 ADP 0.093989 0.000000 0.013266 0.004956\n", - "2 ACN 0.075439 0.000000 -0.001696 0.005608\n", - "4 ADM 0.030148 0.000000 0.009269 0.008055\n", - "7 AOS 0.004840 0.000000 -0.000447 0.009701\n", - "3 ADBE 0.000000 0.001203 0.009086 0.015262\n", - "6 AES 0.000000 0.000552 -0.003823 0.012922\n", - "9 MMM 0.000000 0.000363 -0.016547 0.006696" + "source": [ + "values_df = yf.download(tickers=list(tickers_df['Symbol'])[:10], start='2022-1-1', interval='1mo')['Adj Close']\n", + "returns_df = values_df.head(100).dropna(axis=1).pct_change().dropna(axis=0, how='all')\n", + "returns_df.head()" + ] + }, + { + "cell_type": "markdown", + "id": "1c6d20e8", + "metadata": {}, + "source": [ + "## Find the optimal allocation\n", + "\n", + "Asset groups are left as exercise to the reader." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "071df5b7", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "async def optimal_allocation(returns):\n", + " \"\"\"Returns an optimal allocation of assets given the input returns\"\"\"\n", + " problem = opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={\n", + " 'covariance': returns.cov().stack(),\n", + " 'expectedReturn': returns.mean(),\n", + " 'minimumReturn': 0.005,\n", + " 'membership': {},\n", + " 'minimumAllocation': {},\n", + " },\n", + " )\n", + " client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + " solution = await client.solve(problem)\n", + " allocation = solution.outputs.variable('allocation')\n", + " return allocation.reset_index(names=['ticker']).join(\n", + " returns_df.agg(['mean', 'var']).T,\n", + " on='ticker',\n", + " validate='one_to_one'\n", + " ).sort_values(by=['value'], ascending=False)" ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c9d8ddd7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
tickervaluedual_valuemeanvar
8ATVI0.3751300.0000000.0098820.002479
1ABT0.2579020.000000-0.0075830.002929
0ABBV0.1625530.0000000.0113960.004645
5ADP0.0939890.0000000.0132660.004956
2ACN0.0754390.000000-0.0016960.005608
4ADM0.0301480.0000000.0092690.008055
7AOS0.0048400.000000-0.0004470.009701
3ADBE0.0000000.0012030.0090860.015262
6AES0.0000000.000552-0.0038230.012922
9MMM0.0000000.000363-0.0165470.006696
\n
", + "text/plain": " ticker value dual_value mean var\n8 ATVI 0.375130 0.000000 0.009882 0.002479\n1 ABT 0.257902 0.000000 -0.007583 0.002929\n0 ABBV 0.162553 0.000000 0.011396 0.004645\n5 ADP 0.093989 0.000000 0.013266 0.004956\n2 ACN 0.075439 0.000000 -0.001696 0.005608\n4 ADM 0.030148 0.000000 0.009269 0.008055\n7 AOS 0.004840 0.000000 -0.000447 0.009701\n3 ADBE 0.000000 0.001203 0.009086 0.015262\n6 AES 0.000000 0.000552 -0.003823 0.012922\n9 MMM 0.000000 0.000363 -0.016547 0.006696" + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await optimal_allocation(returns_df)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "60fb053e", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await optimal_allocation(returns_df)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "60fb053e", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/product-allocation.ipynb b/resources/examples/product-allocation.ipynb index 9cc74e7..25b7919 100644 --- a/resources/examples/product-allocation.ipynb +++ b/resources/examples/product-allocation.ipynb @@ -1,603 +1,295 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "ed29c462", - "metadata": {}, - "source": [ - "# Product allocation\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser.\n", - "
\n", - "\n", - "This notebook implements an optimization model for allocating retail products to stores given demand, supply, and diversity constraints." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "e2865e56", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "99ae6e2d", - "metadata": {}, - "source": [ - "## Model\n", - "\n", - "We first formulate the model using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html). You can also find an equivalent LaTeX formulation [here](https://github.com/opvious/examples/blob/main/sources/product-allocation.md)." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "35943dc2", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "ed29c462", + "metadata": {}, + "source": [ + "# Product allocation\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser.\n", + "
\n", + "\n", + "This notebook implements an optimization model for allocating retail products to stores given demand, supply, and diversity constraints." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e2865e56", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "ProductAllocation\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{products}&: P \\\\\n", - " \\S^d_\\mathrm{sizes}&: S \\\\\n", - " \\S^d_\\mathrm{locations}&: L \\\\\n", - " \\S^d_\\mathrm{tiers}&: T \\\\\n", - " \\S^p_\\mathrm{supply}&: a^\\mathrm{max} \\in \\mathbb{N}^{P \\times S} \\\\\n", - " \\S^p_\\mathrm{minAllocation}&: a^\\mathrm{min} \\in \\mathbb{N}^{P} \\\\\n", - " \\S^p_\\mathrm{maxTotalAllocation}&: a^\\mathrm{maxTotal} \\in \\mathbb{N} \\\\\n", - " \\S^p_\\mathrm{breadth}&: b \\in \\mathbb{N}^{P} \\\\\n", - " \\S^p_\\mathrm{demand}&: d \\in \\mathbb{N}^{L \\times P \\times S \\times T} \\\\\n", - " \\S^p_\\mathrm{value}&: v \\in \\mathbb{R}^{T} \\\\\n", - " \\S^v_\\mathrm{allocation}&: \\alpha \\in \\{0 \\ldots a^\\mathrm{maxTotal}\\}^{L \\times P \\times S \\times T} \\\\\n", - " \\S^v_\\mathrm{productAllocated}&: \\alpha^\\mathrm{product} \\in \\{0, 1\\}^{L \\times P} \\\\\n", - " \\S^c_\\mathrm{productAllocatedActivates}&: \\forall l \\in L, p \\in P, s \\in S, t \\in T, a^\\mathrm{maxTotal} \\alpha^\\mathrm{product}_{l,p} \\geq \\alpha_{l,p,s,t} \\\\\n", - " \\S^v_\\mathrm{sizeAllocated}&: \\alpha^\\mathrm{size} \\in \\{0, 1\\}^{L \\times P \\times S} \\\\\n", - " \\S^c_\\mathrm{sizeAllocatedDeactivates}&: \\forall l \\in L, p \\in P, s \\in S, \\alpha^\\mathrm{size}_{l,p,s} \\leq \\sum_{t \\in T} \\alpha_{l,p,s,t} \\\\\n", - " \\S^c_\\mathrm{allocationFitsWithinDemand}&: \\forall l \\in L, p \\in P, s \\in S, t \\in T, \\alpha_{l,p,s,t} \\leq d_{l,p,s,t} \\\\\n", - " \\S^c_\\mathrm{allocationFitsWithinSupply}&: \\forall p \\in P, s \\in S, \\sum_{l \\in L, t \\in T} \\alpha_{l,p,s,t} \\leq a^\\mathrm{max}_{p,s} \\\\\n", - " \\S^c_\\mathrm{totalAllocationFitsWithinMax}&: \\sum_{l \\in L, p \\in P, s \\in S, t \\in T} \\alpha_{l,p,s,t} \\leq a^\\mathrm{maxTotal} \\\\\n", - " \\S^c_\\mathrm{allocationMeetsProductMin}&: \\forall p \\in P, l \\in L, \\sum_{s \\in S, t \\in T} \\alpha_{l,p,s,t} \\geq a^\\mathrm{min}_{p} \\alpha^\\mathrm{product}_{l,p} \\\\\n", - " \\S^c_\\mathrm{allocationMeetsProductBreadth}&: \\forall p \\in P, l \\in L, \\sum_{s \\in S} \\alpha^\\mathrm{size}_{l,p,s} \\geq b_{p} \\alpha^\\mathrm{product}_{l,p} \\\\\n", - " \\S^o_\\mathrm{maximizeValue}&: \\max \\sum_{l \\in L, p \\in P, s \\in S, t \\in T} v_{t} \\alpha_{l,p,s,t} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "markdown", + "id": "99ae6e2d", + "metadata": {}, + "source": [ + "## Model\n", + "\n", + "We first formulate the model using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html). You can also find an equivalent LaTeX formulation [here](https://github.com/opvious/examples/blob/main/sources/product-allocation.md)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "35943dc2", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nProductAllocation\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{products}&: P \\\\\n \\S^d_\\mathrm{sizes}&: S \\\\\n \\S^d_\\mathrm{locations}&: L \\\\\n \\S^d_\\mathrm{tiers}&: T \\\\\n \\S^p_\\mathrm{supply}&: a^\\mathrm{max} \\in \\mathbb{N}^{P \\times S} \\\\\n \\S^p_\\mathrm{minAllocation}&: a^\\mathrm{min} \\in \\mathbb{N}^{P} \\\\\n \\S^p_\\mathrm{maxTotalAllocation}&: a^\\mathrm{maxTotal} \\in \\mathbb{N} \\\\\n \\S^p_\\mathrm{breadth}&: b \\in \\mathbb{N}^{P} \\\\\n \\S^p_\\mathrm{demand}&: d \\in \\mathbb{N}^{L \\times P \\times S \\times T} \\\\\n \\S^p_\\mathrm{value}&: v \\in \\mathbb{R}^{T} \\\\\n \\S^v_\\mathrm{allocation}&: \\alpha \\in \\{0 \\ldots a^\\mathrm{maxTotal}\\}^{L \\times P \\times S \\times T} \\\\\n \\S^v_\\mathrm{productAllocated}&: \\alpha^\\mathrm{product} \\in \\{0, 1\\}^{L \\times P} \\\\\n \\S^c_\\mathrm{productAllocatedActivates}&: \\forall l \\in L, p \\in P, s \\in S, t \\in T, a^\\mathrm{maxTotal} \\alpha^\\mathrm{product}_{l,p} \\geq \\alpha_{l,p,s,t} \\\\\n \\S^v_\\mathrm{sizeAllocated}&: \\alpha^\\mathrm{size} \\in \\{0, 1\\}^{L \\times P \\times S} \\\\\n \\S^c_\\mathrm{sizeAllocatedDeactivates}&: \\forall l \\in L, p \\in P, s \\in S, \\alpha^\\mathrm{size}_{l,p,s} \\leq \\sum_{t \\in T} \\alpha_{l,p,s,t} \\\\\n \\S^c_\\mathrm{allocationFitsWithinDemand}&: \\forall l \\in L, p \\in P, s \\in S, t \\in T, \\alpha_{l,p,s,t} \\leq d_{l,p,s,t} \\\\\n \\S^c_\\mathrm{allocationFitsWithinSupply}&: \\forall p \\in P, s \\in S, \\sum_{l \\in L, t \\in T} \\alpha_{l,p,s,t} \\leq a^\\mathrm{max}_{p,s} \\\\\n \\S^c_\\mathrm{totalAllocationFitsWithinMax}&: \\sum_{l \\in L, p \\in P, s \\in S, t \\in T} \\alpha_{l,p,s,t} \\leq a^\\mathrm{maxTotal} \\\\\n \\S^c_\\mathrm{allocationMeetsProductMin}&: \\forall p \\in P, l \\in L, \\sum_{s \\in S, t \\in T} \\alpha_{l,p,s,t} \\geq a^\\mathrm{min}_{p} \\alpha^\\mathrm{product}_{l,p} \\\\\n \\S^c_\\mathrm{allocationMeetsProductBreadth}&: \\forall p \\in P, l \\in L, \\sum_{s \\in S} \\alpha^\\mathrm{size}_{l,p,s} \\geq b_{p} \\alpha^\\mathrm{product}_{l,p} \\\\\n \\S^o_\\mathrm{maximizeValue}&: \\max \\sum_{l \\in L, p \\in P, s \\in S, t \\in T} v_{t} \\alpha_{l,p,s,t} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{products}&: P \\\\\\\\\\n \\\\S^d_\\\\mathrm{sizes}&: S \\\\\\\\\\n \\\\S^d_\\\\mathrm{locations}&: L \\\\\\\\\\n \\\\S^d_\\\\mathrm{tiers}&: T \\\\\\\\\\n \\\\S^p_\\\\mathrm{supply}&: a^\\\\mathrm{max} \\\\in \\\\mathbb{N}^{P \\\\times S} \\\\\\\\\\n \\\\S^p_\\\\mathrm{minAllocation}&: a^\\\\mathrm{min} \\\\in \\\\mathbb{N}^{P} \\\\\\\\\\n \\\\S^p_\\\\mathrm{maxTotalAllocation}&: a^\\\\mathrm{maxTotal} \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^p_\\\\mathrm{breadth}&: b \\\\in \\\\mathbb{N}^{P} \\\\\\\\\\n \\\\S^p_\\\\mathrm{demand}&: d \\\\in \\\\mathbb{N}^{L \\\\times P \\\\times S \\\\times T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{value}&: v \\\\in \\\\mathbb{R}^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{allocation}&: \\\\alpha \\\\in \\\\{0 \\\\ldots a^\\\\mathrm{maxTotal}\\\\}^{L \\\\times P \\\\times S \\\\times T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{productAllocated}&: \\\\alpha^\\\\mathrm{product} \\\\in \\\\{0, 1\\\\}^{L \\\\times P} \\\\\\\\\\n \\\\S^c_\\\\mathrm{productAllocatedActivates}&: \\\\forall l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T, a^\\\\mathrm{maxTotal} \\\\alpha^\\\\mathrm{product}_{l,p} \\\\geq \\\\alpha_{l,p,s,t} \\\\\\\\\\n \\\\S^v_\\\\mathrm{sizeAllocated}&: \\\\alpha^\\\\mathrm{size} \\\\in \\\\{0, 1\\\\}^{L \\\\times P \\\\times S} \\\\\\\\\\n \\\\S^c_\\\\mathrm{sizeAllocatedDeactivates}&: \\\\forall l \\\\in L, p \\\\in P, s \\\\in S, \\\\alpha^\\\\mathrm{size}_{l,p,s} \\\\leq \\\\sum_{t \\\\in T} \\\\alpha_{l,p,s,t} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationFitsWithinDemand}&: \\\\forall l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T, \\\\alpha_{l,p,s,t} \\\\leq d_{l,p,s,t} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationFitsWithinSupply}&: \\\\forall p \\\\in P, s \\\\in S, \\\\sum_{l \\\\in L, t \\\\in T} \\\\alpha_{l,p,s,t} \\\\leq a^\\\\mathrm{max}_{p,s} \\\\\\\\\\n \\\\S^c_\\\\mathrm{totalAllocationFitsWithinMax}&: \\\\sum_{l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T} \\\\alpha_{l,p,s,t} \\\\leq a^\\\\mathrm{maxTotal} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationMeetsProductMin}&: \\\\forall p \\\\in P, l \\\\in L, \\\\sum_{s \\\\in S, t \\\\in T} \\\\alpha_{l,p,s,t} \\\\geq a^\\\\mathrm{min}_{p} \\\\alpha^\\\\mathrm{product}_{l,p} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationMeetsProductBreadth}&: \\\\forall p \\\\in P, l \\\\in L, \\\\sum_{s \\\\in S} \\\\alpha^\\\\mathrm{size}_{l,p,s} \\\\geq b_{p} \\\\alpha^\\\\mathrm{product}_{l,p} \\\\\\\\\\n \\\\S^o_\\\\mathrm{maximizeValue}&: \\\\max \\\\sum_{l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T} v_{t} \\\\alpha_{l,p,s,t} \\\\\\\\\\n\\\\end{align*}\\n$$', title='ProductAllocation')], description=None, annotation=None)" + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{products}&: P \\\\\\\\\\n \\\\S^d_\\\\mathrm{sizes}&: S \\\\\\\\\\n \\\\S^d_\\\\mathrm{locations}&: L \\\\\\\\\\n \\\\S^d_\\\\mathrm{tiers}&: T \\\\\\\\\\n \\\\S^p_\\\\mathrm{supply}&: a^\\\\mathrm{max} \\\\in \\\\mathbb{N}^{P \\\\times S} \\\\\\\\\\n \\\\S^p_\\\\mathrm{minAllocation}&: a^\\\\mathrm{min} \\\\in \\\\mathbb{N}^{P} \\\\\\\\\\n \\\\S^p_\\\\mathrm{maxTotalAllocation}&: a^\\\\mathrm{maxTotal} \\\\in \\\\mathbb{N} \\\\\\\\\\n \\\\S^p_\\\\mathrm{breadth}&: b \\\\in \\\\mathbb{N}^{P} \\\\\\\\\\n \\\\S^p_\\\\mathrm{demand}&: d \\\\in \\\\mathbb{N}^{L \\\\times P \\\\times S \\\\times T} \\\\\\\\\\n \\\\S^p_\\\\mathrm{value}&: v \\\\in \\\\mathbb{R}^{T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{allocation}&: \\\\alpha \\\\in \\\\{0 \\\\ldots a^\\\\mathrm{maxTotal}\\\\}^{L \\\\times P \\\\times S \\\\times T} \\\\\\\\\\n \\\\S^v_\\\\mathrm{productAllocated}&: \\\\alpha^\\\\mathrm{product} \\\\in \\\\{0, 1\\\\}^{L \\\\times P} \\\\\\\\\\n \\\\S^c_\\\\mathrm{productAllocatedActivates}&: \\\\forall l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T, a^\\\\mathrm{maxTotal} \\\\alpha^\\\\mathrm{product}_{l,p} \\\\geq \\\\alpha_{l,p,s,t} \\\\\\\\\\n \\\\S^v_\\\\mathrm{sizeAllocated}&: \\\\alpha^\\\\mathrm{size} \\\\in \\\\{0, 1\\\\}^{L \\\\times P \\\\times S} \\\\\\\\\\n \\\\S^c_\\\\mathrm{sizeAllocatedDeactivates}&: \\\\forall l \\\\in L, p \\\\in P, s \\\\in S, \\\\alpha^\\\\mathrm{size}_{l,p,s} \\\\leq \\\\sum_{t \\\\in T} \\\\alpha_{l,p,s,t} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationFitsWithinDemand}&: \\\\forall l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T, \\\\alpha_{l,p,s,t} \\\\leq d_{l,p,s,t} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationFitsWithinSupply}&: \\\\forall p \\\\in P, s \\\\in S, \\\\sum_{l \\\\in L, t \\\\in T} \\\\alpha_{l,p,s,t} \\\\leq a^\\\\mathrm{max}_{p,s} \\\\\\\\\\n \\\\S^c_\\\\mathrm{totalAllocationFitsWithinMax}&: \\\\sum_{l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T} \\\\alpha_{l,p,s,t} \\\\leq a^\\\\mathrm{maxTotal} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationMeetsProductMin}&: \\\\forall p \\\\in P, l \\\\in L, \\\\sum_{s \\\\in S, t \\\\in T} \\\\alpha_{l,p,s,t} \\\\geq a^\\\\mathrm{min}_{p} \\\\alpha^\\\\mathrm{product}_{l,p} \\\\\\\\\\n \\\\S^c_\\\\mathrm{allocationMeetsProductBreadth}&: \\\\forall p \\\\in P, l \\\\in L, \\\\sum_{s \\\\in S} \\\\alpha^\\\\mathrm{size}_{l,p,s} \\\\geq b_{p} \\\\alpha^\\\\mathrm{product}_{l,p} \\\\\\\\\\n \\\\S^o_\\\\mathrm{maximizeValue}&: \\\\max \\\\sum_{l \\\\in L, p \\\\in P, s \\\\in S, t \\\\in T} v_{t} \\\\alpha_{l,p,s,t} \\\\\\\\\\n\\\\end{align*}\\n$$', title='ProductAllocation')], description=None, annotation=None)" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class ProductAllocation(om.Model):\n", + " products = om.Dimension()\n", + " sizes = om.Dimension()\n", + " locations = om.Dimension()\n", + " tiers = om.Dimension()\n", + "\n", + " supply = om.Parameter.natural(products, sizes, name=r\"a^\\mathrm{max}\")\n", + " min_allocation = om.Parameter.natural(products)\n", + " max_total_allocation = om.Parameter.natural()\n", + " breadth = om.Parameter.natural(products)\n", + " demand = om.Parameter.natural(locations, products, sizes, tiers)\n", + " value = om.Parameter.continuous(tiers)\n", + "\n", + " allocation = om.Variable.natural(*demand.quantifiables(), upper_bound=max_total_allocation())\n", + " product_allocated = om.fragments.ActivationVariable(allocation, projection=0b11)\n", + " size_allocated = om.fragments.ActivationVariable(allocation, projection=0b111, upper_bound=False, lower_bound=1)\n", + " \n", + " @om.constraint\n", + " def allocation_fits_within_demand(self):\n", + " for l, p, s, t in self.locations * self.products * self.sizes * self.tiers:\n", + " yield self.allocation(l, p, s, t) <= self.demand(l, p, s, t)\n", + " \n", + " @om.constraint\n", + " def allocation_fits_within_supply(self):\n", + " for p, s in self.products * self.sizes:\n", + " yield om.total(self.allocation(l, p, s, t) for l, t in self.locations * self.tiers) <= self.supply(p, s)\n", + " \n", + " @om.constraint\n", + " def total_allocation_fits_within_max(self):\n", + " total = om.total(\n", + " self.allocation(l, p, s, t)\n", + " for l, p, s, t in self.locations * self.products * self.sizes * self.tiers\n", + " )\n", + " yield total <= self.max_total_allocation()\n", + " \n", + " @om.constraint\n", + " def allocation_meets_product_min(self):\n", + " for p, l in self.products * self.locations:\n", + " alloc = om.total(self.allocation(l, p, s, t) for s, t in self.sizes * self.tiers)\n", + " yield alloc >= self.min_allocation(p) * self.product_allocated(l, p)\n", + " \n", + " @om.constraint\n", + " def allocation_meets_product_breadth(self):\n", + " for p, l in self.products * self.locations:\n", + " breadth = om.total(self.size_allocated(l, p, s) for s in self.sizes)\n", + " yield breadth >= self.breadth(p) * self.product_allocated(l, p)\n", + "\n", + " @om.objective\n", + " def maximize_value(self):\n", + " return om.total(\n", + " self.value(t) * self.allocation(l, p, s, t)\n", + " for l, p, s, t in self.locations * self.products * self.sizes * self.tiers\n", + " )\n", + "\n", + " \n", + "model = ProductAllocation()\n", + "model.specification()" ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class ProductAllocation(om.Model):\n", - " products = om.Dimension()\n", - " sizes = om.Dimension()\n", - " locations = om.Dimension()\n", - " tiers = om.Dimension()\n", - "\n", - " supply = om.Parameter.natural(products, sizes, name=r\"a^\\mathrm{max}\")\n", - " min_allocation = om.Parameter.natural(products)\n", - " max_total_allocation = om.Parameter.natural()\n", - " breadth = om.Parameter.natural(products)\n", - " demand = om.Parameter.natural(locations, products, sizes, tiers)\n", - " value = om.Parameter.continuous(tiers)\n", - "\n", - " allocation = om.Variable.natural(*demand.quantifiables(), upper_bound=max_total_allocation())\n", - " product_allocated = om.fragments.ActivationVariable(allocation, projection=0b11)\n", - " size_allocated = om.fragments.ActivationVariable(allocation, projection=0b111, upper_bound=False, lower_bound=1)\n", - " \n", - " @om.constraint\n", - " def allocation_fits_within_demand(self):\n", - " for l, p, s, t in self.locations * self.products * self.sizes * self.tiers:\n", - " yield self.allocation(l, p, s, t) <= self.demand(l, p, s, t)\n", - " \n", - " @om.constraint\n", - " def allocation_fits_within_supply(self):\n", - " for p, s in self.products * self.sizes:\n", - " yield om.total(self.allocation(l, p, s, t) for l, t in self.locations * self.tiers) <= self.supply(p, s)\n", - " \n", - " @om.constraint\n", - " def total_allocation_fits_within_max(self):\n", - " total = om.total(\n", - " self.allocation(l, p, s, t)\n", - " for l, p, s, t in self.locations * self.products * self.sizes * self.tiers\n", - " )\n", - " yield total <= self.max_total_allocation()\n", - " \n", - " @om.constraint\n", - " def allocation_meets_product_min(self):\n", - " for p, l in self.products * self.locations:\n", - " alloc = om.total(self.allocation(l, p, s, t) for s, t in self.sizes * self.tiers)\n", - " yield alloc >= self.min_allocation(p) * self.product_allocated(l, p)\n", - " \n", - " @om.constraint\n", - " def allocation_meets_product_breadth(self):\n", - " for p, l in self.products * self.locations:\n", - " breadth = om.total(self.size_allocated(l, p, s) for s in self.sizes)\n", - " yield breadth >= self.breadth(p) * self.product_allocated(l, p)\n", - "\n", - " @om.objective\n", - " def maximize_value(self):\n", - " return om.total(\n", - " self.value(t) * self.allocation(l, p, s, t)\n", - " for l, p, s, t in self.locations * self.products * self.sizes * self.tiers\n", - " )\n", - "\n", - " \n", - "model = ProductAllocation()\n", - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "fafc3407", - "metadata": {}, - "source": [ - "## Testing\n", - "\n", - "Let's solve the model above on a small dataset." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "da65c912", - "metadata": {}, - "outputs": [], - "source": [ - "import io\n", - "import opvious\n", - "import pandas as pd\n", - "\n", - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "fc6c1534", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "fafc3407", + "metadata": {}, + "source": [ + "## Testing\n", + "\n", + "Let's solve the model above on a small dataset." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
demand
locationproductsizetier
BostonhoodieMT150
shirtLT130
T225
XLT120
SeattlehoodieMT1100
LT175
T250
XLT150
shirtLT110
\n", - "
" + "cell_type": "code", + "execution_count": 3, + "id": "da65c912", + "metadata": {}, + "outputs": [], + "source": [ + "import io\n", + "import opvious\n", + "import pandas as pd\n", + "\n", + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fc6c1534", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
demand
locationproductsizetier
BostonhoodieMT150
shirtLT130
T225
XLT120
SeattlehoodieMT1100
LT175
T250
XLT150
shirtLT110
\n
", + "text/plain": " demand\nlocation product size tier \nBoston hoodie M T1 50\n shirt L T1 30\n T2 25\n XL T1 20\nSeattle hoodie M T1 100\n L T1 75\n T2 50\n XL T1 50\n shirt L T1 10" + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " demand\n", - "location product size tier \n", - "Boston hoodie M T1 50\n", - " shirt L T1 30\n", - " T2 25\n", - " XL T1 20\n", - "Seattle hoodie M T1 100\n", - " L T1 75\n", - " T2 50\n", - " XL T1 50\n", - " shirt L T1 10" + "source": [ + "demand_df = pd.read_csv(io.StringIO(\"\"\"\n", + "location,tier,product,size,demand\n", + "Boston,T1,hoodie,M,50\n", + "Boston,T1,shirt,L,30\n", + "Boston,T2,shirt,L,25\n", + "Boston,T1,shirt,XL,20\n", + "Seattle,T1,hoodie,M,100\n", + "Seattle,T1,hoodie,L,75\n", + "Seattle,T2,hoodie,L,50\n", + "Seattle,T1,hoodie,XL,50\n", + "Seattle,T1,shirt,L,10\n", + "\"\"\")).set_index([\"location\", \"product\", \"size\", \"tier\"])\n", + "demand_df" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "demand_df = pd.read_csv(io.StringIO(\"\"\"\n", - "location,tier,product,size,demand\n", - "Boston,T1,hoodie,M,50\n", - "Boston,T1,shirt,L,30\n", - "Boston,T2,shirt,L,25\n", - "Boston,T1,shirt,XL,20\n", - "Seattle,T1,hoodie,M,100\n", - "Seattle,T1,hoodie,L,75\n", - "Seattle,T2,hoodie,L,50\n", - "Seattle,T1,hoodie,XL,50\n", - "Seattle,T1,shirt,L,10\n", - "\"\"\")).set_index([\"location\", \"product\", \"size\", \"tier\"])\n", - "demand_df" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "9cacd452", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
supply
productsize
hoodieM100
L50
shirtL50
XL10
\n", - "
" + "cell_type": "code", + "execution_count": 5, + "id": "9cacd452", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
supply
productsize
hoodieM100
L50
shirtL50
XL10
\n
", + "text/plain": " supply\nproduct size \nhoodie M 100\n L 50\nshirt L 50\n XL 10" + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " supply\n", - "product size \n", - "hoodie M 100\n", - " L 50\n", - "shirt L 50\n", - " XL 10" + "source": [ + "supply_df = pd.read_csv(io.StringIO(\"\"\"\n", + "product,size,supply\n", + "hoodie,M,100\n", + "hoodie,L,50\n", + "shirt,L,50\n", + "shirt,XL,10\n", + "\"\"\")).set_index([\"product\", \"size\"])\n", + "supply_df" ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "supply_df = pd.read_csv(io.StringIO(\"\"\"\n", - "product,size,supply\n", - "hoodie,M,100\n", - "hoodie,L,50\n", - "shirt,L,50\n", - "shirt,XL,10\n", - "\"\"\")).set_index([\"product\", \"size\"])\n", - "supply_df" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "90c13efe", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
min_allocationdiversity
product
hoodie1002
shirt102
\n", - "
" + "cell_type": "code", + "execution_count": 6, + "id": "90c13efe", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
min_allocationdiversity
product
hoodie1002
shirt102
\n
", + "text/plain": " min_allocation diversity\nproduct \nhoodie 100 2\nshirt 10 2" + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " min_allocation diversity\n", - "product \n", - "hoodie 100 2\n", - "shirt 10 2" + "source": [ + "product_df = pd.read_csv(io.StringIO(\"\"\"\n", + "product,min_allocation,diversity\n", + "hoodie,100,2\n", + "shirt,10,2\n", + "\"\"\")).set_index([\"product\"])\n", + "product_df" ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "product_df = pd.read_csv(io.StringIO(\"\"\"\n", - "product,min_allocation,diversity\n", - "hoodie,100,2\n", - "shirt,10,2\n", - "\"\"\")).set_index([\"product\"])\n", - "product_df" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "f66abb7d", - "metadata": {}, - "outputs": [], - "source": [ - "solution = await client.solve(\n", - " opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={\n", - " \"demand\": demand_df[\"demand\"],\n", - " \"value\": {\"T1\": 1, \"T2\": 0.8},\n", - " \"minAllocation\": product_df[\"min_allocation\"],\n", - " \"breadth\": product_df[\"diversity\"],\n", - " \"supply\": supply_df[\"supply\"],\n", - " \"maxTotalAllocation\": 500\n", - " },\n", - " ),\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "bdf01ff6", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f66abb7d", + "metadata": {}, + "outputs": [], + "source": [ + "solution = await client.solve(\n", + " opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={\n", + " \"demand\": demand_df[\"demand\"],\n", + " \"value\": {\"T1\": 1, \"T2\": 0.8},\n", + " \"minAllocation\": product_df[\"min_allocation\"],\n", + " \"breadth\": product_df[\"diversity\"],\n", + " \"supply\": supply_df[\"supply\"],\n", + " \"maxTotalAllocation\": 500\n", + " },\n", + " ),\n", + ")" + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
value
locationsproductssizestiers
BostonshirtLT130
T220
XLT110
SeattlehoodieLT150
MT1100
\n", - "
" + "cell_type": "code", + "execution_count": 8, + "id": "bdf01ff6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
value
locationsproductssizestiers
BostonshirtLT130
T220
XLT110
SeattlehoodieLT150
MT1100
\n
", + "text/plain": " value\nlocations products sizes tiers \nBoston shirt L T1 30\n T2 20\n XL T1 10\nSeattle hoodie L T1 50\n M T1 100" + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - " value\n", - "locations products sizes tiers \n", - "Boston shirt L T1 30\n", - " T2 20\n", - " XL T1 10\n", - "Seattle hoodie L T1 50\n", - " M T1 100" + "source": [ + "solution.outputs.variable(\"allocation\")" ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cbac1598", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "solution.outputs.variable(\"allocation\")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "cbac1598", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/examples/sudoku.ipynb b/resources/examples/sudoku.ipynb index 276e357..0360c51 100644 --- a/resources/examples/sudoku.ipynb +++ b/resources/examples/sudoku.ipynb @@ -1,920 +1,399 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "ca71ca24", - "metadata": {}, - "source": [ - "# Sudoku assistant\n", - "\n", - "
\n", - " ⓘ This notebook (source) can be executed directly from your browser when accessed via its opvious.io/notebooks URL.\n", - "
\n", - "\n", - "This notebook shows how we can use the [Opvious Python SDK](https://github.com/opvious/sdk.py) to solve Sudoku grids and identify mistakes. You will not only be able to quickly find solutions but also restore feasibility in grids such as this one:\n", - "\n", - "
\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "d94f9d35", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "be16f96e", - "metadata": {}, - "source": [ - "## Setup\n", - "\n", - "We first introduce a few utilities to parse and render Sudoku grids." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "f37990ab", - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "\n", - "def parse_grid(s):\n", - " \"\"\"Parses (row, column, value) triples from a grid's string representation\"\"\"\n", - " return [\n", - " (i, j, int(c))\n", - " for i, line in enumerate(s.strip().split(\"\\n\"))\n", - " for j, c in enumerate(line.strip())\n", - " if c != \".\"\n", - " ]\n", - "\n", - "def pretty_grid(triples):\n", - " \"\"\"Pretty-prints a list of triples as a 2d grid\"\"\"\n", - " positions = list(range(9))\n", - " return (\n", - " pd.DataFrame(triples, columns=['row', 'column', 'value'])\n", - " .pivot_table(index='row', columns='column', values='value')\n", - " .reindex(positions)\n", - " .reindex(positions, axis=1)\n", - " .map(lambda v: str(int(v)) if v == v else '')\n", - " )" - ] - }, - { - "cell_type": "markdown", - "id": "a65a3d3c", - "metadata": {}, - "source": [ - "## Creating the model\n", - "\n", - "The next step is to formulate Sudoku as an optimization problem using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "793f26e4", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "ca71ca24", + "metadata": {}, + "source": [ + "# Sudoku assistant\n", + "\n", + "
\n", + " ⓘ This notebook (source) can be executed directly from your browser when accessed via its opvious.io/notebooks URL.\n", + "
\n", + "\n", + "This notebook shows how we can use the [Opvious Python SDK](https://github.com/opvious/sdk.py) to solve Sudoku grids and identify mistakes. You will not only be able to quickly find solutions but also restore feasibility in grids such as this one:\n", + "\n", + "
\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d94f9d35", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "be16f96e", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "We first introduce a few utilities to parse and render Sudoku grids." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "f37990ab", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "def parse_grid(s):\n", + " \"\"\"Parses (row, column, value) triples from a grid's string representation\"\"\"\n", + " return [\n", + " (i, j, int(c))\n", + " for i, line in enumerate(s.strip().split(\"\\n\"))\n", + " for j, c in enumerate(line.strip())\n", + " if c != \".\"\n", + " ]\n", + "\n", + "def pretty_grid(triples):\n", + " \"\"\"Pretty-prints a list of triples as a 2d grid\"\"\"\n", + " positions = list(range(9))\n", + " return (\n", + " pd.DataFrame(triples, columns=['row', 'column', 'value'])\n", + " .pivot_table(index='row', columns='column', values='value')\n", + " .reindex(positions)\n", + " .reindex(positions, axis=1)\n", + " .map(lambda v: str(int(v)) if v == v else '')\n", + " )" + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "Sudoku\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^p_\\mathrm{input[row,column,value]}&: i \\in \\{0, 1\\}^{P \\times P \\times V} \\\\\n", - " \\S^v_\\mathrm{output[row,column,value]}&: \\omicron \\in \\{0, 1\\}^{P \\times P \\times V} \\\\\n", - " \\S^a&: P \\doteq \\{ 0 \\ldots 8 \\} \\\\\n", - " \\S^a&: V \\doteq \\{ 1 \\ldots 9 \\} \\\\\n", - " \\S^c_\\mathrm{outputMatchesInput}&: \\forall p \\in P, p' \\in P, v \\in V \\mid i_{p,p',v} \\neq 0, \\omicron_{p,p',v} \\geq i_{p,p',v} \\\\\n", - " \\S^c_\\mathrm{oneOutputPerCell}&: \\forall p \\in P, p' \\in P, \\sum_{v \\in V} \\omicron_{p,p',v} = 1 \\\\\n", - " \\S^c_\\mathrm{oneValuePerColumn}&: \\forall p \\in P, v \\in V, \\sum_{p' \\in P} \\omicron_{p',p,v} = 1 \\\\\n", - " \\S^c_\\mathrm{oneValuePerRow}&: \\forall p \\in P, v \\in V, \\sum_{p' \\in P} \\omicron_{p,p',v} = 1 \\\\\n", - " \\S^c_\\mathrm{oneValuePerBox}&: \\forall v \\in V, p \\in P, \\sum_{p' \\in P} \\omicron_{3 \\left\\lfloor \\frac{p}{3} \\right\\rfloor + \\left\\lfloor \\frac{p'}{3} \\right\\rfloor,3 \\left(p \\bmod 3\\right) + p' \\bmod 3,v} = 1 \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "markdown", + "id": "a65a3d3c", + "metadata": {}, + "source": [ + "## Creating the model\n", + "\n", + "The next step is to formulate Sudoku as an optimization problem using `opvious`' [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "793f26e4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nSudoku\n
\n$$\n\\begin{align*}\n \\S^p_\\mathrm{input[row,column,value]}&: i \\in \\{0, 1\\}^{P \\times P \\times V} \\\\\n \\S^v_\\mathrm{output[row,column,value]}&: \\omicron \\in \\{0, 1\\}^{P \\times P \\times V} \\\\\n \\S^a&: P \\doteq \\{ 0 \\ldots 8 \\} \\\\\n \\S^a&: V \\doteq \\{ 1 \\ldots 9 \\} \\\\\n \\S^c_\\mathrm{outputMatchesInput}&: \\forall p \\in P, p' \\in P, v \\in V \\mid i_{p,p',v} \\neq 0, \\omicron_{p,p',v} \\geq i_{p,p',v} \\\\\n \\S^c_\\mathrm{oneOutputPerCell}&: \\forall p \\in P, p' \\in P, \\sum_{v \\in V} \\omicron_{p,p',v} = 1 \\\\\n \\S^c_\\mathrm{oneValuePerColumn}&: \\forall p \\in P, v \\in V, \\sum_{p' \\in P} \\omicron_{p',p,v} = 1 \\\\\n \\S^c_\\mathrm{oneValuePerRow}&: \\forall p \\in P, v \\in V, \\sum_{p' \\in P} \\omicron_{p,p',v} = 1 \\\\\n \\S^c_\\mathrm{oneValuePerBox}&: \\forall v \\in V, p \\in P, \\sum_{p' \\in P} \\omicron_{3 \\left\\lfloor \\frac{p}{3} \\right\\rfloor + \\left\\lfloor \\frac{p'}{3} \\right\\rfloor,3 \\left(p \\bmod 3\\right) + p' \\bmod 3,v} = 1 \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{input[row,column,value]}&: i \\\\in \\\\{0, 1\\\\}^{P \\\\times P \\\\times V} \\\\\\\\\\n \\\\S^v_\\\\mathrm{output[row,column,value]}&: \\\\omicron \\\\in \\\\{0, 1\\\\}^{P \\\\times P \\\\times V} \\\\\\\\\\n \\\\S^a&: P \\\\doteq \\\\{ 0 \\\\ldots 8 \\\\} \\\\\\\\\\n \\\\S^a&: V \\\\doteq \\\\{ 1 \\\\ldots 9 \\\\} \\\\\\\\\\n \\\\S^c_\\\\mathrm{outputMatchesInput}&: \\\\forall p \\\\in P, p' \\\\in P, v \\\\in V \\\\mid i_{p,p',v} \\\\neq 0, \\\\omicron_{p,p',v} \\\\geq i_{p,p',v} \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneOutputPerCell}&: \\\\forall p \\\\in P, p' \\\\in P, \\\\sum_{v \\\\in V} \\\\omicron_{p,p',v} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneValuePerColumn}&: \\\\forall p \\\\in P, v \\\\in V, \\\\sum_{p' \\\\in P} \\\\omicron_{p',p,v} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneValuePerRow}&: \\\\forall p \\\\in P, v \\\\in V, \\\\sum_{p' \\\\in P} \\\\omicron_{p,p',v} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneValuePerBox}&: \\\\forall v \\\\in V, p \\\\in P, \\\\sum_{p' \\\\in P} \\\\omicron_{3 \\\\left\\\\lfloor \\\\frac{p}{3} \\\\right\\\\rfloor + \\\\left\\\\lfloor \\\\frac{p'}{3} \\\\right\\\\rfloor,3 \\\\left(p \\\\bmod 3\\\\right) + p' \\\\bmod 3,v} = 1 \\\\\\\\\\n\\\\end{align*}\\n$$\", title='Sudoku')], description='A mixed-integer model for Sudoku', annotation=None)" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text=\"$$\\n\\\\begin{align*}\\n \\\\S^p_\\\\mathrm{input[row,column,value]}&: i \\\\in \\\\{0, 1\\\\}^{P \\\\times P \\\\times V} \\\\\\\\\\n \\\\S^v_\\\\mathrm{output[row,column,value]}&: \\\\omicron \\\\in \\\\{0, 1\\\\}^{P \\\\times P \\\\times V} \\\\\\\\\\n \\\\S^a&: P \\\\doteq \\\\{ 0 \\\\ldots 8 \\\\} \\\\\\\\\\n \\\\S^a&: V \\\\doteq \\\\{ 1 \\\\ldots 9 \\\\} \\\\\\\\\\n \\\\S^c_\\\\mathrm{outputMatchesInput}&: \\\\forall p \\\\in P, p' \\\\in P, v \\\\in V \\\\mid i_{p,p',v} \\\\neq 0, \\\\omicron_{p,p',v} \\\\geq i_{p,p',v} \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneOutputPerCell}&: \\\\forall p \\\\in P, p' \\\\in P, \\\\sum_{v \\\\in V} \\\\omicron_{p,p',v} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneValuePerColumn}&: \\\\forall p \\\\in P, v \\\\in V, \\\\sum_{p' \\\\in P} \\\\omicron_{p',p,v} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneValuePerRow}&: \\\\forall p \\\\in P, v \\\\in V, \\\\sum_{p' \\\\in P} \\\\omicron_{p,p',v} = 1 \\\\\\\\\\n \\\\S^c_\\\\mathrm{oneValuePerBox}&: \\\\forall v \\\\in V, p \\\\in P, \\\\sum_{p' \\\\in P} \\\\omicron_{3 \\\\left\\\\lfloor \\\\frac{p}{3} \\\\right\\\\rfloor + \\\\left\\\\lfloor \\\\frac{p'}{3} \\\\right\\\\rfloor,3 \\\\left(p \\\\bmod 3\\\\right) + p' \\\\bmod 3,v} = 1 \\\\\\\\\\n\\\\end{align*}\\n$$\", title='Sudoku')], description='A mixed-integer model for Sudoku', annotation=None)" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class Sudoku(om.Model):\n", + " \"\"\"A mixed-integer model for Sudoku\"\"\"\n", + " \n", + " positions = om.interval(0, 8, name='P')\n", + " values = om.interval(1, 9, name='V')\n", + "\n", + " def __init__(self):\n", + " self.input = om.Parameter.indicator(self.grid * self.values, qualifiers=['row', 'column', 'value'])\n", + " self.output = om.Variable.indicator(self.grid * self.values, qualifiers=['row', 'column', 'value'])\n", + "\n", + " @property\n", + " def grid(self):\n", + " \"\"\"Cross-product of (row, column) positions\"\"\"\n", + " return self.positions * self.positions\n", + "\n", + " @om.constraint\n", + " def output_matches_input(self):\n", + " \"\"\"The output must match all input values where specified\"\"\"\n", + " for i, j, v in self.grid * self.values:\n", + " if self.input(i, j, v):\n", + " yield self.output(i, j, v) >= self.input(i, j, v)\n", + "\n", + " @om.constraint\n", + " def one_output_per_cell(self):\n", + " \"\"\"Each cell has exactly one value\"\"\"\n", + " for i, j in self.grid:\n", + " yield om.total(self.output(i, j, v) == 1 for v in self.values)\n", + "\n", + " @om.constraint\n", + " def one_value_per_column(self):\n", + " \"\"\"Each value is present exactly once per column\"\"\"\n", + " for j, v in self.positions * self.values:\n", + " yield om.total(self.output(i, j, v) == 1 for i in self.positions)\n", + "\n", + " @om.constraint\n", + " def one_value_per_row(self):\n", + " \"\"\"Each value is present exactly once per row\"\"\"\n", + " for i, v in self.positions * self.values:\n", + " yield om.total(self.output(i, j, v) == 1 for j in self.positions)\n", + "\n", + " @om.constraint\n", + " def one_value_per_box(self):\n", + " \"\"\"Each value is present exactly once per box\"\"\"\n", + " for v, b in self.values * self.positions:\n", + " yield om.total(\n", + " self.output(3 * (b // 3) + c // 3, 3 * (b % 3) + c % 3, v) == 1\n", + " for c in self.positions\n", + " )\n", + " \n", + "model = Sudoku()\n", + "model.specification() # The model's mathematical definitions" ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class Sudoku(om.Model):\n", - " \"\"\"A mixed-integer model for Sudoku\"\"\"\n", - " \n", - " positions = om.interval(0, 8, name='P')\n", - " values = om.interval(1, 9, name='V')\n", - "\n", - " def __init__(self):\n", - " self.input = om.Parameter.indicator(self.grid * self.values, qualifiers=['row', 'column', 'value'])\n", - " self.output = om.Variable.indicator(self.grid * self.values, qualifiers=['row', 'column', 'value'])\n", - "\n", - " @property\n", - " def grid(self):\n", - " \"\"\"Cross-product of (row, column) positions\"\"\"\n", - " return self.positions * self.positions\n", - "\n", - " @om.constraint\n", - " def output_matches_input(self):\n", - " \"\"\"The output must match all input values where specified\"\"\"\n", - " for i, j, v in self.grid * self.values:\n", - " if self.input(i, j, v):\n", - " yield self.output(i, j, v) >= self.input(i, j, v)\n", - "\n", - " @om.constraint\n", - " def one_output_per_cell(self):\n", - " \"\"\"Each cell has exactly one value\"\"\"\n", - " for i, j in self.grid:\n", - " yield om.total(self.output(i, j, v) == 1 for v in self.values)\n", - "\n", - " @om.constraint\n", - " def one_value_per_column(self):\n", - " \"\"\"Each value is present exactly once per column\"\"\"\n", - " for j, v in self.positions * self.values:\n", - " yield om.total(self.output(i, j, v) == 1 for i in self.positions)\n", - "\n", - " @om.constraint\n", - " def one_value_per_row(self):\n", - " \"\"\"Each value is present exactly once per row\"\"\"\n", - " for i, v in self.positions * self.values:\n", - " yield om.total(self.output(i, j, v) == 1 for j in self.positions)\n", - "\n", - " @om.constraint\n", - " def one_value_per_box(self):\n", - " \"\"\"Each value is present exactly once per box\"\"\"\n", - " for v, b in self.values * self.positions:\n", - " yield om.total(\n", - " self.output(3 * (b // 3) + c // 3, 3 * (b % 3) + c % 3, v) == 1\n", - " for c in self.positions\n", - " )\n", - " \n", - "model = Sudoku()\n", - "model.specification() # The model's mathematical definitions" - ] - }, - { - "cell_type": "markdown", - "id": "51479849", - "metadata": {}, - "source": [ - "## Finding solutions\n", - "\n", - "Now that we've formulated the problem, we'll first use it to fill in Sudoku grids. We just need to pass in the initial triples as `input` parameter and [start solving](https://opvious.readthedocs.io/en/stable/overview.html#solves)." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "1d641d3a", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "async def fill_in(grid):\n", - " \"\"\"Completes a partial grid into a valid solution\n", - " \n", - " Args:\n", - " grid: Partial grid\n", - " \"\"\"\n", - " problem = opvious.Problem(model.specification(), parameters={'input': parse_grid(grid)})\n", - " solution = await client.solve(problem)\n", - " output = solution.outputs.variable('output')\n", - " return pretty_grid(output.index.to_list())" - ] - }, - { - "cell_type": "markdown", - "id": "f8a128bb", - "metadata": {}, - "source": [ - "We test that it works on an example." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "74184eef", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "51479849", + "metadata": {}, + "source": [ + "## Finding solutions\n", + "\n", + "Now that we've formulated the problem, we'll first use it to fill in Sudoku grids. We just need to pass in the initial triples as `input` parameter and [start solving](https://opvious.readthedocs.io/en/stable/overview.html#solves)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1d641d3a", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "async def fill_in(grid):\n", + " \"\"\"Completes a partial grid into a valid solution\n", + " \n", + " Args:\n", + " grid: Partial grid\n", + " \"\"\"\n", + " problem = opvious.Problem(model.specification(), parameters={'input': parse_grid(grid)})\n", + " solution = await client.solve(problem)\n", + " output = solution.outputs.variable('output')\n", + " return pretty_grid(output.index.to_list())" + ] + }, + { + "cell_type": "markdown", + "id": "f8a128bb", + "metadata": {}, + "source": [ + "We test that it works on an example." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
column012345678
row
0876592314
1532416987
2419738625
3351869742
4698247531
5247153896
6924375168
7783621459
8165984273
\n", - "
" + "cell_type": "code", + "execution_count": 5, + "id": "74184eef", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
column012345678
row
0876592314
1532416987
2419738625
3351869742
4698247531
5247153896
6924375168
7783621459
8165984273
\n
", + "text/plain": "column 0 1 2 3 4 5 6 7 8\nrow \n0 8 7 6 5 9 2 3 1 4\n1 5 3 2 4 1 6 9 8 7\n2 4 1 9 7 3 8 6 2 5\n3 3 5 1 8 6 9 7 4 2\n4 6 9 8 2 4 7 5 3 1\n5 2 4 7 1 5 3 8 9 6\n6 9 2 4 3 7 5 1 6 8\n7 7 8 3 6 2 1 4 5 9\n8 1 6 5 9 8 4 2 7 3" + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "column 0 1 2 3 4 5 6 7 8\n", - "row \n", - "0 8 7 6 5 9 2 3 1 4\n", - "1 5 3 2 4 1 6 9 8 7\n", - "2 4 1 9 7 3 8 6 2 5\n", - "3 3 5 1 8 6 9 7 4 2\n", - "4 6 9 8 2 4 7 5 3 1\n", - "5 2 4 7 1 5 3 8 9 6\n", - "6 9 2 4 3 7 5 1 6 8\n", - "7 7 8 3 6 2 1 4 5 9\n", - "8 1 6 5 9 8 4 2 7 3" + "source": [ + "await fill_in(\"\"\"\n", + " 87.59...4\n", + " ..2...98.\n", + " 41.7.8.25\n", + " .5..6...2\n", + " ....4....\n", + " ..71.....\n", + " .2.......\n", + " .8.6...5.\n", + " 1.59.....\n", + "\"\"\")" ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await fill_in(\"\"\"\n", - " 87.59...4\n", - " ..2...98.\n", - " 41.7.8.25\n", - " .5..6...2\n", - " ....4....\n", - " ..71.....\n", - " .2.......\n", - " .8.6...5.\n", - " 1.59.....\n", - "\"\"\")" - ] - }, - { - "cell_type": "markdown", - "id": "2da615ad", - "metadata": {}, - "source": [ - "## Identifying mistakes\n", - "\n", - "Mistakes can happen when manually solving Sudoku puzzles. We often discover these much later, making it difficult to identify which decision(s) caused the grid to become infeasible.\n", - "\n", - "If we were to use `fill_in` on an infeasible grid directly, it would throw an exception. Fortunately, we only need to tweak its implementation slightly to instead detect the _smallest set of changes_ needed to restore feasibility in a grid. We simply [relax the input matching constraint](https://opvious.readthedocs.io/en/stable/transformations.html#relaxing-constraints) and output the corresponding deficit. Clearing all cells with a deficit is guaranteed to make the grid solvable again." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "5a02f11d", - "metadata": {}, - "outputs": [], - "source": [ - "async def find_mistakes(grid):\n", - " \"\"\"Returns the smallest subgrid which restore feasibility when cleared\n", - " \n", - " Args:\n", - " grid: Partial grid\n", - " \"\"\"\n", - " problem = opvious.Problem(\n", - " model.specification(),\n", - " parameters={'input': parse_grid(grid)},\n", - " transformations=[opvious.transformations.RelaxConstraints(['outputMatchesInput'])],\n", - " )\n", - " solution = await client.solve(problem)\n", - " deficit = solution.outputs.variable('outputMatchesInput_deficit')\n", - " return pretty_grid(deficit.index.to_list())" - ] - }, - { - "cell_type": "markdown", - "id": "f37e5b1f", - "metadata": {}, - "source": [ - "We check that it works on the infeasible grid displayed above." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "bcb81f58", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "2da615ad", + "metadata": {}, + "source": [ + "## Identifying mistakes\n", + "\n", + "Mistakes can happen when manually solving Sudoku puzzles. We often discover these much later, making it difficult to identify which decision(s) caused the grid to become infeasible.\n", + "\n", + "If we were to use `fill_in` on an infeasible grid directly, it would throw an exception. Fortunately, we only need to tweak its implementation slightly to instead detect the _smallest set of changes_ needed to restore feasibility in a grid. We simply [relax the input matching constraint](https://opvious.readthedocs.io/en/stable/transformations.html#relaxing-constraints) and output the corresponding deficit. Clearing all cells with a deficit is guaranteed to make the grid solvable again." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
column012345678
row
0
1
2
357
495
5
6
7
8
\n", - "
" + "cell_type": "code", + "execution_count": 6, + "id": "5a02f11d", + "metadata": {}, + "outputs": [], + "source": [ + "async def find_mistakes(grid):\n", + " \"\"\"Returns the smallest subgrid which restore feasibility when cleared\n", + " \n", + " Args:\n", + " grid: Partial grid\n", + " \"\"\"\n", + " problem = opvious.Problem(\n", + " model.specification(),\n", + " parameters={'input': parse_grid(grid)},\n", + " transformations=[opvious.transformations.RelaxConstraints(['outputMatchesInput'])],\n", + " )\n", + " solution = await client.solve(problem)\n", + " deficit = solution.outputs.variable('outputMatchesInput_deficit')\n", + " return pretty_grid(deficit.index.to_list())" + ] + }, + { + "cell_type": "markdown", + "id": "f37e5b1f", + "metadata": {}, + "source": [ + "We check that it works on the infeasible grid displayed above." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "bcb81f58", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
column012345678
row
0
1
2
357
495
5
6
7
8
\n
", + "text/plain": "column 0 1 2 3 4 5 6 7 8\nrow \n0 \n1 \n2 \n3 5 7 \n4 9 5 \n5 \n6 \n7 \n8 " + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "column 0 1 2 3 4 5 6 7 8\n", - "row \n", - "0 \n", - "1 \n", - "2 \n", - "3 5 7 \n", - "4 9 5 \n", - "5 \n", - "6 \n", - "7 \n", - "8 " + "source": [ + "infeasible_grid = \"\"\"\n", + " 876592314\n", + " 532416987\n", + " 419738625\n", + " 351867742\n", + " 698243591\n", + " 247159863\n", + " 924385176\n", + " 783621459\n", + " 165974238\n", + "\"\"\"\n", + "\n", + "await find_mistakes(infeasible_grid)" ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "infeasible_grid = \"\"\"\n", - " 876592314\n", - " 532416987\n", - " 419738625\n", - " 351867742\n", - " 698243591\n", - " 247159863\n", - " 924385176\n", - " 783621459\n", - " 165974238\n", - "\"\"\"\n", - "\n", - "await find_mistakes(infeasible_grid)" - ] - }, - { - "cell_type": "markdown", - "id": "1089030a", - "metadata": {}, - "source": [ - "As a quick extension we'll allow marking certain cells as definitely correct. This can be useful to find different sets of mistakes or to prevent the solution from including the initial Sudoku numbers. For example you can notice that the 5 highlighted as mistake in row 3 was part of the infeasible grid's original input.\n", - "\n", - "To do so, we just add a [transformation which pins](https://opvious.readthedocs.io/en/stable/transformations.html#pinning-variables) a configurable subset of output variables to their current value to the original `find_mistakes` implementation. This will automatically generate an `output_pin` parameter which we can use to mark certain outputs as fixed." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "cc6fe174", - "metadata": {}, - "outputs": [], - "source": [ - "async def explore_mistakes(grid, correct=None):\n", - " \"\"\"Returns the smallest subset of input triples which restore feasibility when removed\n", - " \n", - " Args:\n", - " inputs: Partial grid\n", - " correct: List of (row, column) pairs capturing cells which are known to be correct (these\n", - " will never be returned as mistakes)\n", - " \"\"\"\n", - " inputs = parse_grid(grid)\n", - " problem = opvious.Problem(\n", - " model.specification(),\n", - " parameters={\n", - " 'input': inputs,\n", - " 'output_pin': [t for t in inputs if t[:2] in correct] if correct else [], # Pinned outputs\n", - " },\n", - " transformations=[\n", - " opvious.transformations.RelaxConstraints(['outputMatchesInput']),\n", - " opvious.transformations.PinVariables(['output']), # Added transformation\n", - " ],\n", - " )\n", - " solution = await client.solve(problem)\n", - " deficit = solution.outputs.variable('outputMatchesInput_deficit')\n", - " return pretty_grid(deficit.index.to_list())" - ] - }, - { - "cell_type": "markdown", - "id": "fa09b012", - "metadata": {}, - "source": [ - "We check that it works by marking the 5 in row 3 as correct. The function now accurately finds another way to restore feasibility." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "d7bc8b3d", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "1089030a", + "metadata": {}, + "source": [ + "As a quick extension we'll allow marking certain cells as definitely correct. This can be useful to find different sets of mistakes or to prevent the solution from including the initial Sudoku numbers. For example you can notice that the 5 highlighted as mistake in row 3 was part of the infeasible grid's original input.\n", + "\n", + "To do so, we just add a [transformation which pins](https://opvious.readthedocs.io/en/stable/transformations.html#pinning-variables) a configurable subset of output variables to their current value to the original `find_mistakes` implementation. This will automatically generate an `output_pin` parameter which we can use to mark certain outputs as fixed." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cc6fe174", + "metadata": {}, + "outputs": [], + "source": [ + "async def explore_mistakes(grid, correct=None):\n", + " \"\"\"Returns the smallest subset of input triples which restore feasibility when removed\n", + " \n", + " Args:\n", + " inputs: Partial grid\n", + " correct: List of (row, column) pairs capturing cells which are known to be correct (these\n", + " will never be returned as mistakes)\n", + " \"\"\"\n", + " inputs = parse_grid(grid)\n", + " problem = opvious.Problem(\n", + " model.specification(),\n", + " parameters={\n", + " 'input': inputs,\n", + " 'output_pin': [t for t in inputs if t[:2] in correct] if correct else [], # Pinned outputs\n", + " },\n", + " transformations=[\n", + " opvious.transformations.RelaxConstraints(['outputMatchesInput']),\n", + " opvious.transformations.PinVariables(['output']), # Added transformation\n", + " ],\n", + " )\n", + " solution = await client.solve(problem)\n", + " deficit = solution.outputs.variable('outputMatchesInput_deficit')\n", + " return pretty_grid(deficit.index.to_list())" + ] + }, + { + "cell_type": "markdown", + "id": "fa09b012", + "metadata": {}, + "source": [ + "We check that it works by marking the 5 in row 3 as correct. The function now accurately finds another way to restore feasibility." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
column012345678
row
0
1
2
374
449
5
6
7
874
\n", - "
" + "cell_type": "code", + "execution_count": 9, + "id": "d7bc8b3d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
column012345678
row
0
1
2
374
449
5
6
7
874
\n
", + "text/plain": "column 0 1 2 3 4 5 6 7 8\nrow \n0 \n1 \n2 \n3 7 4 \n4 4 9 \n5 \n6 \n7 \n8 7 4 " + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "column 0 1 2 3 4 5 6 7 8\n", - "row \n", - "0 \n", - "1 \n", - "2 \n", - "3 7 4 \n", - "4 4 9 \n", - "5 \n", - "6 \n", - "7 \n", - "8 7 4 " + "source": [ + "await explore_mistakes(infeasible_grid, correct=[(3, 1)])" ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ceb2466d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "await explore_mistakes(infeasible_grid, correct=[(3, 1)])" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "ceb2466d", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/guides/managing-modeling-complexity.ipynb b/resources/guides/managing-modeling-complexity.ipynb index d3be341..51e500f 100644 --- a/resources/guides/managing-modeling-complexity.ipynb +++ b/resources/guides/managing-modeling-complexity.ipynb @@ -1,393 +1,271 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "001ead62", - "metadata": {}, - "source": [ - "# Managing modeling complexity\n", - "\n", - "In this notebook we highlight a few features of `opvious`'s [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html) which help build and maintain large models." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "531c55c1", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "802d4cb9", - "metadata": {}, - "source": [ - "## Dependencies\n", - "\n", - "It's good practice to split up large blocks of code into smaller meaningful units. This also holds for modeling code. To enable this, `Model` constructors accept an array of model dependencies. Each of these dependencies will automatically be included in the dependent's specification." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "b3116ff9", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class Environment(om.Model):\n", - " \"\"\"Parent model\"\"\"\n", - " epochs = om.Dimension()\n", - " active = om.Variable.indicator(epochs)\n", - " \n", - "class Activity(om.Model):\n", - " \"\"\"Child model\"\"\"\n", - " \n", - " def __init__(self, environment):\n", - " super().__init__(dependencies=[environment]) # Note the dependency\n", - " self._environment = environment\n", - " \n", - " @om.objective\n", - " def maximize_active_duration(self):\n", - " return self._environment.active.total()\n", - "\n", - "activity = Activity(Environment())" - ] - }, - { - "cell_type": "markdown", - "id": "b55b99a7", - "metadata": {}, - "source": [ - "The child's specification will include all of its (potentially transitive) dependencies, organized by model:\n", - "\n", - "
\n", - " ⓘ In Jupyter notebooks, individual model specifications can be collapsed by clicking on the model's name.\n", - "
" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "544a9af5", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "001ead62", + "metadata": {}, + "source": [ + "# Managing modeling complexity\n", + "\n", + "In this notebook we highlight a few features of `opvious`'s [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html) which help build and maintain large models." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "531c55c1", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "802d4cb9", + "metadata": {}, + "source": [ + "## Dependencies\n", + "\n", + "It's good practice to split up large blocks of code into smaller meaningful units. This also holds for modeling code. To enable this, `Model` constructors accept an array of model dependencies. Each of these dependencies will automatically be included in the dependent's specification." + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "Activity\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^o_\\mathrm{maximizeActiveDuration}&: \\max \\sum_{e \\in E} \\alpha_{e} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "\n", - "---\n", - "\n", - "
\n", - "Environment\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{epochs}&: E \\\\\n", - " \\S^v_\\mathrm{active}&: \\alpha \\in \\{0, 1\\}^{E} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "code", + "execution_count": 2, + "id": "b3116ff9", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious.modeling as om\n", + "\n", + "class Environment(om.Model):\n", + " \"\"\"Parent model\"\"\"\n", + " epochs = om.Dimension()\n", + " active = om.Variable.indicator(epochs)\n", + " \n", + "class Activity(om.Model):\n", + " \"\"\"Child model\"\"\"\n", + " \n", + " def __init__(self, environment):\n", + " super().__init__(dependencies=[environment]) # Note the dependency\n", + " self._environment = environment\n", + " \n", + " @om.objective\n", + " def maximize_active_duration(self):\n", + " return self._environment.active.total()\n", + "\n", + "activity = Activity(Environment())" + ] + }, + { + "cell_type": "markdown", + "id": "b55b99a7", + "metadata": {}, + "source": [ + "The child's specification will include all of its (potentially transitive) dependencies, organized by model:\n", + "\n", + "
\n", + " ⓘ In Jupyter notebooks, individual model specifications can be collapsed by clicking on the model's name.\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "544a9af5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nActivity\n
\n$$\n\\begin{align*}\n \\S^o_\\mathrm{maximizeActiveDuration}&: \\max \\sum_{e \\in E} \\alpha_{e} \\\\\n\\end{align*}\n$$\n
\n
\n\n---\n\n
\nEnvironment\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{epochs}&: E \\\\\n \\S^v_\\mathrm{active}&: \\alpha \\in \\{0, 1\\}^{E} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^o_\\\\mathrm{maximizeActiveDuration}&: \\\\max \\\\sum_{e \\\\in E} \\\\alpha_{e} \\\\\\\\\\n\\\\end{align*}\\n$$', title='Activity'), LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{epochs}&: E \\\\\\\\\\n \\\\S^v_\\\\mathrm{active}&: \\\\alpha \\\\in \\\\{0, 1\\\\}^{E} \\\\\\\\\\n\\\\end{align*}\\n$$', title='Environment')], description='Child model', annotation=None)" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^o_\\\\mathrm{maximizeActiveDuration}&: \\\\max \\\\sum_{e \\\\in E} \\\\alpha_{e} \\\\\\\\\\n\\\\end{align*}\\n$$', title='Activity'), LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{epochs}&: E \\\\\\\\\\n \\\\S^v_\\\\mathrm{active}&: \\\\alpha \\\\in \\\\{0, 1\\\\}^{E} \\\\\\\\\\n\\\\end{align*}\\n$$', title='Environment')], description='Child model', annotation=None)" + "source": [ + "activity.specification()" ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "activity.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "9f488256", - "metadata": {}, - "source": [ - "`definition_counts` will also break down the definitions by model:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "08f3fc80", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
categoryDIMENSIONOBJECTIVEVARIABLE
title
Activity010
Environment101
\n", - "
" + "cell_type": "markdown", + "id": "9f488256", + "metadata": {}, + "source": [ + "`definition_counts` will also break down the definitions by model:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "08f3fc80", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
categoryDIMENSIONOBJECTIVEVARIABLE
title
Activity010
Environment101
\n
", + "text/plain": "category DIMENSION OBJECTIVE VARIABLE\ntitle \nActivity 0 1 0\nEnvironment 1 0 1" + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "category DIMENSION OBJECTIVE VARIABLE\n", - "title \n", - "Activity 0 1 0\n", - "Environment 1 0 1" + "source": [ + "activity.definition_counts()" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "activity.definition_counts()" - ] - }, - { - "cell_type": "markdown", - "id": "3857d53d", - "metadata": {}, - "source": [ - "## Annotations\n", - "\n", - "Calling a model's `specification` method performs basic validation when generating its output. Not all errors are detected however, for example mismatched indices would not be caught here but raise an exception later when solving.\n", - "\n", - "Can you swap the error(s) in the model below?" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "f85c3b19", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "3857d53d", + "metadata": {}, + "source": [ + "## Annotations\n", + "\n", + "Calling a model's `specification` method performs basic validation when generating its output. Not all errors are detected however, for example mismatched indices would not be caught here but raise an exception later when solving.\n", + "\n", + "Can you swap the error(s) in the model below?" + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "Invalid\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{products}&: P \\\\\n", - " \\S^d_\\mathrm{stores}&: S \\\\\n", - " \\S^p_\\mathrm{inventory}&: i \\in \\mathbb{R}_+^{P} \\\\\n", - " \\S^v_\\mathrm{shipment}&: \\sigma \\in \\mathbb{N}^{P \\times S} \\\\\n", - " \\S^c_\\mathrm{shipmentsWithinInventory}&: \\forall p \\in P, \\sum_{s \\in S} \\sigma_{s,p} \\leq i \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "code", + "execution_count": 5, + "id": "f85c3b19", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nInvalid\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{products}&: P \\\\\n \\S^d_\\mathrm{stores}&: S \\\\\n \\S^p_\\mathrm{inventory}&: i \\in \\mathbb{R}_+^{P} \\\\\n \\S^v_\\mathrm{shipment}&: \\sigma \\in \\mathbb{N}^{P \\times S} \\\\\n \\S^c_\\mathrm{shipmentsWithinInventory}&: \\forall p \\in P, \\sum_{s \\in S} \\sigma_{s,p} \\leq i \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{products}&: P \\\\\\\\\\n \\\\S^d_\\\\mathrm{stores}&: S \\\\\\\\\\n \\\\S^p_\\\\mathrm{inventory}&: i \\\\in \\\\mathbb{R}_+^{P} \\\\\\\\\\n \\\\S^v_\\\\mathrm{shipment}&: \\\\sigma \\\\in \\\\mathbb{N}^{P \\\\times S} \\\\\\\\\\n \\\\S^c_\\\\mathrm{shipmentsWithinInventory}&: \\\\forall p \\\\in P, \\\\sum_{s \\\\in S} \\\\sigma_{s,p} \\\\leq i \\\\\\\\\\n\\\\end{align*}\\n$$', title='Invalid')], description=None, annotation=None)" + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{products}&: P \\\\\\\\\\n \\\\S^d_\\\\mathrm{stores}&: S \\\\\\\\\\n \\\\S^p_\\\\mathrm{inventory}&: i \\\\in \\\\mathbb{R}_+^{P} \\\\\\\\\\n \\\\S^v_\\\\mathrm{shipment}&: \\\\sigma \\\\in \\\\mathbb{N}^{P \\\\times S} \\\\\\\\\\n \\\\S^c_\\\\mathrm{shipmentsWithinInventory}&: \\\\forall p \\\\in P, \\\\sum_{s \\\\in S} \\\\sigma_{s,p} \\\\leq i \\\\\\\\\\n\\\\end{align*}\\n$$', title='Invalid')], description=None, annotation=None)" + "source": [ + "class Invalid(om.Model):\n", + " products = om.Dimension()\n", + " stores = om.Dimension()\n", + " inventory = om.Parameter.non_negative(products)\n", + " shipment = om.Variable.natural(products, stores)\n", + " \n", + " @om.constraint\n", + " def shipments_within_inventory(self):\n", + " for p in self.products:\n", + " yield om.total(self.shipment(s, p) for s in self.stores) <= self.inventory()\n", + "\n", + "Invalid().specification()" ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "class Invalid(om.Model):\n", - " products = om.Dimension()\n", - " stores = om.Dimension()\n", - " inventory = om.Parameter.non_negative(products)\n", - " shipment = om.Variable.natural(products, stores)\n", - " \n", - " @om.constraint\n", - " def shipments_within_inventory(self):\n", - " for p in self.products:\n", - " yield om.total(self.shipment(s, p) for s in self.stores) <= self.inventory()\n", - "\n", - "Invalid().specification()" - ] - }, - { - "cell_type": "markdown", - "id": "60a9ca2c", - "metadata": {}, - "source": [ - "Fortunately we can always use a client's [`annotate_specification`](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.Client.annotate_specification) method to discover all inconsistencies within a specification: mismatched indices, unexpected degree (e.g. quadratic expressions within a constraint), numeric operations on non-numeric terms, and much more. Any errors will be highlighted in orange in the specification and indicated by a warning symbol next to the model's name.\n", - "\n", - "Let's try it on the specification above." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "ffd407bd", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "60a9ca2c", + "metadata": {}, + "source": [ + "Fortunately we can always use a client's [`annotate_specification`](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.Client.annotate_specification) method to discover all inconsistencies within a specification: mismatched indices, unexpected degree (e.g. quadratic expressions within a constraint), numeric operations on non-numeric terms, and much more. Any errors will be highlighted in orange in the specification and indicated by a warning symbol next to the model's name.\n", + "\n", + "Let's try it on the specification above." + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "Invalid ⚠\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{products}&: P \\\\\n", - " \\S^d_\\mathrm{stores}&: S \\\\\n", - " \\S^p_\\mathrm{inventory}&: i \\in \\mathbb{R}_+^{P} \\\\\n", - " \\S^v_\\mathrm{shipment}&: \\sigma \\in \\mathbb{N}^{P \\times S} \\\\\n", - " \\S^c_\\mathrm{shipmentsWithinInventory}&: \\forall p \\in P, \\sum_{s \\in S} \\sigma_{\\color{orange}{s},\\color{orange}{p}} \\leq \\color{orange}{i} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "code", + "execution_count": 6, + "id": "ffd407bd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nInvalid ⚠\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{products}&: P \\\\\n \\S^d_\\mathrm{stores}&: S \\\\\n \\S^p_\\mathrm{inventory}&: i \\in \\mathbb{R}_+^{P} \\\\\n \\S^v_\\mathrm{shipment}&: \\sigma \\in \\mathbb{N}^{P \\times S} \\\\\n \\S^c_\\mathrm{shipmentsWithinInventory}&: \\forall p \\in P, \\sum_{s \\in S} \\sigma_{\\color{orange}{s},\\color{orange}{p}} \\leq \\color{orange}{i} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{products}&: P \\\\\\\\\\n \\\\S^d_\\\\mathrm{stores}&: S \\\\\\\\\\n \\\\S^p_\\\\mathrm{inventory}&: i \\\\in \\\\mathbb{R}_+^{P} \\\\\\\\\\n \\\\S^v_\\\\mathrm{shipment}&: \\\\sigma \\\\in \\\\mathbb{N}^{P \\\\times S} \\\\\\\\\\n \\\\S^c_\\\\mathrm{shipmentsWithinInventory}&: \\\\forall p \\\\in P, \\\\sum_{s \\\\in S} \\\\sigma_{s,p} \\\\leq i \\\\\\\\\\n\\\\end{align*}\\n$$', title='Invalid')], description=None, annotation=LocalSpecificationAnnotation(issue_count=3, issues={0: [LocalSpecificationIssue(source_index=0, start_offset=282, end_offset=282, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'), LocalSpecificationIssue(source_index=0, start_offset=284, end_offset=284, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'), LocalSpecificationIssue(source_index=0, start_offset=292, end_offset=292, message='This subscript has size 0 which does not match the underlying space (of rank 1)', code='ERR_INCOMPATIBLE_SUBSCRIPT')]}))" + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{products}&: P \\\\\\\\\\n \\\\S^d_\\\\mathrm{stores}&: S \\\\\\\\\\n \\\\S^p_\\\\mathrm{inventory}&: i \\\\in \\\\mathbb{R}_+^{P} \\\\\\\\\\n \\\\S^v_\\\\mathrm{shipment}&: \\\\sigma \\\\in \\\\mathbb{N}^{P \\\\times S} \\\\\\\\\\n \\\\S^c_\\\\mathrm{shipmentsWithinInventory}&: \\\\forall p \\\\in P, \\\\sum_{s \\\\in S} \\\\sigma_{s,p} \\\\leq i \\\\\\\\\\n\\\\end{align*}\\n$$', title='Invalid')], description=None, annotation=LocalSpecificationAnnotation(issue_count=3, issues={0: [LocalSpecificationIssue(source_index=0, start_offset=282, end_offset=282, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'), LocalSpecificationIssue(source_index=0, start_offset=284, end_offset=284, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'), LocalSpecificationIssue(source_index=0, start_offset=292, end_offset=292, message='This subscript has size 0 which does not match the underlying space (of rank 1)', code='ERR_INCOMPATIBLE_SUBSCRIPT')]}))" + "source": [ + "import opvious\n", + "\n", + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "annotated = await client.annotate_specification(Invalid().specification())\n", + "annotated" ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import opvious\n", - "\n", - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "annotated = await client.annotate_specification(Invalid().specification())\n", - "annotated" - ] - }, - { - "cell_type": "markdown", - "id": "8d0e6a6e", - "metadata": {}, - "source": [ - "The 3 errors were correctly identified, all in the `shipmentsWithinInventory` constraint:\n", - "\n", - "* $\\sigma$'s indices both have incorrect types (they are swapped);\n", - "* $i$ is missing an index.\n", - "\n", - "Any errors are also available for programmatic use via the specification's `annotation` property:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "592dc3df", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "markdown", + "id": "8d0e6a6e", + "metadata": {}, + "source": [ + "The 3 errors were correctly identified, all in the `shipmentsWithinInventory` constraint:\n", + "\n", + "* $\\sigma$'s indices both have incorrect types (they are swapped);\n", + "* $i$ is missing an index.\n", + "\n", + "Any errors are also available for programmatic use via the specification's `annotation` property:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "592dc3df", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": "{0: [LocalSpecificationIssue(source_index=0, start_offset=282, end_offset=282, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'),\n LocalSpecificationIssue(source_index=0, start_offset=284, end_offset=284, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'),\n LocalSpecificationIssue(source_index=0, start_offset=292, end_offset=292, message='This subscript has size 0 which does not match the underlying space (of rank 1)', code='ERR_INCOMPATIBLE_SUBSCRIPT')]}" + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "annotated.annotation.issues" + ] + }, { - "data": { - "text/plain": [ - "{0: [LocalSpecificationIssue(source_index=0, start_offset=282, end_offset=282, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'),\n", - " LocalSpecificationIssue(source_index=0, start_offset=284, end_offset=284, message='This expression is not compatible with its context; please check that dimensions and numericity match', code='ERR_INCOMPATIBLE_VALUE'),\n", - " LocalSpecificationIssue(source_index=0, start_offset=292, end_offset=292, message='This subscript has size 0 which does not match the underlying space (of rank 1)', code='ERR_INCOMPATIBLE_SUBSCRIPT')]}" + "cell_type": "markdown", + "id": "bebd2cc4", + "metadata": {}, + "source": [ + "## Fragments\n", + "\n", + "Coming soon." ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "6b2e7b2b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "annotated.annotation.issues" - ] - }, - { - "cell_type": "markdown", - "id": "bebd2cc4", - "metadata": {}, - "source": [ - "## Fragments\n", - "\n", - "Coming soon." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "6b2e7b2b", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/guides/uploading-a-model.ipynb b/resources/guides/uploading-a-model.ipynb index 241c55d..cd8859f 100644 --- a/resources/guides/uploading-a-model.ipynb +++ b/resources/guides/uploading-a-model.ipynb @@ -1,270 +1,266 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "b1e45002-aa7d-44f8-ae0e-aeb33d2e9eab", - "metadata": {}, - "source": [ - "## Uploading a model\n", - "\n", - "
\n", - " ⓘ The code in this notebook can be executed directly from your browser. You will need an Opvious account.\n", - "
\n", - "\n", - "In this notebook we show how to upload a model such that it can be solved without access to the original specification. This approach is useful in automated environments and production, particularly in combination with version tags." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "dd3e2c07-1f43-4ad1-809b-badc3f7c8a66", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "a8167f57-87d4-4f88-99d7-837f51a5e7af", - "metadata": {}, - "source": [ - "## Formulation\n", - "\n", - "We will use the [bin-packing problem](https://www.opvious.io/notebooks/retro/notebooks/?path=examples/bin-packing.ipynb) as example and use the same formulation." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "de9a68f2-7e9a-4def-b479-6c5f32981024", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class BinPacking(om.Model):\n", - " \"\"\"Bin-packing MIP formulation\"\"\"\n", - " \n", - " items = om.Dimension() # Set of items to be put into bins\n", - " weight = om.Parameter.non_negative(items) # Weight of each item\n", - " bins = om.interval(1, om.size(items), name=\"B\") # Set of bins\n", - " max_weight = om.Parameter.non_negative() # Maximum weight allowed in a bin\n", - " assigned = om.Variable.indicator(bins, items, qualifiers=['bins']) # 1 if an item is assigned to a given bin, 0 otherwise\n", - " used = om.Variable.indicator(bins) # 1 if a bin is used, 0 otherwise\n", - "\n", - " @om.constraint\n", - " def each_item_is_assigned_once(self):\n", - " \"\"\"Constrains each item to be assigned to exactly one bin\"\"\"\n", - " for i in self.items:\n", - " yield om.total(self.assigned(b, i) for b in self.bins) == 1\n", - "\n", - " @om.constraint\n", - " def bin_weights_are_below_max(self):\n", - " \"\"\"Constrains each bin's total weight to be below the maximum allowed\"\"\"\n", - " for b in self.bins:\n", - " bin_weight = om.total(self.weight(i) * self.assigned(b, i) for i in self.items)\n", - " yield bin_weight <= self.used(b) * self.max_weight()\n", - "\n", - " @om.objective\n", - " def minimize_bins_used(self):\n", - " \"\"\"Minimizes the total number of bins with at least one item\"\"\"\n", - " return om.total(self.used(b) for b in self.bins)\n", - "\n", - "model = BinPacking()" - ] - }, - { - "cell_type": "markdown", - "id": "9270a9d3-acbb-4574-992b-35260c7f39a0", - "metadata": {}, - "source": [ - "## Application\n", - "\n", - "Instead of optimizing directly from the model as in the original notebook, we will first upload it. This requires two main pieces:\n", - "\n", - "+ An API token, from which to generate an authenticated client.\n", - "+ A name for the uploaded formulation, which later be used to refer to it when optimizing." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "699cb44d-5fb9-4f3c-a737-104a6e3af89a", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "FORMULATION_NAME = \"bin-packing\"\n", - "\n", - "async def upload_model(version_tag=None):\n", - " \"\"\"Saves the bin-packing model so that it can be solved just from the formulation name\n", - "\n", - " Args:\n", - " version_tag: Optional versioning tag used to target specific model versions.\n", - " \"\"\"\n", - " await client.register_specification(model.specification(), FORMULATION_NAME, tag_names=[version_tag] if version_tag else None)\n", - "\n", - "await upload_model()" - ] - }, - { - "cell_type": "markdown", - "id": "2df2c83c-946a-45a8-b61a-094e47201268", - "metadata": {}, - "source": [ - "We can now get solutions just with the formulation name. The code is very similar to the one in the original bin-packing example, we simply replaced the inline specification with a `FormulationSpecification`." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "308e053a-b931-45a4-adda-c37dec3bdf25", - "metadata": {}, - "outputs": [ + "cells": [ { - "data": { - "text/plain": [ - "[('heavy',), ('light', 'medium')]" + "cell_type": "markdown", + "id": "b1e45002-aa7d-44f8-ae0e-aeb33d2e9eab", + "metadata": {}, + "source": [ + "## Uploading a model\n", + "\n", + "
\n", + " ⓘ The code in this notebook can be executed directly from your browser. You will need an Opvious account.\n", + "
\n", + "\n", + "In this notebook we show how to upload a model such that it can be solved without access to the original specification. This approach is useful in automated environments and production, particularly in combination with version tags." ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "async def optimal_assignment(bin_max_weight, item_weights, version_tag=None):\n", - " \"\"\"Returns a grouping of items which minimizes the number of bins used\n", - " \n", - " Args:\n", - " bin_max_weight: The maximum allowable total weight for all items assigned to a given bin\n", - " item_weights: Mapping from item name to its (non-negative) weight\n", - " version_tag: Model version tag\n", - " \"\"\"\n", - " problem = opvious.Problem(\n", - " specification=opvious.FormulationSpecification(FORMULATION_NAME), # Note the formulation reference\n", - " parameters={'weight': item_weights, 'maxWeight': bin_max_weight},\n", - " )\n", - " solution = await client.solve(problem)\n", - " assignment = solution.outputs.variable('assigned')\n", - " return list(assignment.reset_index().groupby('bins')['items'].agg(tuple))\n", - "\n", - "await optimal_assignment(15, {\n", - " 'light': 5,\n", - " 'medium': 10,\n", - " 'heavy': 15,\n", - "})" - ] - }, - { - "cell_type": "markdown", - "id": "ddb53774-b219-4a22-8d80-8f85224bc6e5", - "metadata": {}, - "source": [ - "We can also make requests without the SDK: under the hood everything goes through the same API (see its OpenAPI specification [here](https://api.try.opvious.io/openapi.yaml)). To show how, we implement below a function which returns the minimum number of bins needed to fit the input items (our model's objective value) using the popular `requests` library." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "0b6b5530-1c0d-42e8-b985-7a7c5d5d8f35", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install requests" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "171d4c7e-5b5e-4d02-8dc0-b4165c600da8", - "metadata": {}, - "outputs": [ + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "dd3e2c07-1f43-4ad1-809b-badc3f7c8a66", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "a8167f57-87d4-4f88-99d7-837f51a5e7af", + "metadata": {}, + "source": [ + "## Formulation\n", + "\n", + "We will use the [bin-packing problem](https://www.opvious.io/notebooks/retro/notebooks/?path=examples/bin-packing.ipynb) as example and use the same formulation." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "de9a68f2-7e9a-4def-b479-6c5f32981024", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious.modeling as om\n", + "\n", + "class BinPacking(om.Model):\n", + " \"\"\"Bin-packing MIP formulation\"\"\"\n", + " \n", + " items = om.Dimension() # Set of items to be put into bins\n", + " weight = om.Parameter.non_negative(items) # Weight of each item\n", + " bins = om.interval(1, om.size(items), name=\"B\") # Set of bins\n", + " max_weight = om.Parameter.non_negative() # Maximum weight allowed in a bin\n", + " assigned = om.Variable.indicator(bins, items, qualifiers=['bins']) # 1 if an item is assigned to a given bin, 0 otherwise\n", + " used = om.Variable.indicator(bins) # 1 if a bin is used, 0 otherwise\n", + "\n", + " @om.constraint\n", + " def each_item_is_assigned_once(self):\n", + " \"\"\"Constrains each item to be assigned to exactly one bin\"\"\"\n", + " for i in self.items:\n", + " yield om.total(self.assigned(b, i) for b in self.bins) == 1\n", + "\n", + " @om.constraint\n", + " def bin_weights_are_below_max(self):\n", + " \"\"\"Constrains each bin's total weight to be below the maximum allowed\"\"\"\n", + " for b in self.bins:\n", + " bin_weight = om.total(self.weight(i) * self.assigned(b, i) for i in self.items)\n", + " yield bin_weight <= self.used(b) * self.max_weight()\n", + "\n", + " @om.objective\n", + " def minimize_bins_used(self):\n", + " \"\"\"Minimizes the total number of bins with at least one item\"\"\"\n", + " return om.total(self.used(b) for b in self.bins)\n", + "\n", + "model = BinPacking()" + ] + }, + { + "cell_type": "markdown", + "id": "9270a9d3-acbb-4574-992b-35260c7f39a0", + "metadata": {}, + "source": [ + "## Application\n", + "\n", + "Instead of optimizing directly from the model as in the original notebook, we will first upload it. This requires two main pieces:\n", + "\n", + "+ An API token, from which to generate an authenticated client.\n", + "+ A name for the uploaded formulation, which later be used to refer to it when optimizing." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "699cb44d-5fb9-4f3c-a737-104a6e3af89a", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "FORMULATION_NAME = \"bin-packing\"\n", + "\n", + "async def upload_model(version_tag=None):\n", + " \"\"\"Saves the bin-packing model so that it can be solved just from the formulation name\n", + "\n", + " Args:\n", + " version_tag: Optional versioning tag used to target specific model versions.\n", + " \"\"\"\n", + " await client.register_specification(model.specification(), FORMULATION_NAME, tag_names=[version_tag] if version_tag else None)\n", + "\n", + "await upload_model()" + ] + }, + { + "cell_type": "markdown", + "id": "2df2c83c-946a-45a8-b61a-094e47201268", + "metadata": {}, + "source": [ + "We can now get solutions just with the formulation name. The code is very similar to the one in the original bin-packing example, we simply replaced the inline specification with a `FormulationSpecification`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "308e053a-b931-45a4-adda-c37dec3bdf25", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": "[('heavy',), ('light', 'medium')]" + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "async def optimal_assignment(bin_max_weight, item_weights, version_tag=None):\n", + " \"\"\"Returns a grouping of items which minimizes the number of bins used\n", + " \n", + " Args:\n", + " bin_max_weight: The maximum allowable total weight for all items assigned to a given bin\n", + " item_weights: Mapping from item name to its (non-negative) weight\n", + " version_tag: Model version tag\n", + " \"\"\"\n", + " problem = opvious.Problem(\n", + " specification=opvious.FormulationSpecification(FORMULATION_NAME), # Note the formulation reference\n", + " parameters={'weight': item_weights, 'maxWeight': bin_max_weight},\n", + " )\n", + " solution = await client.solve(problem)\n", + " assignment = solution.outputs.variable('assigned')\n", + " return list(assignment.reset_index().groupby('bins')['items'].agg(tuple))\n", + "\n", + "await optimal_assignment(15, {\n", + " 'light': 5,\n", + " 'medium': 10,\n", + " 'heavy': 15,\n", + "})" + ] + }, + { + "cell_type": "markdown", + "id": "ddb53774-b219-4a22-8d80-8f85224bc6e5", + "metadata": {}, + "source": [ + "We can also make requests without the SDK: under the hood everything goes through the same API (see its OpenAPI specification [here](https://api.try.opvious.io/openapi.yaml)). To show how, we implement below a function which returns the minimum number of bins needed to fit the input items (our model's objective value) using the popular `requests` library." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0b6b5530-1c0d-42e8-b985-7a7c5d5d8f35", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install requests" + ] + }, { - "data": { - "text/plain": [ - "2" + "cell_type": "code", + "execution_count": 6, + "id": "171d4c7e-5b5e-4d02-8dc0-b4165c600da8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": "2" + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "import requests\n", + "\n", + "_token = os.environ.get('OPVIOUS_TOKEN')\n", + "\n", + "def minimum_bin_count(bin_max_weight, item_weights, version_tag='latest'):\n", + " \"\"\"Returns the minimum number of bins needed to fit the input items\n", + "\n", + " Args:\n", + " bin_max_weight: The maximum allowable total weight for all items assigned to a given bin\n", + " item_weights: Mapping from item name to its (non-negative) weight\n", + " version_tag: Model version tag\n", + " \"\"\"\n", + " response = requests.post(\n", + " url=f'{client.executor.endpoint}/solve',\n", + " headers={\n", + " 'accept': 'application/json',\n", + " 'authorization': f'Bearer {_token}',\n", + " },\n", + " json={\n", + " 'problem': {\n", + " 'formulation': {'name': FORMULATION_NAME, 'specificationTagName': version_tag},\n", + " 'inputs': {\n", + " 'parameters': [\n", + " {'label': 'maxWeight', 'entries': [{'key': [], 'value': bin_max_weight}]},\n", + " {'label': 'weight', 'entries': [{'key': k, 'value': v} for k, v in item_weights.items()]},\n", + " ]\n", + " }\n", + " }\n", + " }\n", + " )\n", + " return response.json()['outcome']['objectiveValue']\n", + "\n", + "if _token:\n", + " minimum_bin_count(15, {\n", + " 'light': 5,\n", + " 'medium': 10,\n", + " 'heavy': 15,\n", + " })" ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "dc047f77-7b0f-466e-87e1-a9c361a76f0e", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "import os\n", - "import requests\n", - "\n", - "_token = os.environ.get('OPVIOUS_TOKEN')\n", - "\n", - "def minimum_bin_count(bin_max_weight, item_weights, version_tag='latest'):\n", - " \"\"\"Returns the minimum number of bins needed to fit the input items\n", - "\n", - " Args:\n", - " bin_max_weight: The maximum allowable total weight for all items assigned to a given bin\n", - " item_weights: Mapping from item name to its (non-negative) weight\n", - " version_tag: Model version tag\n", - " \"\"\"\n", - " response = requests.post(\n", - " url=f'{client.executor.endpoint}/solve',\n", - " headers={\n", - " 'accept': 'application/json',\n", - " 'authorization': f'Bearer {_token}',\n", - " },\n", - " json={\n", - " 'problem': {\n", - " 'formulation': {'name': FORMULATION_NAME, 'specificationTagName': version_tag},\n", - " 'inputs': {\n", - " 'parameters': [\n", - " {'label': 'maxWeight', 'entries': [{'key': [], 'value': bin_max_weight}]},\n", - " {'label': 'weight', 'entries': [{'key': k, 'value': v} for k, v in item_weights.items()]},\n", - " ]\n", - " }\n", - " }\n", - " }\n", - " )\n", - " return response.json()['outcome']['objectiveValue']\n", - "\n", - "if _token:\n", - " minimum_bin_count(15, {\n", - " 'light': 5,\n", - " 'medium': 10,\n", - " 'heavy': 15,\n", - " })" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "dc047f77-7b0f-466e-87e1-a9c361a76f0e", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/guides/using-a-self-hosted-api-server.ipynb b/resources/guides/using-a-self-hosted-api-server.ipynb index 97400c3..33a2e43 100644 --- a/resources/guides/using-a-self-hosted-api-server.ipynb +++ b/resources/guides/using-a-self-hosted-api-server.ipynb @@ -1,102 +1,102 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "ae17559e", - "metadata": {}, - "source": [ - "# Using a self-hosted API server\n", - "\n", - "In this notebook we go over the steps required to set up and connect to a self-hosted [API server](https://hub.docker.com/r/opvious/api-server), allowing you to host your own Opvious platform.\n", - "\n", - "\n", - "## Starting the server\n", - "\n", - "### With the CLI\n", - "\n", - "The simplest way to download and run the API server is with the [Opvious CLI](https://www.npmjs.com/package/opvious-cli#starting-an-api-server). The CLI will take care of setting up the server's dependencies automatically before starting it:\n", - "\n", - "```sh\n", - "npm install -g opvious-cli # Install the CLI\n", - "opvious api start # Start the API server along with its dependencies\n", - "```\n", - "\n", - "Refer to the CLI's README or run `opvious api --help` to view the list of available commands. You may also be interested in the server's telemetry configuration options described [here](https://hub.docker.com/r/opvious/api-server).\n", - "\n", - "\n", - "### From the Docker image\n", - "\n", - "Alternatively, you can download and run the [`opvious/api-server`](https://hub.docker.com/r/opvious/api-server) Docker image directly. This allows you to use it with your own database and/or cache:\n", - "\n", - "```\n", - "docker run -p 8080:8080 \\\n", - " -e DB_URL=postgres:// \\\n", - " -e REDIS_URL=redis:// \\\n", - " -e OPVIOUS_API_IMAGE_EULA \\\n", - " opvious/api-server\n", - "```\n", - "\n", - "Refer to the image's documentation for more information.\n", - "\n", - "\n", - "## Connecting to the server\n", - "\n", - "Once the API server is running, the next step is to use it from the SDKs. To do so, simply set the `OPVIOUS_ENDPOINT` environment variable to the server's endpoint (http://localhost:8080 if started via the CLI with default options). For example from your Bash configuration:\n", - "\n", - "```\n", - "# ~/.bashrc\n", - "OPVIOUS_ENDPOINT=http://localhost:8080\n", - "```\n", - "\n", - "You may also find it useful to create a [dedicated configuration profile](https://www.npmjs.com/package/opvious-cli#configuration-profiles) pointing to it:\n", - "\n", - "```\n", - "# ~/.config/opvious/cli.yml\n", - "profiles:\n", - " - name: local\n", - " endpoint: http://localhost:8080\n", - "```\n", - "\n", - "\n", - "## Authenticating requests\n", - "\n", - "The server's `STATIC_TOKENS` environment variable is used to specify a comma-separated list of static API tokens for authenticating API requests. Each entry's format is `=`, where `` is the associated account's email. When using the CLI, this variable is set with the `-t` option:\n", - "\n", - "```sh\n", - "opvious api start -t user@example.com=secret-token\n", - "```\n", - "\n", - "These tokens can then be used as regular API tokens in SDKs by prefixing them with `static:`. For example requests to the server started with the command just above can be authenticated as `user@example.com` by setting `OPVIOUS_TOKEN=static:secret-token`." - ] + "cells": [ + { + "cell_type": "markdown", + "id": "ae17559e", + "metadata": {}, + "source": [ + "# Using a self-hosted API server\n", + "\n", + "In this notebook we go over the steps required to set up and connect to a self-hosted [API server](https://hub.docker.com/r/opvious/api-server), allowing you to host your own Opvious platform.\n", + "\n", + "\n", + "## Starting the server\n", + "\n", + "### With the CLI\n", + "\n", + "The simplest way to download and run the API server is with the [Opvious CLI](https://www.npmjs.com/package/opvious-cli#starting-an-api-server). The CLI will take care of setting up the server's dependencies automatically before starting it:\n", + "\n", + "```sh\n", + "npm install -g opvious-cli # Install the CLI\n", + "opvious api start # Start the API server along with its dependencies\n", + "```\n", + "\n", + "Refer to the CLI's README or run `opvious api --help` to view the list of available commands. You may also be interested in the server's telemetry configuration options described [here](https://hub.docker.com/r/opvious/api-server).\n", + "\n", + "\n", + "### From the Docker image\n", + "\n", + "Alternatively, you can download and run the [`opvious/api-server`](https://hub.docker.com/r/opvious/api-server) Docker image directly. This allows you to use it with your own database and/or cache:\n", + "\n", + "```\n", + "docker run -p 8080:8080 \\\n", + " -e DB_URL=postgres:// \\\n", + " -e REDIS_URL=redis:// \\\n", + " -e OPVIOUS_API_IMAGE_EULA \\\n", + " opvious/api-server\n", + "```\n", + "\n", + "Refer to the image's documentation for more information.\n", + "\n", + "\n", + "## Connecting to the server\n", + "\n", + "Once the API server is running, the next step is to use it from the SDKs. To do so, simply set the `OPVIOUS_ENDPOINT` environment variable to the server's endpoint (http://localhost:8080 if started via the CLI with default options). For example from your Bash configuration:\n", + "\n", + "```\n", + "# ~/.bashrc\n", + "OPVIOUS_ENDPOINT=http://localhost:8080\n", + "```\n", + "\n", + "You may also find it useful to create a [dedicated configuration profile](https://www.npmjs.com/package/opvious-cli#configuration-profiles) pointing to it:\n", + "\n", + "```\n", + "# ~/.config/opvious/cli.yml\n", + "profiles:\n", + " - name: local\n", + " endpoint: http://localhost:8080\n", + "```\n", + "\n", + "\n", + "## Authenticating requests\n", + "\n", + "The server's `STATIC_TOKENS` environment variable is used to specify a comma-separated list of static API tokens for authenticating API requests. Each entry's format is `=`, where `` is the associated account's email. When using the CLI, this variable is set with the `-t` option:\n", + "\n", + "```sh\n", + "opvious api start -t user@example.com=secret-token\n", + "```\n", + "\n", + "These tokens can then be used as regular API tokens in SDKs by prefixing them with `static:`. For example requests to the server started with the command just above can be authenticated as `user@example.com` by setting `OPVIOUS_TOKEN=static:secret-token`." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "75b0bdbb", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" + } }, - { - "cell_type": "code", - "execution_count": 1, - "id": "75b0bdbb", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/guides/welcome.ipynb b/resources/guides/welcome.ipynb index 4d10b77..b3b8b70 100644 --- a/resources/guides/welcome.ipynb +++ b/resources/guides/welcome.ipynb @@ -1,400 +1,276 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "39078097", - "metadata": {}, - "source": [ - "# Welcome!\n", - "\n", - "In this notebook we introduce [Opvious](https://www.opvious.io), a _batteries-included optimization platform_, by walking through an end-to-end optimization example.\n", - "\n", - "
\n", - " ⓘ This example can be run directly from your browser when accessed via its opvious.io/notebooks URL. No Opvious account required.\n", - "
\n", - "\n", - "Let's imagine that we are tasked with allocating a budget between various projects. We are also given an expected cost and value for each project. Our goal is to __pick projects__ which __maximize total value__ while keeping __total cost within the budget__. It turns out that our task can be formulated naturally as an [integer programming problem](https://en.wikipedia.org/wiki/Integer_programming) (it is actually an instance of the [knapsack problem](https://en.wikipedia.org/wiki/Knapsack_problem)) which--unlike heuristics--will be guaranteed to give us an optimal allocation.\n", - "\n", - "To get started, we install the [Opvious Python SDK](https://opvious.readthedocs.io)." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "d5a782a8", - "metadata": {}, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Note: you may need to restart the kernel to use updated packages.\n" - ] - } - ], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "99c8d038", - "metadata": {}, - "source": [ - "## Formulating the problem\n", - "\n", - "We now define our model using the SDK's [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html) which generates mathematical specifications from readable Python code. At a high level, each model contains four types of components:\n", - "\n", - "* _Dimensions and parameters_, capturing the problem's inputs. Here, we have a single dimension (the list of available projects) and three parameters (the total budget, the cost of each project, and the value of each project).\n", - "* _Variables_, representing its outputs. We have a single output here, whether a project is selected or not (modeled as an indicator--0 or 1--variable).\n", - "* _Constraints_, enforcing invariants. Here we need to make sure that the sum of the selected projects' cost does not exceed the budget.\n", - "* _Objectives_, evaluating the quality of a solution. We have a single objective here: maximizing the sum of the selected projects' value.\n", - "\n", - "Models are always _abstract_: they exist independently of data and can be reused with different input values. This separation allows them to be easily validated, exported, and version-controlled." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "4ed1667e", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class BudgetAllocation(om.Model):\n", - " \"\"\"A simple model allocating a budget between various projects\"\"\"\n", - " \n", - " # Inputs\n", - " projects = om.Dimension() # Set of available projects\n", - " budget = om.Parameter.non_negative() # Total budget value\n", - " cost = om.Parameter.non_negative(projects) # Cost per project\n", - " value = om.Parameter.continuous(projects) # Value of each project\n", - " \n", - " # Output\n", - " selected = om.Variable.indicator(projects) # Whether a project is selected (1) or not (0)\n", - " \n", - " @om.constraint\n", - " def within_budget(self):\n", - " \"\"\"Ensure that the total (summed) cost of selected projects is less or equal to the budget\"\"\"\n", - " yield om.total(self.selected(p) * self.cost(p) for p in self.projects) <= self.budget()\n", - " \n", - " @om.objective\n", - " def maximize_value(self):\n", - " \"\"\"Maximize the total (summed) value of selected projects\"\"\"\n", - " return om.total(self.selected(p) * self.value(p) for p in self.projects)\n", - " \n", - "model = BudgetAllocation()" - ] - }, - { - "cell_type": "markdown", - "id": "7e68b370", - "metadata": {}, - "source": [ - "[`Model`](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.modeling.Model) instances expose various built-in methods. The most important one is `specification`, which automatically validates and exports its mathematical representation. This specification is integrated with IPython’s rich display capabilities, making it easy to review:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "06d04aff", - "metadata": {}, - "outputs": [ + "cell_type": "markdown", + "id": "39078097", + "metadata": {}, + "source": [ + "# Welcome!\n", + "\n", + "In this notebook we introduce [Opvious](https://www.opvious.io), a _batteries-included optimization platform_, by walking through an end-to-end optimization example.\n", + "\n", + "
\n", + " ⓘ This example can be run directly from your browser when accessed via its opvious.io/notebooks URL. No Opvious account required.\n", + "
\n", + "\n", + "Let's imagine that we are tasked with allocating a budget between various projects. We are also given an expected cost and value for each project. Our goal is to __pick projects__ which __maximize total value__ while keeping __total cost within the budget__. It turns out that our task can be formulated naturally as an [integer programming problem](https://en.wikipedia.org/wiki/Integer_programming) (it is actually an instance of the [knapsack problem](https://en.wikipedia.org/wiki/Knapsack_problem)) which--unlike heuristics--will be guaranteed to give us an optimal allocation.\n", + "\n", + "To get started, we install the [Opvious Python SDK](https://opvious.readthedocs.io)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d5a782a8", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, { - "data": { - "text/markdown": [ - "
\n", - "
\n", - "BudgetAllocation\n", - "
\n", - "$$\n", - "\\begin{align*}\n", - " \\S^d_\\mathrm{projects}&: P \\\\\n", - " \\S^p_\\mathrm{budget}&: b \\in \\mathbb{R}_+ \\\\\n", - " \\S^p_\\mathrm{cost}&: c \\in \\mathbb{R}_+^{P} \\\\\n", - " \\S^p_\\mathrm{value}&: v \\in \\mathbb{R}^{P} \\\\\n", - " \\S^v_\\mathrm{selected}&: \\sigma \\in \\{0, 1\\}^{P} \\\\\n", - " \\S^c_\\mathrm{withinBudget}&: \\sum_{p \\in P} \\sigma_{p} c_{p} \\leq b \\\\\n", - " \\S^o_\\mathrm{maximizeValue}&: \\max \\sum_{p \\in P} \\sigma_{p} v_{p} \\\\\n", - "\\end{align*}\n", - "$$\n", - "
\n", - "
\n", - "
" + "cell_type": "markdown", + "id": "99c8d038", + "metadata": {}, + "source": [ + "## Formulating the problem\n", + "\n", + "We now define our model using the SDK's [declarative modeling API](https://opvious.readthedocs.io/en/stable/modeling.html) which generates mathematical specifications from readable Python code. At a high level, each model contains four types of components:\n", + "\n", + "* _Dimensions and parameters_, capturing the problem's inputs. Here, we have a single dimension (the list of available projects) and three parameters (the total budget, the cost of each project, and the value of each project).\n", + "* _Variables_, representing its outputs. We have a single output here, whether a project is selected or not (modeled as an indicator--0 or 1--variable).\n", + "* _Constraints_, enforcing invariants. Here we need to make sure that the sum of the selected projects' cost does not exceed the budget.\n", + "* _Objectives_, evaluating the quality of a solution. We have a single objective here: maximizing the sum of the selected projects' value.\n", + "\n", + "Models are always _abstract_: they exist independently of data and can be reused with different input values. This separation allows them to be easily validated, exported, and version-controlled." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4ed1667e", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious.modeling as om\n", + "\n", + "class BudgetAllocation(om.Model):\n", + " \"\"\"A simple model allocating a budget between various projects\"\"\"\n", + " \n", + " # Inputs\n", + " projects = om.Dimension() # Set of available projects\n", + " budget = om.Parameter.non_negative() # Total budget value\n", + " cost = om.Parameter.non_negative(projects) # Cost per project\n", + " value = om.Parameter.continuous(projects) # Value of each project\n", + " \n", + " # Output\n", + " selected = om.Variable.indicator(projects) # Whether a project is selected (1) or not (0)\n", + " \n", + " @om.constraint\n", + " def within_budget(self):\n", + " \"\"\"Ensure that the total (summed) cost of selected projects is less or equal to the budget\"\"\"\n", + " yield om.total(self.selected(p) * self.cost(p) for p in self.projects) <= self.budget()\n", + " \n", + " @om.objective\n", + " def maximize_value(self):\n", + " \"\"\"Maximize the total (summed) value of selected projects\"\"\"\n", + " return om.total(self.selected(p) * self.value(p) for p in self.projects)\n", + " \n", + "model = BudgetAllocation()" + ] + }, + { + "cell_type": "markdown", + "id": "7e68b370", + "metadata": {}, + "source": [ + "[`Model`](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.modeling.Model) instances expose various built-in methods. The most important one is `specification`, which automatically validates and exports its mathematical representation. This specification is integrated with IPython\u2019s rich display capabilities, making it easy to review:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "06d04aff", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
\n
\nBudgetAllocation\n
\n$$\n\\begin{align*}\n \\S^d_\\mathrm{projects}&: P \\\\\n \\S^p_\\mathrm{budget}&: b \\in \\mathbb{R}_+ \\\\\n \\S^p_\\mathrm{cost}&: c \\in \\mathbb{R}_+^{P} \\\\\n \\S^p_\\mathrm{value}&: v \\in \\mathbb{R}^{P} \\\\\n \\S^v_\\mathrm{selected}&: \\sigma \\in \\{0, 1\\}^{P} \\\\\n \\S^c_\\mathrm{withinBudget}&: \\sum_{p \\in P} \\sigma_{p} c_{p} \\leq b \\\\\n \\S^o_\\mathrm{maximizeValue}&: \\max \\sum_{p \\in P} \\sigma_{p} v_{p} \\\\\n\\end{align*}\n$$\n
\n
\n
", + "text/plain": "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{projects}&: P \\\\\\\\\\n \\\\S^p_\\\\mathrm{budget}&: b \\\\in \\\\mathbb{R}_+ \\\\\\\\\\n \\\\S^p_\\\\mathrm{cost}&: c \\\\in \\\\mathbb{R}_+^{P} \\\\\\\\\\n \\\\S^p_\\\\mathrm{value}&: v \\\\in \\\\mathbb{R}^{P} \\\\\\\\\\n \\\\S^v_\\\\mathrm{selected}&: \\\\sigma \\\\in \\\\{0, 1\\\\}^{P} \\\\\\\\\\n \\\\S^c_\\\\mathrm{withinBudget}&: \\\\sum_{p \\\\in P} \\\\sigma_{p} c_{p} \\\\leq b \\\\\\\\\\n \\\\S^o_\\\\mathrm{maximizeValue}&: \\\\max \\\\sum_{p \\\\in P} \\\\sigma_{p} v_{p} \\\\\\\\\\n\\\\end{align*}\\n$$', title='BudgetAllocation')], description='A simple model allocating a budget between various projects', annotation=None)" + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[LocalSpecificationSource(text='$$\\n\\\\begin{align*}\\n \\\\S^d_\\\\mathrm{projects}&: P \\\\\\\\\\n \\\\S^p_\\\\mathrm{budget}&: b \\\\in \\\\mathbb{R}_+ \\\\\\\\\\n \\\\S^p_\\\\mathrm{cost}&: c \\\\in \\\\mathbb{R}_+^{P} \\\\\\\\\\n \\\\S^p_\\\\mathrm{value}&: v \\\\in \\\\mathbb{R}^{P} \\\\\\\\\\n \\\\S^v_\\\\mathrm{selected}&: \\\\sigma \\\\in \\\\{0, 1\\\\}^{P} \\\\\\\\\\n \\\\S^c_\\\\mathrm{withinBudget}&: \\\\sum_{p \\\\in P} \\\\sigma_{p} c_{p} \\\\leq b \\\\\\\\\\n \\\\S^o_\\\\mathrm{maximizeValue}&: \\\\max \\\\sum_{p \\\\in P} \\\\sigma_{p} v_{p} \\\\\\\\\\n\\\\end{align*}\\n$$', title='BudgetAllocation')], description='A simple model allocating a budget between various projects', annotation=None)" + "source": [ + "model.specification()" ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "model.specification()" - ] - }, - { - "cell_type": "markdown", - "id": "dd0f7cff", - "metadata": {}, - "source": [ - "## Finding a solution\n", - "\n", - "Once our model is instantiated, we are ready to add data and solve it!\n", - "\n", - "Combining a model with data is straightforward: we just provide a value for each parameter. In this simple example we provide them as a number (budget) and dictionaries (project cost and value) but many other formats are accepted, including `pandas` series." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "ad50096f", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "problem = opvious.Problem( # Sample problem instance with 3 projects\n", - " model.specification(),\n", - " parameters={\n", - " \"budget\": 100,\n", - " \"cost\": {\"p1\": 50, \"p2\": 45, \"p3\": 60},\n", - " \"value\": {\"p1\": 5, \"p2\": 6, \"p3\": 10},\n", - " },\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "0b21b08c", - "metadata": {}, - "source": [ - "Solves are handled remotely via [`Client`](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.Client) instances. Since our sample problem is small it can be solved without authentication, otherwise we would need to [configure our client accordingly](https://opvious.readthedocs.io/en/stable/overview.html#creating-a-client)." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "c1dd5859", - "metadata": {}, - "outputs": [ + }, { - "data": { - "text/plain": [ - "{'p1', 'p2'}" + "cell_type": "markdown", + "id": "dd0f7cff", + "metadata": {}, + "source": [ + "## Finding a solution\n", + "\n", + "Once our model is instantiated, we are ready to add data and solve it!\n", + "\n", + "Combining a model with data is straightforward: we just provide a value for each parameter. In this simple example we provide them as a number (budget) and dictionaries (project cost and value) but many other formats are accepted, including `pandas` series." ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "async def solve_problem(problem):\n", - " \"\"\"Returns an optimal set of projects for the provided budget allocation problem\"\"\"\n", - " solution = await client.solve(problem)\n", - " selected = solution.outputs.variable(\"selected\") # `pandas` dataframe containing optimal variable values\n", - " return set(selected.index) # Names of the selected projects\n", - "\n", - "# Let's try it!\n", - "await solve_problem(problem)" - ] - }, - { - "cell_type": "markdown", - "id": "7f674034", - "metadata": {}, - "source": [ - "The solution is what we expect: selecting `p1` and `p2` yields a total value of 11 (greater than `p3`'s 10), with a total cost of 95 (less than the budget of 100).\n", - "\n", - "## Digging deeper\n", - "\n", - "`solve` is just one of the many useful methods available on client instances. For example `format_problem` will return the problem’s fully annotated representation in [LP format](https://web.mit.edu/lpsolve/doc/CPLEX-format.htm). This can be used for quick sanity checks or as input to other optimization tools." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "71e1ca5d", - "metadata": {}, - "outputs": [ + }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "maximize\n", - " +5 selected$1 \\ [projects=p1]\n", - " +6 selected$2 \\ [projects=p2]\n", - " +10 selected$3 \\ [projects=p3]\n", - "subject to\n", - " withinBudget$1:\n", - " +50 selected$1 \\ [projects=p1]\n", - " +45 selected$2 \\ [projects=p2]\n", - " +60 selected$3 \\ [projects=p3]\n", - " <= +100\n", - "general\n", - " selected$1 \\ [projects=p1]\n", - " selected$2 \\ [projects=p2]\n", - " selected$3 \\ [projects=p3]\n", - "end\n", - "\n" - ] - } - ], - "source": [ - "print(await client.format_problem(problem))" - ] - }, - { - "cell_type": "markdown", - "id": "f2348b2e-64d4-4d7b-9f75-61656d1efa8c", - "metadata": {}, - "source": [ - "The client also exposes summary statistics about each solved problem, making it easy to spot potential issues (weight imbalances, performance bottlenecks, ...)." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "19f5d5ee-aa4b-4894-bb85-184d351d8a50", - "metadata": {}, - "outputs": [ + "cell_type": "code", + "execution_count": 4, + "id": "ad50096f", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "problem = opvious.Problem( # Sample problem instance with 3 projects\n", + " model.specification(),\n", + " parameters={\n", + " \"budget\": 100,\n", + " \"cost\": {\"p1\": 50, \"p2\": 45, \"p3\": 60},\n", + " \"value\": {\"p1\": 5, \"p2\": 6, \"p3\": 10},\n", + " },\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "0b21b08c", + "metadata": {}, + "source": [ + "Solves are handled remotely via [`Client`](https://opvious.readthedocs.io/en/stable/api-reference.html#opvious.Client) instances. Since our sample problem is small it can be solved without authentication, otherwise we would need to [configure our client accordingly](https://opvious.readthedocs.io/en/stable/overview.html#creating-a-client)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c1dd5859", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": "{'p1', 'p2'}" + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "async def solve_problem(problem):\n", + " \"\"\"Returns an optimal set of projects for the provided budget allocation problem\"\"\"\n", + " solution = await client.solve(problem)\n", + " selected = solution.outputs.variable(\"selected\") # `pandas` dataframe containing optimal variable values\n", + " return set(selected.index) # Names of the selected projects\n", + "\n", + "# Let's try it!\n", + "await solve_problem(problem)" + ] + }, + { + "cell_type": "markdown", + "id": "7f674034", + "metadata": {}, + "source": [ + "The solution is what we expect: selecting `p1` and `p2` yields a total value of 11 (greater than `p3`'s 10), with a total cost of 95 (less than the budget of 100).\n", + "\n", + "## Digging deeper\n", + "\n", + "`solve` is just one of the many useful methods available on client instances. For example `format_problem` will return the problem\u2019s fully annotated representation in [LP format](https://web.mit.edu/lpsolve/doc/CPLEX-format.htm). This can be used for quick sanity checks or as input to other optimization tools." + ] + }, { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
row_countrow_sprscolumn_countcolumn_sprsweight_countweight_minweight_maxweight_meanweight_stddevweight_sprsreify_ms
label
withinBudget10.030.03456051.6666677.6376260.01
\n", - "
" + "cell_type": "code", + "execution_count": 6, + "id": "71e1ca5d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": "maximize\n +5 selected$1 \\ [projects=p1]\n +6 selected$2 \\ [projects=p2]\n +10 selected$3 \\ [projects=p3]\nsubject to\n withinBudget$1:\n +50 selected$1 \\ [projects=p1]\n +45 selected$2 \\ [projects=p2]\n +60 selected$3 \\ [projects=p3]\n <= +100\ngeneral\n selected$1 \\ [projects=p1]\n selected$2 \\ [projects=p2]\n selected$3 \\ [projects=p3]\nend\n\n" + } ], - "text/plain": [ - " row_count row_sprs column_count column_sprs weight_count \\\n", - "label \n", - "withinBudget 1 0.0 3 0.0 3 \n", - "\n", - " weight_min weight_max weight_mean weight_stddev weight_sprs \\\n", - "label \n", - "withinBudget 45 60 51.666667 7.637626 0.0 \n", - "\n", - " reify_ms \n", - "label \n", - "withinBudget 1 " + "source": [ + "print(await client.format_problem(problem))" ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "markdown", + "id": "f2348b2e-64d4-4d7b-9f75-61656d1efa8c", + "metadata": {}, + "source": [ + "The client also exposes summary statistics about each solved problem, making it easy to spot potential issues (weight imbalances, performance bottlenecks, ...)." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "19f5d5ee-aa4b-4894-bb85-184d351d8a50", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
row_countrow_sprscolumn_countcolumn_sprsweight_countweight_minweight_maxweight_meanweight_stddevweight_sprsreify_ms
label
withinBudget10.030.03456051.6666677.6376260.01
\n
", + "text/plain": " row_count row_sprs column_count column_sprs weight_count \\\nlabel \nwithinBudget 1 0.0 3 0.0 3 \n\n weight_min weight_max weight_mean weight_stddev weight_sprs \\\nlabel \nwithinBudget 45 60 51.666667 7.637626 0.0 \n\n reify_ms \nlabel \nwithinBudget 1 " + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "summary = await client.summarize_problem(problem)\n", + "summary.constraints # Summary statistics about the problem's constraints (count, sparsity, weight distribution, ...)" + ] + }, + { + "cell_type": "markdown", + "id": "9c30a9c8", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "+ Browse our other interactive [guides and examples](https://www.opvious.io/notebooks/retro)\n", + "+ Create a (free) account via the [Optimization Hub](https://hub.cloud.opvious.io) to solve larger problems\n", + "+ Try the platform out locally with a [self-hosted API server](https://hub.docker.com/r/opvious/api-server)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "69697ecc", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "summary = await client.summarize_problem(problem)\n", - "summary.constraints # Summary statistics about the problem's constraints (count, sparsity, weight distribution, ...)" - ] - }, - { - "cell_type": "markdown", - "id": "9c30a9c8", - "metadata": {}, - "source": [ - "## Next steps\n", - "\n", - "+ Browse our other interactive [guides and examples](https://www.opvious.io/notebooks/retro)\n", - "+ Create a (free) account via the [Optimization Hub](https://hub.cloud.opvious.io) to solve larger problems\n", - "+ Try the platform out locally with a [self-hosted API server](https://hub.docker.com/r/opvious/api-server)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "69697ecc", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/resources/templates/simple.ipynb b/resources/templates/simple.ipynb index e1cc897..77dd663 100644 --- a/resources/templates/simple.ipynb +++ b/resources/templates/simple.ipynb @@ -1,124 +1,120 @@ { - "cells": [ - { - "cell_type": "markdown", - "id": "a27f1002-11a5-485a-b5ba-a86b8763ed0a", - "metadata": {}, - "source": [ - "# Simple model template\n", - "\n", - "Use this notebook as starting point for implementing simple optimization models with the [Python SDK](https://opvious.readthedocs.io/en/stable/index.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "206de643-14b8-45b9-91cb-0917ef42af85", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install opvious" - ] - }, - { - "cell_type": "markdown", - "id": "e174fc54-7a58-4fa1-815d-d6ddb4fe715e", - "metadata": {}, - "source": [ - "## Specification\n", - "\n", - "We first define the model using the [declarative API](https://opvious.readthedocs.io/en/stable/modeling.html)." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "598a2070-24da-47c0-babe-e88d5d201f63", - "metadata": {}, - "outputs": [ + "cells": [ + { + "cell_type": "markdown", + "id": "a27f1002-11a5-485a-b5ba-a86b8763ed0a", + "metadata": {}, + "source": [ + "# Simple model template\n", + "\n", + "Use this notebook as starting point for implementing simple optimization models with the [Python SDK](https://opvious.readthedocs.io/en/stable/index.html)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "206de643-14b8-45b9-91cb-0917ef42af85", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install opvious" + ] + }, + { + "cell_type": "markdown", + "id": "e174fc54-7a58-4fa1-815d-d6ddb4fe715e", + "metadata": {}, + "source": [ + "## Specification\n", + "\n", + "We first define the model using the [declarative API](https://opvious.readthedocs.io/en/stable/modeling.html)." + ] + }, { - "data": { - "text/markdown": [ - "
" + "cell_type": "code", + "execution_count": 2, + "id": "598a2070-24da-47c0-babe-e88d5d201f63", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": "
", + "text/plain": "LocalSpecification(sources=[], description=None, annotation=None)" + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "LocalSpecification(sources=[], description=None, annotation=None)" + "source": [ + "import opvious.modeling as om\n", + "\n", + "class Model(om.Model):\n", + " pass\n", + "\n", + "model = Model()\n", + "model.specification() # Renders the model's LaTeX definitions" ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" + }, + { + "cell_type": "markdown", + "id": "f02b2e0e-a300-4420-b283-65940b4a326c", + "metadata": {}, + "source": [ + "## Validation\n", + "\n", + "We can now use the [API's capabilities](https://opvious.readthedocs.io/en/stable/overview.html) to validate the model and run it on sample data." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "fe74722e-d100-4052-b6d0-b04dcbc04a38", + "metadata": {}, + "outputs": [], + "source": [ + "import opvious\n", + "\n", + "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", + "\n", + "def build_problem(): # Add model-specific arguments\n", + " \"\"\"Generates a problem instance from model-specific arguments\"\"\"\n", + " return opvious.Problem(\n", + " specification=model.specification(),\n", + " parameters={\n", + " # Add parameters from arguments\n", + " }\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "59461dca-f754-44dd-a2d2-a25c9bed0834", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" } - ], - "source": [ - "import opvious.modeling as om\n", - "\n", - "class Model(om.Model):\n", - " pass\n", - "\n", - "model = Model()\n", - "model.specification() # Renders the model's LaTeX definitions" - ] - }, - { - "cell_type": "markdown", - "id": "f02b2e0e-a300-4420-b283-65940b4a326c", - "metadata": {}, - "source": [ - "## Validation\n", - "\n", - "We can now use the [API's capabilities](https://opvious.readthedocs.io/en/stable/overview.html) to validate the model and run it on sample data." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "fe74722e-d100-4052-b6d0-b04dcbc04a38", - "metadata": {}, - "outputs": [], - "source": [ - "import opvious\n", - "\n", - "client = opvious.Client.from_environment(default_endpoint=opvious.DEMO_ENDPOINT)\n", - "\n", - "def build_problem(): # Add model-specific arguments\n", - " \"\"\"Generates a problem instance from model-specific arguments\"\"\"\n", - " return opvious.Problem(\n", - " specification=model.specification(),\n", - " parameters={\n", - " # Add parameters from arguments\n", - " }\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "59461dca-f754-44dd-a2d2-a25c9bed0834", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file