From 1ea989d48af0feb7e1d7735d1027a452b1a33b51 Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 8 Jun 2023 11:25:39 -0500 Subject: [PATCH] MIPLearn v0.3 --- .github/ISSUE_TEMPLATE/bug_report.md | 26 - .github/ISSUE_TEMPLATE/config.yml | 8 - .github/workflows/lint.yml | 11 - .github/workflows/test.yml | 27 - .gitignore | 10 +- .mypy.ini | 2 +- .pre-commit-config.yaml | 6 - .zenodo.json | 27 + CHANGELOG.md | 53 +- LICENSE | 2 +- Makefile | 13 +- README.md | 47 +- docs/Makefile | 6 + docs/_static/custom.css | 66 +- docs/api/collectors.rst | 42 + docs/api/components.rst | 44 + docs/api/helpers.rst | 18 + docs/api/problems.rst | 57 + docs/api/solvers.rst | 26 + docs/benchmarks/Manifest.toml | 671 --- docs/benchmarks/Project.toml | 5 - docs/benchmarks/Untitled.ipynb | 68 - docs/benchmarks/facility.ipynb | 29 - docs/benchmarks/gurobi.env | 3 - docs/benchmarks/knapsack.ipynb | 3989 ----------------- docs/benchmarks/preliminaries.ipynb | 51 - docs/benchmarks/stab.ipynb | 3978 ---------------- docs/benchmarks/tsp.ipynb | 3895 ---------------- docs/benchmarks/uc.ipynb | 151 - docs/conf.py | 12 +- docs/guide/collectors.ipynb | 277 ++ docs/guide/features.ipynb | 334 ++ docs/guide/primal.ipynb | 291 ++ docs/guide/problems.ipynb | 1567 +++++++ docs/guide/solvers.ipynb | 247 + docs/index.md | 113 - docs/index.rst | 67 + docs/internals/abstract-component.ipynb | 29 - docs/internals/data-collection.ipynb | 29 - docs/internals/dynamic-lazy.ipynb | 29 - docs/internals/primal.ipynb | 29 - docs/internals/solver-interfaces.ipynb | 29 - docs/internals/static-lazy.ipynb | 29 - docs/jump-tutorials/Manifest.toml | 772 ---- docs/jump-tutorials/Project.toml | 9 - docs/jump-tutorials/customizing-ml.ipynb | 29 - docs/jump-tutorials/getting-started.ipynb | 691 --- docs/jump-tutorials/lazy-constraints.ipynb | 29 - docs/jump-tutorials/user-cuts.ipynb | 29 - docs/make.bat | 35 + docs/pyomo-tutorials/getting-started.ipynb | 625 --- docs/pyomo-tutorials/gurobi.env | 3 - docs/tutorials/Manifest.toml | 637 +++ docs/tutorials/Project.toml | 7 + docs/tutorials/getting-started-gurobipy.ipynb | 849 ++++ docs/tutorials/getting-started-jump.ipynb | 680 +++ docs/tutorials/getting-started-pyomo.ipynb | 869 ++++ miplearn/__init__.py | 30 +- miplearn/benchmark.py | 264 -- miplearn/classifiers/__init__.py | 162 +- miplearn/classifiers/adaptive.py | 135 - miplearn/classifiers/counting.py | 45 - miplearn/classifiers/cv.py | 132 - miplearn/classifiers/minprob.py | 61 + miplearn/classifiers/singleclass.py | 51 + miplearn/classifiers/sklearn.py | 93 - miplearn/classifiers/threshold.py | 143 - .../collectors}/__init__.py | 0 miplearn/collectors/basic.py | 86 + miplearn/collectors/lazy.py | 117 + miplearn/collectors/priority.py | 49 + miplearn/components/__init__.py | 46 +- miplearn/components/component.py | 269 -- miplearn/components/dynamic_common.py | 184 - miplearn/components/dynamic_lazy.py | 223 - miplearn/components/dynamic_user_cuts.py | 133 - miplearn/components/lazy.py | 43 + miplearn/components/objective.py | 126 - miplearn/components/primal.py | 341 -- miplearn/components/primal/__init__.py | 29 + miplearn/components/primal/actions.py | 93 + miplearn/components/primal/expert.py | 32 + miplearn/components/primal/indep.py | 129 + miplearn/components/primal/joint.py | 88 + miplearn/components/primal/mem.py | 167 + miplearn/components/priority.py | 31 + miplearn/components/static_lazy.py | 252 -- miplearn/extractors/AlvLouWeh2017.py | 210 + miplearn/extractors/__init__.py | 0 miplearn/extractors/abstract.py | 19 + miplearn/extractors/dummy.py | 24 + miplearn/extractors/fields.py | 69 + miplearn/features/extractor.py | 504 --- miplearn/{features/sample.py => h5.py} | 164 +- miplearn/instance/__init__.py | 3 - miplearn/instance/base.py | 204 - miplearn/instance/file.py | 131 - miplearn/instance/picklegz.py | 195 - miplearn/io.py | 92 + miplearn/log.py | 74 - miplearn/parallel.py | 32 + miplearn/problems/__init__.py | 2 +- miplearn/problems/binpack.py | 146 + miplearn/problems/knapsack.py | 230 - miplearn/problems/multiknapsack.py | 189 + miplearn/problems/pmedian.py | 185 + miplearn/problems/setcover.py | 120 + miplearn/problems/setpack.py | 66 + miplearn/problems/stab.py | 81 +- miplearn/problems/tsp.py | 158 +- miplearn/problems/uc.py | 201 + miplearn/problems/vertexcover.py | 54 + miplearn/solvers/__init__.py | 47 +- miplearn/solvers/abstract.py | 70 + miplearn/solvers/gurobi.py | 923 ++-- miplearn/solvers/internal.py | 340 -- miplearn/solvers/learning.py | 612 +-- miplearn/solvers/pyomo.py | 366 ++ miplearn/solvers/pyomo/__init__.py | 3 - miplearn/solvers/pyomo/base.py | 677 --- miplearn/solvers/pyomo/cplex.py | 48 - miplearn/solvers/pyomo/gurobi.py | 61 - miplearn/solvers/pyomo/xpress.py | 42 - miplearn/solvers/tests/__init__.py | 288 -- miplearn/types.py | 52 - pyproject.toml | 3 - requirements.txt | 2 +- setup.py | 48 +- tests/__init__.py | 3 + tests/classifiers/__init__.py | 39 - tests/classifiers/test_adaptive.py | 40 - tests/classifiers/test_counting.py | 38 - tests/classifiers/test_cv.py | 58 - tests/classifiers/test_sklearn.py | 33 - tests/classifiers/test_threshold.py | 56 - tests/components/__init__.py | 2 +- tests/components/primal/__init__.py | 0 tests/components/primal/test_expert.py | 26 + tests/components/primal/test_indep.py | 51 + tests/components/primal/test_joint.py | 46 + tests/components/primal/test_mem.py | 86 + tests/components/test_dynamic_lazy.py | 147 - tests/components/test_dynamic_user_cuts.py | 105 - tests/components/test_objective.py | 141 - tests/components/test_primal.py | 166 - tests/components/test_static_lazy.py | 238 - tests/conftest.py | 25 + .../features => tests/extractors}/__init__.py | 2 +- tests/extractors/test_dummy.py | 19 + tests/extractors/test_fields.py | 33 + tests/features/test_extractor.py | 709 --- tests/features/test_sample.py | 71 - tests/instance/__init__.py | 3 - tests/instance/test_file.py | 32 - tests/instance/test_picklegz.py | 33 - tests/problems/__init__.py | 2 +- tests/problems/test_binpack.py | 58 + tests/problems/test_knapsack.py | 39 - tests/problems/test_multiknapsack.py | 61 + tests/problems/test_pmedian.py | 53 + tests/problems/test_setcover.py | 91 + tests/problems/test_setpack.py | 26 + tests/problems/test_stab.py | 63 +- tests/problems/test_tsp.py | 144 +- tests/problems/test_uc.py | 71 + tests/problems/test_vertexcover.py | 21 + tests/solvers/__init__.py | 17 - tests/solvers/test_internal_solver.py | 37 - tests/solvers/test_learning_solver.py | 163 - tests/test_benchmark.py | 48 - tests/test_h5.py | 64 + tests/test_solvers.py | 181 + 172 files changed, 10495 insertions(+), 24812 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 .pre-commit-config.yaml create mode 100644 .zenodo.json create mode 100644 docs/api/collectors.rst create mode 100644 docs/api/components.rst create mode 100644 docs/api/helpers.rst create mode 100644 docs/api/problems.rst create mode 100644 docs/api/solvers.rst delete mode 100644 docs/benchmarks/Manifest.toml delete mode 100644 docs/benchmarks/Project.toml delete mode 100644 docs/benchmarks/Untitled.ipynb delete mode 100644 docs/benchmarks/facility.ipynb delete mode 100644 docs/benchmarks/gurobi.env delete mode 100644 docs/benchmarks/knapsack.ipynb delete mode 100644 docs/benchmarks/preliminaries.ipynb delete mode 100644 docs/benchmarks/stab.ipynb delete mode 100644 docs/benchmarks/tsp.ipynb delete mode 100644 docs/benchmarks/uc.ipynb create mode 100644 docs/guide/collectors.ipynb create mode 100644 docs/guide/features.ipynb create mode 100644 docs/guide/primal.ipynb create mode 100644 docs/guide/problems.ipynb create mode 100644 docs/guide/solvers.ipynb delete mode 100644 docs/index.md create mode 100644 docs/index.rst delete mode 100644 docs/internals/abstract-component.ipynb delete mode 100644 docs/internals/data-collection.ipynb delete mode 100644 docs/internals/dynamic-lazy.ipynb delete mode 100644 docs/internals/primal.ipynb delete mode 100644 docs/internals/solver-interfaces.ipynb delete mode 100644 docs/internals/static-lazy.ipynb delete mode 100644 docs/jump-tutorials/Manifest.toml delete mode 100644 docs/jump-tutorials/Project.toml delete mode 100644 docs/jump-tutorials/customizing-ml.ipynb delete mode 100644 docs/jump-tutorials/getting-started.ipynb delete mode 100644 docs/jump-tutorials/lazy-constraints.ipynb delete mode 100644 docs/jump-tutorials/user-cuts.ipynb create mode 100644 docs/make.bat delete mode 100644 docs/pyomo-tutorials/getting-started.ipynb delete mode 100644 docs/pyomo-tutorials/gurobi.env create mode 100644 docs/tutorials/Manifest.toml create mode 100644 docs/tutorials/Project.toml create mode 100644 docs/tutorials/getting-started-gurobipy.ipynb create mode 100644 docs/tutorials/getting-started-jump.ipynb create mode 100644 docs/tutorials/getting-started-pyomo.ipynb delete mode 100644 miplearn/benchmark.py delete mode 100644 miplearn/classifiers/adaptive.py delete mode 100644 miplearn/classifiers/counting.py delete mode 100644 miplearn/classifiers/cv.py create mode 100644 miplearn/classifiers/minprob.py create mode 100644 miplearn/classifiers/singleclass.py delete mode 100644 miplearn/classifiers/sklearn.py delete mode 100644 miplearn/classifiers/threshold.py rename {tests/features => miplearn/collectors}/__init__.py (100%) create mode 100644 miplearn/collectors/basic.py create mode 100644 miplearn/collectors/lazy.py create mode 100644 miplearn/collectors/priority.py delete mode 100644 miplearn/components/component.py delete mode 100644 miplearn/components/dynamic_common.py delete mode 100644 miplearn/components/dynamic_lazy.py delete mode 100644 miplearn/components/dynamic_user_cuts.py create mode 100644 miplearn/components/lazy.py delete mode 100644 miplearn/components/objective.py delete mode 100644 miplearn/components/primal.py create mode 100644 miplearn/components/primal/__init__.py create mode 100644 miplearn/components/primal/actions.py create mode 100644 miplearn/components/primal/expert.py create mode 100644 miplearn/components/primal/indep.py create mode 100644 miplearn/components/primal/joint.py create mode 100644 miplearn/components/primal/mem.py create mode 100644 miplearn/components/priority.py delete mode 100644 miplearn/components/static_lazy.py create mode 100644 miplearn/extractors/AlvLouWeh2017.py create mode 100644 miplearn/extractors/__init__.py create mode 100644 miplearn/extractors/abstract.py create mode 100644 miplearn/extractors/dummy.py create mode 100644 miplearn/extractors/fields.py delete mode 100644 miplearn/features/extractor.py rename miplearn/{features/sample.py => h5.py} (58%) delete mode 100644 miplearn/instance/__init__.py delete mode 100644 miplearn/instance/base.py delete mode 100644 miplearn/instance/file.py delete mode 100644 miplearn/instance/picklegz.py create mode 100644 miplearn/io.py delete mode 100644 miplearn/log.py create mode 100644 miplearn/parallel.py create mode 100644 miplearn/problems/binpack.py delete mode 100644 miplearn/problems/knapsack.py create mode 100644 miplearn/problems/multiknapsack.py create mode 100644 miplearn/problems/pmedian.py create mode 100644 miplearn/problems/setcover.py create mode 100644 miplearn/problems/setpack.py create mode 100644 miplearn/problems/uc.py create mode 100644 miplearn/problems/vertexcover.py create mode 100644 miplearn/solvers/abstract.py delete mode 100644 miplearn/solvers/internal.py create mode 100644 miplearn/solvers/pyomo.py delete mode 100644 miplearn/solvers/pyomo/__init__.py delete mode 100644 miplearn/solvers/pyomo/base.py delete mode 100644 miplearn/solvers/pyomo/cplex.py delete mode 100644 miplearn/solvers/pyomo/gurobi.py delete mode 100644 miplearn/solvers/pyomo/xpress.py delete mode 100644 miplearn/solvers/tests/__init__.py delete mode 100644 miplearn/types.py delete mode 100644 pyproject.toml delete mode 100644 tests/classifiers/__init__.py delete mode 100644 tests/classifiers/test_adaptive.py delete mode 100644 tests/classifiers/test_counting.py delete mode 100644 tests/classifiers/test_cv.py delete mode 100644 tests/classifiers/test_sklearn.py delete mode 100644 tests/classifiers/test_threshold.py create mode 100644 tests/components/primal/__init__.py create mode 100644 tests/components/primal/test_expert.py create mode 100644 tests/components/primal/test_indep.py create mode 100644 tests/components/primal/test_joint.py create mode 100644 tests/components/primal/test_mem.py delete mode 100644 tests/components/test_dynamic_lazy.py delete mode 100644 tests/components/test_dynamic_user_cuts.py delete mode 100644 tests/components/test_objective.py delete mode 100644 tests/components/test_primal.py delete mode 100644 tests/components/test_static_lazy.py create mode 100644 tests/conftest.py rename {miplearn/features => tests/extractors}/__init__.py (72%) create mode 100644 tests/extractors/test_dummy.py create mode 100644 tests/extractors/test_fields.py delete mode 100644 tests/features/test_extractor.py delete mode 100644 tests/features/test_sample.py delete mode 100644 tests/instance/__init__.py delete mode 100644 tests/instance/test_file.py delete mode 100644 tests/instance/test_picklegz.py create mode 100644 tests/problems/test_binpack.py delete mode 100644 tests/problems/test_knapsack.py create mode 100644 tests/problems/test_multiknapsack.py create mode 100644 tests/problems/test_pmedian.py create mode 100644 tests/problems/test_setcover.py create mode 100644 tests/problems/test_setpack.py create mode 100644 tests/problems/test_uc.py create mode 100644 tests/problems/test_vertexcover.py delete mode 100644 tests/solvers/__init__.py delete mode 100644 tests/solvers/test_internal_solver.py delete mode 100644 tests/solvers/test_learning_solver.py delete mode 100644 tests/test_benchmark.py create mode 100644 tests/test_h5.py create mode 100644 tests/test_solvers.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 7d167f8..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Bug report -about: Something is broken in the package -title: '' -labels: '' -assignees: '' - ---- - -## Description - -A clear and concise description of what the bug is. - -## Steps to Reproduce - -Please describe how can the developers reproduce the problem in their own computers. Code snippets and sample input files are specially helpful. For example: - -1. Install the package -2. Run the code below with the attached input file... -3. The following error appears... - -## System Information - - Operating System: [e.g. Ubuntu 20.04] - - Python version: [e.g. 3.6] - - Solver: [e.g. Gurobi 9.0] - - Package version: [e.g. 0.1.0] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 342087c..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -blank_issues_enabled: false -contact_links: - - name: Feature Request - url: https://github.com/ANL-CEEESA/MIPLearn/discussions/categories/feature-requests - about: Submit ideas for new features and small enhancements - - name: Help & FAQ - url: https://github.com/ANL-CEEESA/MIPLearn/discussions/categories/help-faq - about: Ask questions about the package and get help from the community diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 63e1851..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: psf/black@20.8b1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 8642a0a..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Test -on: - push: - pull_request: - schedule: - - cron: '45 10 * * *' -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.7, 3.8] - - steps: - - name: Check out source code - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: make install-deps - - - name: Test - run: make test diff --git a/.gitignore b/.gitignore index 4970bb6..37fe45a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.h5 -*.jld2 TODO.md .idea *.gz @@ -80,6 +78,8 @@ wheels/ notebooks/ .vscode tmp -benchmark/tsp -benchmark/stab -benchmark/knapsack +benchmark/data +benchmark/results +**/*.xz +**/*.h5 +**/*.jld2 diff --git a/.mypy.ini b/.mypy.ini index 152ead6..c16c83f 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -4,4 +4,4 @@ disallow_untyped_defs = True disallow_untyped_calls = True disallow_incomplete_defs = True pretty = True -no_implicit_optional = True \ No newline at end of file +no_implicit_optional = True diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index a9a051d..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,6 +0,0 @@ -repos: - - repo: https://github.com/ambv/black - rev: 20.8b1 - hooks: - - id: black - args: ["--check"] diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 0000000..983ffeb --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,27 @@ +{ + "creators": [ + { + "orcid": "0000-0002-5022-9802", + "affiliation": "Argonne National Laboratory", + "name": "Santos Xavier, Alinson" + }, + { + "affiliation": "Argonne National Laboratory", + "name": "Qiu, Feng" + }, + { + "affiliation": "Georgia Institute of Technology", + "name": "Gu, Xiaoyi" + }, + { + "affiliation": "Georgia Institute of Technology", + "name": "Becu, Berkay" + }, + { + "affiliation": "Georgia Institute of Technology", + "name": "Dey, Santanu S." + } + ], + "title": "MIPLearn: An Extensible Framework for Learning-Enhanced Optimization", + "description": "MIPLearn is an extensible framework for solving discrete optimization problems using a combination of Mixed-Integer Linear Programming (MIP) and Machine Learning (ML). MIPLearn uses ML methods to automatically identify patterns in previously solved instances of the problem, then uses these patterns to accelerate the performance of conventional state-of-the-art MIP solvers such as CPLEX, Gurobi or XPRESS." +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 588686c..41e21b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,45 +1,34 @@ -# MIPLearn: Changelog +# Changelog -## [0.2.0] - [Unreleased] +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2023-06-08 + +This is a complete rewrite of the original prototype package, with an entirely new API, focused on performance, scalability and flexibility. ### Added -- **Added two new machine learning components:** - - Added `StaticLazyConstraintComponent`, which allows the user to mark some constraints in the formulation as lazy, instead of constructing them in a callback. ML predicts which static lazy constraints should be kept in the formulation, and which should be removed. - - Added `UserCutComponents`, which predicts which user cuts should be generated and added to the formulation as constraints ahead-of-time, before solving the MIP. -- **Added support to additional MILP solvers:** - - Added support for CPLEX and XPRESS, through the Pyomo modeling language, in addition to (existing) Gurobi. The solver classes are named `CplexPyomoSolver`, `XpressPyomoSolver` and `GurobiPyomoSolver`. - - Added support for Gurobi without any modeling language. The solver class is named `GurobiSolver`. In this case, `instance.to_model` should return ` gp.Model` object. - - Added support to direct MPS files, produced externally, through the `GurobiSolver` class mentioned above. -- **Added dynamic thresholds:** - - In previous versions of the package, it was necessary to manually adjust component aggressiveness to reach a desired precision/recall. This can now be done automatically with `MinProbabilityThreshold`, `MinPrecisionThreshold` and `MinRecallThreshold`. -- **Reduced memory requirements:** - - Previous versions of the package required all training instances to be kept in memory at all times, which was prohibitive for large-scale problems. It is now possible to store instances in file until they are needed, using `PickledGzInstance`. -- **Refactoring:** - - Added static types to all classes (with mypy). +- Add support for Python/Gurobipy and Julia/JuMP, in addition to the existing Python/Pyomo interface. +- Add six new random instance generators (bin packing, capacitated p-median, set cover, set packing, unit commitment, vertex cover), in addition to the three existing generators (multiknapsack, stable set, tsp). +- Collect some additional raw training data (e.g. basis status, reduced costs, etc) +- Add new primal solution ML strategies (memorizing, independent vars and joint vars) +- Add new primal solution actions (set warm start, fix variables, enforce proximity) +- Add runnable tutorials and user guides to the documentation. ### Changed -- Variables are now referenced by their names, instead of tuples `(var_name, index)`. This change was required to improve the compatibility with modeling languages other than Pyomo, which do not follow this convention. For performance reasons, the functions `get_variable_features` and `get_variable_categories` should now return a dictionary containing categories and features for all relevant variables. Previously, MIPLearn had to perform two function calls per variable, which was too slow for very large models. -- Internal solvers must now be specified as objects, instead of strings. For example, - ```python - solver = LearningSolver( - solver=GurobiPyomoSolver( - params={ - "TimeLimit": 300, - "Threads": 4, - } - ) - ) - ``` -- `LazyConstraintComponent` has been renamed to `DynamicLazyConstraintsComponent`. -- Categories, lazy constraints and cutting plane identifiers must now be strings, instead `Hashable`. This change was required for compatibility with HDF5 data format. +- To support large-scale problems and datasets, switch from an in-memory architecture to a file-based architecture, using HDF5 files. +- To accelerate development cycle, split training data collection from feature extraction. ### Removed -- Temporarily removed the experimental `BranchPriorityComponent`. This component will be re-added in the Julia version of the package. -- Removed `solver.add` method, previously used to add components to an existing solver. Use the constructor `LearningSolver(components=[...])` instead. +- Temporarily remove ML strategies for lazy constraints +- Remove benchmarks from documentation. These will be published in a separate paper. + ## [0.1.0] - 2020-11-23 -- Initial public release +- Initial public release \ No newline at end of file diff --git a/LICENSE b/LICENSE index 497106c..7c7f102 100644 --- a/LICENSE +++ b/LICENSE @@ -22,4 +22,4 @@ DISCLAIMER THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -******************************************************************************** +******************************************************************************** \ No newline at end of file diff --git a/Makefile b/Makefile index c34cb46..b218345 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ PYTHON := python3 PYTEST := pytest PIP := $(PYTHON) -m pip MYPY := $(PYTHON) -m mypy -PYTEST_ARGS := -W ignore::DeprecationWarning -vv --log-level=DEBUG tests -VERSION := 0.2 +PYTEST_ARGS := -W ignore::DeprecationWarning -vv --log-level=DEBUG +VERSION := 0.3 all: docs test @@ -24,11 +24,8 @@ docs: cd docs; make clean; make dirhtml rsync -avP --delete-after docs/_build/dirhtml/ ../docs/$(VERSION) - install-deps: $(PIP) install --upgrade pip - $(PIP) install --upgrade -i https://pypi.gurobi.com 'gurobipy>=9.5,<9.6' - $(PIP) install --upgrade xpress $(PIP) install --upgrade -r requirements.txt install: @@ -41,9 +38,11 @@ reformat: $(PYTHON) -m black . test: + # pyflakes miplearn tests + black --check . # rm -rf .mypy_cache - # $(MYPY) -p miplearn - # $(MYPY) -p tests + $(MYPY) -p miplearn + $(MYPY) -p tests $(PYTEST) $(PYTEST_ARGS) .PHONY: test test-watch docs install dist diff --git a/README.md b/README.md index 5461c46..d814a66 100644 --- a/README.md +++ b/README.md @@ -14,36 +14,51 @@

-**MIPLearn** is an extensible framework for solving discrete optimization problems using a combination of Mixed-Integer Linear Programming (MIP) and Machine Learning (ML). +**MIPLearn** is an extensible framework for solving discrete optimization problems using a combination of Mixed-Integer Linear Programming (MIP) and Machine Learning (ML). MIPLearn uses ML methods to automatically identify patterns in previously solved instances of the problem, then uses these patterns to accelerate the performance of conventional state-of-the-art MIP solvers such as CPLEX, Gurobi or XPRESS. -MIPLearn uses ML methods to automatically identify patterns in previously solved instances of the problem, then uses these patterns to accelerate the performance of conventional state-of-the-art MIP solvers such as CPLEX, Gurobi or XPRESS. Unlike pure ML methods, MIPLearn is not only able to find high-quality solutions to discrete optimization problems, but it can also prove the optimality and feasibility of these solutions. Unlike conventional MIP solvers, MIPLearn can take full advantage of very specific observations that happen to be true in a particular family of instances (such as the observation that a particular constraint is typically redundant, or that a particular variable typically assumes a certain value). For certain classes of problems, this approach has been shown to provide significant performance benefits (see [benchmarks](https://anl-ceeesa.github.io/MIPLearn/0.1/problems/) and [references](https://anl-ceeesa.github.io/MIPLearn/0.1/about/)). - -Features --------- -* **MIPLearn proposes a flexible problem specification format,** which allows users to describe their particular optimization problems to a Learning-Enhanced MIP solver, both from the MIP perspective and from the ML perspective, without making any assumptions on the problem being modeled, the mathematical formulation of the problem, or ML encoding. - -* **MIPLearn provides a reference implementation of a *Learning-Enhanced Solver*,** which can use the above problem specification format to automatically predict, based on previously solved instances, a number of hints to accelerate MIP performance. - -* **MIPLearn provides a set of benchmark problems and random instance generators,** covering applications from different domains, which can be used to quickly evaluate new learning-enhanced MIP techniques in a measurable and reproducible way. - -* **MIPLearn is customizable and extensible**. For MIP and ML researchers exploring new techniques to accelerate MIP performance based on historical data, each component of the reference solver can be individually replaced, extended or customized. +Unlike pure ML methods, MIPLearn is not only able to find high-quality solutions to discrete optimization problems, but it can also prove the optimality and feasibility of these solutions. Unlike conventional MIP solvers, MIPLearn can take full advantage of very specific observations that happen to be true in a particular family of instances (such as the observation that a particular constraint is typically redundant, or that a particular variable typically assumes a certain value). For certain classes of problems, this approach may provide significant performance benefits. Documentation ------------- -For installation instructions, basic usage and benchmarks results, see the [official documentation](https://anl-ceeesa.github.io/MIPLearn/). +- Tutorials: + 1. [Getting started (Pyomo)](https://anl-ceeesa.github.io/MIPLearn/0.3/tutorials/getting-started-pyomo/) + 2. [Getting started (Gurobipy)](https://anl-ceeesa.github.io/MIPLearn/0.3/tutorials/getting-started-gurobipy/) + 3. [Getting started (JuMP)](https://anl-ceeesa.github.io/MIPLearn/0.3/tutorials/getting-started-jump/) +- User Guide + 1. [Benchmark problems](https://anl-ceeesa.github.io/MIPLearn/0.3/guide/problems/) + 2. [Training data collectors](https://anl-ceeesa.github.io/MIPLearn/0.3/guide/collectors/) + 3. [Feature extractors](https://anl-ceeesa.github.io/MIPLearn/0.3/guide/features/) + 4. [Primal components](https://anl-ceeesa.github.io/MIPLearn/0.3/guide/primal/) + 5. [Learning solver](https://anl-ceeesa.github.io/MIPLearn/0.3/guide/solvers/) +- Python API Reference + 1. [Benchmark problems](https://anl-ceeesa.github.io/MIPLearn/0.3/api/problems/) + 2. [Collectors & extractors](https://anl-ceeesa.github.io/MIPLearn/0.3/api/collectors/) + 3. [Components](https://anl-ceeesa.github.io/MIPLearn/0.3/api/components/) + 4. [Solvers](https://anl-ceeesa.github.io/MIPLearn/0.3/api/solvers/) + 5. [Helpers](https://anl-ceeesa.github.io/MIPLearn/0.3/api/helpers/) + +Authors +------- + +- **Alinson S. Xavier** (Argonne National Laboratory) +- **Feng Qiu** (Argonne National Laboratory) +- **Xiaoyi Gu** (Georgia Institute of Technology) +- **Berkay Becu** (Georgia Institute of Technology) +- **Santanu S. Dey** (Georgia Institute of Technology) + Acknowledgments --------------- -* Based upon work supported by **Laboratory Directed Research and Development** (LDRD) funding from Argonne National Laboratory, provided by the Director, Office of Science, of the U.S. Department of Energy under Contract No. DE-AC02-06CH11357. -* Based upon work supported by the **U.S. Department of Energy Advanced Grid Modeling Program** under Grant DE-OE0000875. +* Based upon work supported by **Laboratory Directed Research and Development** (LDRD) funding from Argonne National Laboratory, provided by the Director, Office of Science, of the U.S. Department of Energy. +* Based upon work supported by the **U.S. Department of Energy Advanced Grid Modeling Program**. Citing MIPLearn --------------- If you use MIPLearn in your research (either the solver or the included problem generators), we kindly request that you cite the package as follows: -* **Alinson S. Xavier, Feng Qiu.** *MIPLearn: An Extensible Framework for Learning-Enhanced Optimization*. Zenodo (2020). DOI: [10.5281/zenodo.4287567](https://doi.org/10.5281/zenodo.4287567) +* **Alinson S. Xavier, Feng Qiu, Xiaoyi Gu, Berkay Becu, Santanu S. Dey.** *MIPLearn: An Extensible Framework for Learning-Enhanced Optimization (Version 0.3)*. Zenodo (2023). DOI: [10.5281/zenodo.4287567](https://doi.org/10.5281/zenodo.4287567) If you use MIPLearn in the field of power systems optimization, we kindly request that you cite the reference below, in which the main techniques implemented in MIPLearn were first developed: diff --git a/docs/Makefile b/docs/Makefile index c987782..d4bb2cb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,8 +1,14 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build +# Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 620daf3..668b842 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -8,11 +8,10 @@ h1.site-logo small { code { display: inline-block; - color: #222 !important; - background-color: rgba(0 0 0 / 8%); border-radius: 4px; padding: 0 4px; - + background-color: #eee; + color: rgb(232, 62, 140); } .right-next, .left-prev { @@ -50,13 +49,72 @@ code { .output_area pre { color: #fff; + line-height: 20px !important; } .input_area pre { background-color: rgba(0 0 0 / 3%) !important; padding: 12px !important; + line-height: 20px; } .ansi-green-intense-fg { color: #64d88b !important; -} \ No newline at end of file +} + +#site-navigation { + background-color: #fafafa; +} + +.container, .container-lg, .container-md, .container-sm, .container-xl { + max-width: inherit !important; +} + +h1, h2 { + font-weight: bold !important; +} + +#main-content .section { + max-width: 900px !important; + margin: 0 auto !important; + font-size: 16px; +} + +p.caption { + font-weight: bold; +} + +h2 { + padding-bottom: 5px; + border-bottom: 1px solid #ccc; +} + +h3 { + margin-top: 1.5rem; +} + +tbody, thead, pre { + border: 1px solid rgba(0, 0, 0, 0.25); +} + +table td, th { + padding: 8px; +} + +table p { + margin-bottom: 0; +} + +table td code { + white-space: nowrap; +} + +table tr, +table th { + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +table tr:last-child { + border-bottom: 0; +} + diff --git a/docs/api/collectors.rst b/docs/api/collectors.rst new file mode 100644 index 0000000..ca2072f --- /dev/null +++ b/docs/api/collectors.rst @@ -0,0 +1,42 @@ +Collectors & Extractors +======================= + +miplearn.classifiers.minprob +---------------------------- + +.. automodule:: miplearn.classifiers.minprob + :members: + :undoc-members: + :show-inheritance: + +miplearn.classifiers.singleclass +-------------------------------- + +.. automodule:: miplearn.classifiers.singleclass + :members: + :undoc-members: + :show-inheritance: + +miplearn.collectors.basic +------------------------- + +.. automodule:: miplearn.collectors.basic + :members: + :undoc-members: + :show-inheritance: + +miplearn.extractors.fields +-------------------------- + +.. automodule:: miplearn.extractors.fields + :members: + :undoc-members: + :show-inheritance: + +miplearn.extractors.AlvLouWeh2017 +--------------------------------- + +.. automodule:: miplearn.extractors.AlvLouWeh2017 + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/components.rst b/docs/api/components.rst new file mode 100644 index 0000000..64b6c5b --- /dev/null +++ b/docs/api/components.rst @@ -0,0 +1,44 @@ +Components +========== + +miplearn.components.primal.actions +---------------------------------- + +.. automodule:: miplearn.components.primal.actions + :members: + :undoc-members: + :show-inheritance: + +miplearn.components.primal.expert +---------------------------------- + +.. automodule:: miplearn.components.primal.expert + :members: + :undoc-members: + :show-inheritance: + +miplearn.components.primal.indep +---------------------------------- + +.. automodule:: miplearn.components.primal.indep + :members: + :undoc-members: + :show-inheritance: + +miplearn.components.primal.joint +---------------------------------- + +.. automodule:: miplearn.components.primal.joint + :members: + :undoc-members: + :show-inheritance: + +miplearn.components.primal.mem +---------------------------------- + +.. automodule:: miplearn.components.primal.mem + :members: + :undoc-members: + :show-inheritance: + + \ No newline at end of file diff --git a/docs/api/helpers.rst b/docs/api/helpers.rst new file mode 100644 index 0000000..d83450f --- /dev/null +++ b/docs/api/helpers.rst @@ -0,0 +1,18 @@ +Helpers +======= + +miplearn.io +----------- + +.. automodule:: miplearn.io + :members: + :undoc-members: + :show-inheritance: + +miplearn.h5 +----------- + +.. automodule:: miplearn.h5 + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/problems.rst b/docs/api/problems.rst new file mode 100644 index 0000000..a60a968 --- /dev/null +++ b/docs/api/problems.rst @@ -0,0 +1,57 @@ +Benchmark Problems +================== + +miplearn.problems.binpack +------------------------- + +.. automodule:: miplearn.problems.binpack + :members: + +miplearn.problems.multiknapsack +------------------------------- + +.. automodule:: miplearn.problems.multiknapsack + :members: + +miplearn.problems.pmedian +------------------------- + +.. automodule:: miplearn.problems.pmedian + :members: + +miplearn.problems.setcover +-------------------------- + +.. automodule:: miplearn.problems.setcover + :members: + +miplearn.problems.setpack +------------------------- + +.. automodule:: miplearn.problems.setpack + :members: + +miplearn.problems.stab +---------------------- + +.. automodule:: miplearn.problems.stab + :members: + +miplearn.problems.tsp +--------------------- + +.. automodule:: miplearn.problems.tsp + :members: + +miplearn.problems.uc +-------------------- + +.. automodule:: miplearn.problems.uc + :members: + +miplearn.problems.vertexcover +----------------------------- + +.. automodule:: miplearn.problems.vertexcover + :members: + diff --git a/docs/api/solvers.rst b/docs/api/solvers.rst new file mode 100644 index 0000000..2337d92 --- /dev/null +++ b/docs/api/solvers.rst @@ -0,0 +1,26 @@ +Solvers +======= + +miplearn.solvers.abstract +------------------------- + +.. automodule:: miplearn.solvers.abstract + :members: + :undoc-members: + :show-inheritance: + +miplearn.solvers.gurobi +------------------------- + +.. automodule:: miplearn.solvers.gurobi + :members: + :undoc-members: + :show-inheritance: + +miplearn.solvers.learning +------------------------- + +.. automodule:: miplearn.solvers.learning + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/benchmarks/Manifest.toml b/docs/benchmarks/Manifest.toml deleted file mode 100644 index 9650eaa..0000000 --- a/docs/benchmarks/Manifest.toml +++ /dev/null @@ -1,671 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -[[ASL_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "370cafc70604b2522f2c7cf9915ebcd17b4cd38b" -uuid = "ae81ac8f-d209-56e5-92de-9978fef736f9" -version = "0.1.2+0" - -[[ArgTools]] -uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" - -[[Artifacts]] -uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" - -[[Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" - -[[BenchmarkTools]] -deps = ["JSON", "Logging", "Printf", "Profile", "Statistics", "UUIDs"] -git-tree-sha1 = "61adeb0823084487000600ef8b1c00cc2474cd47" -uuid = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" -version = "1.2.0" - -[[BinaryProvider]] -deps = ["Libdl", "Logging", "SHA"] -git-tree-sha1 = "ecdec412a9abc8db54c0efc5548c64dfce072058" -uuid = "b99e7846-7c00-51b0-8f62-c81ae34c0232" -version = "0.5.10" - -[[Bzip2_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "19a35467a82e236ff51bc17a3a44b69ef35185a2" -uuid = "6e34b625-4abd-537c-b88f-471c36dfa7a0" -version = "1.0.8+0" - -[[CEnum]] -git-tree-sha1 = "215a9aa4a1f23fbd05b92769fdd62559488d70e9" -uuid = "fa961155-64e5-5f13-b03f-caf6b980ea82" -version = "0.4.1" - -[[CSV]] -deps = ["CodecZlib", "Dates", "FilePathsBase", "Mmap", "Parsers", "PooledArrays", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings"] -git-tree-sha1 = "7c2d71ad51fd4347193463b0a065e4dc7063e248" -uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -version = "0.9.3" - -[[Calculus]] -deps = ["LinearAlgebra"] -git-tree-sha1 = "f641eb0a4f00c343bbc32346e1217b86f3ce9dad" -uuid = "49dc2e85-a5d0-5ad3-a950-438e2897f1b9" -version = "0.5.1" - -[[Cbc]] -deps = ["BinaryProvider", "CEnum", "Cbc_jll", "Libdl", "MathOptInterface", "SparseArrays"] -git-tree-sha1 = "98e3692f90b26a340f32e17475c396c3de4180de" -uuid = "9961bab8-2fa3-5c5a-9d89-47fab24efd76" -version = "0.8.1" - -[[Cbc_jll]] -deps = ["ASL_jll", "Artifacts", "Cgl_jll", "Clp_jll", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "OpenBLAS32_jll", "Osi_jll", "Pkg"] -git-tree-sha1 = "7693a7ca006d25e0d0097a5eee18ce86368e00cd" -uuid = "38041ee0-ae04-5750-a4d2-bb4d0d83d27d" -version = "200.1000.500+1" - -[[Cgl_jll]] -deps = ["Artifacts", "Clp_jll", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Osi_jll", "Pkg"] -git-tree-sha1 = "b5557f48e0e11819bdbda0200dbfa536dd12d9d9" -uuid = "3830e938-1dd0-5f3e-8b8e-b3ee43226782" -version = "0.6000.200+0" - -[[ChainRulesCore]] -deps = ["Compat", "LinearAlgebra", "SparseArrays"] -git-tree-sha1 = "4ce9393e871aca86cc457d9f66976c3da6902ea7" -uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" -version = "1.4.0" - -[[Clp]] -deps = ["BinaryProvider", "CEnum", "Clp_jll", "Libdl", "MathOptInterface", "SparseArrays"] -git-tree-sha1 = "3df260c4a5764858f312ec2a17f5925624099f3a" -uuid = "e2554f3b-3117-50c0-817c-e040a3ddf72d" -version = "0.8.4" - -[[Clp_jll]] -deps = ["Artifacts", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "METIS_jll", "MUMPS_seq_jll", "OpenBLAS32_jll", "Osi_jll", "Pkg"] -git-tree-sha1 = "5e4f9a825408dc6356e6bf1015e75d2b16250ec8" -uuid = "06985876-5285-5a41-9fcb-8948a742cc53" -version = "100.1700.600+0" - -[[CodecBzip2]] -deps = ["Bzip2_jll", "Libdl", "TranscodingStreams"] -git-tree-sha1 = "2e62a725210ce3c3c2e1a3080190e7ca491f18d7" -uuid = "523fee87-0ab8-5b00-afb7-3ecf72e48cfd" -version = "0.7.2" - -[[CodecZlib]] -deps = ["TranscodingStreams", "Zlib_jll"] -git-tree-sha1 = "ded953804d019afa9a3f98981d99b33e3db7b6da" -uuid = "944b1d66-785c-5afd-91f1-9de20f533193" -version = "0.7.0" - -[[CoinUtils_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "9b4a8b1087376c56189d02c3c1a48a0bba098ec2" -uuid = "be027038-0da8-5614-b30d-e42594cb92df" -version = "2.11.4+2" - -[[CommonSubexpressions]] -deps = ["MacroTools", "Test"] -git-tree-sha1 = "7b8a93dba8af7e3b42fecabf646260105ac373f7" -uuid = "bbf7d656-a473-5ed7-a52c-81e309532950" -version = "0.3.0" - -[[Compat]] -deps = ["Base64", "Dates", "DelimitedFiles", "Distributed", "InteractiveUtils", "LibGit2", "Libdl", "LinearAlgebra", "Markdown", "Mmap", "Pkg", "Printf", "REPL", "Random", "SHA", "Serialization", "SharedArrays", "Sockets", "SparseArrays", "Statistics", "Test", "UUIDs", "Unicode"] -git-tree-sha1 = "4866e381721b30fac8dda4c8cb1d9db45c8d2994" -uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" -version = "3.37.0" - -[[CompilerSupportLibraries_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" - -[[Conda]] -deps = ["JSON", "VersionParsing"] -git-tree-sha1 = "299304989a5e6473d985212c28928899c74e9421" -uuid = "8f4d0f93-b110-5947-807f-2305c1781a2d" -version = "1.5.2" - -[[Crayons]] -git-tree-sha1 = "3f71217b538d7aaee0b69ab47d9b7724ca8afa0d" -uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" -version = "4.0.4" - -[[DataAPI]] -git-tree-sha1 = "bec2532f8adb82005476c141ec23e921fc20971b" -uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" -version = "1.8.0" - -[[DataFrames]] -deps = ["Compat", "DataAPI", "Future", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrettyTables", "Printf", "REPL", "Reexport", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"] -git-tree-sha1 = "d785f42445b63fc86caa08bb9a9351008be9b765" -uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" -version = "1.2.2" - -[[DataStructures]] -deps = ["Compat", "InteractiveUtils", "OrderedCollections"] -git-tree-sha1 = "7d9d316f04214f7efdbb6398d545446e246eff02" -uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -version = "0.18.10" - -[[DataValueInterfaces]] -git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" -uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" -version = "1.0.0" - -[[Dates]] -deps = ["Printf"] -uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" - -[[DelimitedFiles]] -deps = ["Mmap"] -uuid = "8bb1440f-4735-579b-a4ab-409b98df4dab" - -[[DiffResults]] -deps = ["StaticArrays"] -git-tree-sha1 = "c18e98cba888c6c25d1c3b048e4b3380ca956805" -uuid = "163ba53b-c6d8-5494-b064-1a9d43ac40c5" -version = "1.0.3" - -[[DiffRules]] -deps = ["NaNMath", "Random", "SpecialFunctions"] -git-tree-sha1 = "7220bc21c33e990c14f4a9a319b1d242ebc5b269" -uuid = "b552c78f-8df3-52c6-915a-8e097449b14b" -version = "1.3.1" - -[[Distributed]] -deps = ["Random", "Serialization", "Sockets"] -uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" - -[[Distributions]] -deps = ["ChainRulesCore", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "SparseArrays", "SpecialFunctions", "Statistics", "StatsBase", "StatsFuns"] -git-tree-sha1 = "f4efaa4b5157e0cdb8283ae0b5428bc9208436ed" -uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" -version = "0.25.16" - -[[DocStringExtensions]] -deps = ["LibGit2"] -git-tree-sha1 = "a32185f5428d3986f47c2ab78b1f216d5e6cc96f" -uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" -version = "0.8.5" - -[[Downloads]] -deps = ["ArgTools", "LibCURL", "NetworkOptions"] -uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" - -[[ExprTools]] -git-tree-sha1 = "b7e3d17636b348f005f11040025ae8c6f645fe92" -uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" -version = "0.1.6" - -[[FileIO]] -deps = ["Pkg", "Requires", "UUIDs"] -git-tree-sha1 = "3c041d2ac0a52a12a27af2782b34900d9c3ee68c" -uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" -version = "1.11.1" - -[[FilePathsBase]] -deps = ["Dates", "Mmap", "Printf", "Test", "UUIDs"] -git-tree-sha1 = "6d4b609786127030d09e6b1ee0e2044ec20eb403" -uuid = "48062228-2e41-5def-b9a4-89aafe57970f" -version = "0.9.11" - -[[FillArrays]] -deps = ["LinearAlgebra", "Random", "SparseArrays", "Statistics"] -git-tree-sha1 = "caf289224e622f518c9dbfe832cdafa17d7c80a6" -uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" -version = "0.12.4" - -[[Formatting]] -deps = ["Printf"] -git-tree-sha1 = "8339d61043228fdd3eb658d86c926cb282ae72a8" -uuid = "59287772-0a20-5a39-b81b-1366585eb4c0" -version = "0.4.2" - -[[ForwardDiff]] -deps = ["CommonSubexpressions", "DiffResults", "DiffRules", "LinearAlgebra", "NaNMath", "Printf", "Random", "SpecialFunctions", "StaticArrays"] -git-tree-sha1 = "b5e930ac60b613ef3406da6d4f42c35d8dc51419" -uuid = "f6369f11-7733-5829-9624-2563aa707210" -version = "0.10.19" - -[[Future]] -deps = ["Random"] -uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" - -[[GZip]] -deps = ["Libdl"] -git-tree-sha1 = "039be665faf0b8ae36e089cd694233f5dee3f7d6" -uuid = "92fee26a-97fe-5a0c-ad85-20a5f3185b63" -version = "0.5.1" - -[[Gurobi]] -deps = ["CEnum", "Libdl", "MathOptInterface"] -git-tree-sha1 = "aac05324d46b53289ccb05510b05b4a56ffd3ed5" -uuid = "2e9cd046-0924-5485-92f1-d5272153d98b" -version = "0.9.14" - -[[HTTP]] -deps = ["Base64", "Dates", "IniFile", "Logging", "MbedTLS", "NetworkOptions", "Sockets", "URIs"] -git-tree-sha1 = "60ed5f1643927479f845b0135bb369b031b541fa" -uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "0.9.14" - -[[IniFile]] -deps = ["Test"] -git-tree-sha1 = "098e4d2c533924c921f9f9847274f2ad89e018b8" -uuid = "83e8ac13-25f8-5344-8a64-a9f2b223428f" -version = "0.5.0" - -[[InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" - -[[InvertedIndices]] -git-tree-sha1 = "bee5f1ef5bf65df56bdd2e40447590b272a5471f" -uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f" -version = "1.1.0" - -[[IrrationalConstants]] -git-tree-sha1 = "f76424439413893a832026ca355fe273e93bce94" -uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" -version = "0.1.0" - -[[IteratorInterfaceExtensions]] -git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" -uuid = "82899510-4779-5014-852e-03e436cf321d" -version = "1.0.0" - -[[JLD2]] -deps = ["DataStructures", "FileIO", "MacroTools", "Mmap", "Pkg", "Printf", "Reexport", "TranscodingStreams", "UUIDs"] -git-tree-sha1 = "192934b3e2a94e897ce177423fd6cf7bdf464bce" -uuid = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -version = "0.4.14" - -[[JLLWrappers]] -deps = ["Preferences"] -git-tree-sha1 = "642a199af8b68253517b80bd3bfd17eb4e84df6e" -uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" -version = "1.3.0" - -[[JSON]] -deps = ["Dates", "Mmap", "Parsers", "Unicode"] -git-tree-sha1 = "8076680b162ada2a031f707ac7b4953e30667a37" -uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "0.21.2" - -[[JSONSchema]] -deps = ["HTTP", "JSON", "URIs"] -git-tree-sha1 = "2f49f7f86762a0fbbeef84912265a1ae61c4ef80" -uuid = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" -version = "0.3.4" - -[[JuMP]] -deps = ["Calculus", "DataStructures", "ForwardDiff", "JSON", "LinearAlgebra", "MathOptInterface", "MutableArithmetics", "NaNMath", "Printf", "Random", "SparseArrays", "SpecialFunctions", "Statistics"] -git-tree-sha1 = "4358b7cbf2db36596bdbbe3becc6b9d87e4eb8f5" -uuid = "4076af6c-e467-56ae-b986-b466b2749572" -version = "0.21.10" - -[[LibCURL]] -deps = ["LibCURL_jll", "MozillaCACerts_jll"] -uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" - -[[LibCURL_jll]] -deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] -uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" - -[[LibGit2]] -deps = ["Base64", "NetworkOptions", "Printf", "SHA"] -uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" - -[[LibSSH2_jll]] -deps = ["Artifacts", "Libdl", "MbedTLS_jll"] -uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" - -[[Libdl]] -uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" - -[[LinearAlgebra]] -deps = ["Libdl"] -uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" - -[[LogExpFunctions]] -deps = ["ChainRulesCore", "DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] -git-tree-sha1 = "34dc30f868e368f8a17b728a1238f3fcda43931a" -uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" -version = "0.3.3" - -[[Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" - -[[METIS_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "2dc1a9fc87e57e32b1fc186db78811157b30c118" -uuid = "d00139f3-1899-568f-a2f0-47f597d42d70" -version = "5.1.0+5" - -[[MIPLearn]] -deps = ["CSV", "Cbc", "Clp", "Conda", "DataFrames", "DataStructures", "Distributed", "JLD2", "JSON", "JuMP", "Logging", "MathOptInterface", "OrderedCollections", "PackageCompiler", "Printf", "ProgressBars", "PyCall", "Random", "SparseArrays", "Statistics", "TimerOutputs"] -path = "/home/axavier/Packages/MIPLearn.jl/dev/" -uuid = "2b1277c3-b477-4c49-a15e-7ba350325c68" -version = "0.2.0" - -[[MUMPS_seq_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "METIS_jll", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "a1d469a2a0acbfe219ef9bdfedae97daacac5a0e" -uuid = "d7ed1dd3-d0ae-5e8e-bfb4-87a502085b8d" -version = "5.4.0+0" - -[[MacroTools]] -deps = ["Markdown", "Random"] -git-tree-sha1 = "5a5bc6bf062f0f95e62d0fe0a2d99699fed82dd9" -uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" -version = "0.5.8" - -[[Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" - -[[MathOptInterface]] -deps = ["BenchmarkTools", "CodecBzip2", "CodecZlib", "JSON", "JSONSchema", "LinearAlgebra", "MutableArithmetics", "OrderedCollections", "SparseArrays", "Test", "Unicode"] -git-tree-sha1 = "575644e3c05b258250bb599e57cf73bbf1062901" -uuid = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" -version = "0.9.22" - -[[MbedTLS]] -deps = ["Dates", "MbedTLS_jll", "Random", "Sockets"] -git-tree-sha1 = "1c38e51c3d08ef2278062ebceade0e46cefc96fe" -uuid = "739be429-bea8-5141-9913-cc70e7f3736d" -version = "1.0.3" - -[[MbedTLS_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" - -[[Missings]] -deps = ["DataAPI"] -git-tree-sha1 = "bf210ce90b6c9eed32d25dbcae1ebc565df2687f" -uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" -version = "1.0.2" - -[[Mmap]] -uuid = "a63ad114-7e13-5084-954f-fe012c677804" - -[[MozillaCACerts_jll]] -uuid = "14a3606d-f60d-562e-9121-12d972cd8159" - -[[MutableArithmetics]] -deps = ["LinearAlgebra", "SparseArrays", "Test"] -git-tree-sha1 = "3927848ccebcc165952dc0d9ac9aa274a87bfe01" -uuid = "d8a4904e-b15c-11e9-3269-09a3773c0cb0" -version = "0.2.20" - -[[NaNMath]] -git-tree-sha1 = "bfe47e760d60b82b66b61d2d44128b62e3a369fb" -uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" -version = "0.3.5" - -[[NetworkOptions]] -uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" - -[[OpenBLAS32_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "ba4a8f683303c9082e84afba96f25af3c7fb2436" -uuid = "656ef2d0-ae68-5445-9ca0-591084a874a2" -version = "0.3.12+1" - -[[OpenSpecFun_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "13652491f6856acfd2db29360e1bbcd4565d04f1" -uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" -version = "0.5.5+0" - -[[OrderedCollections]] -git-tree-sha1 = "85f8e6578bf1f9ee0d11e7bb1b1456435479d47c" -uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.4.1" - -[[Osi_jll]] -deps = ["Artifacts", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "6a9967c4394858f38b7fc49787b983ba3847e73d" -uuid = "7da25872-d9ce-5375-a4d3-7a845f58efdd" -version = "0.108.6+2" - -[[PDMats]] -deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] -git-tree-sha1 = "4dd403333bcf0909341cfe57ec115152f937d7d8" -uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" -version = "0.11.1" - -[[PackageCompiler]] -deps = ["Libdl", "Pkg", "UUIDs"] -git-tree-sha1 = "b8283f57d58e224ce8544934491e389bebdc720c" -uuid = "9b87118b-4619-50d2-8e1e-99f35a4d4d9d" -version = "1.5.0" - -[[Parsers]] -deps = ["Dates"] -git-tree-sha1 = "438d35d2d95ae2c5e8780b330592b6de8494e779" -uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" -version = "2.0.3" - -[[Pkg]] -deps = ["Artifacts", "Dates", "Downloads", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] -uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" - -[[PooledArrays]] -deps = ["DataAPI", "Future"] -git-tree-sha1 = "a193d6ad9c45ada72c14b731a318bedd3c2f00cf" -uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720" -version = "1.3.0" - -[[Preferences]] -deps = ["TOML"] -git-tree-sha1 = "00cfd92944ca9c760982747e9a1d0d5d86ab1e5a" -uuid = "21216c6a-2e73-6563-6e65-726566657250" -version = "1.2.2" - -[[PrettyTables]] -deps = ["Crayons", "Formatting", "Markdown", "Reexport", "Tables"] -git-tree-sha1 = "0d1245a357cc61c8cd61934c07447aa569ff22e6" -uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -version = "1.1.0" - -[[Printf]] -deps = ["Unicode"] -uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" - -[[Profile]] -deps = ["Printf"] -uuid = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" - -[[ProgressBars]] -deps = ["Printf"] -git-tree-sha1 = "938525cc66a4058f6ed75b84acd13a00fbecea11" -uuid = "49802e3a-d2f1-5c88-81d8-b72133a6f568" -version = "1.4.0" - -[[PyCall]] -deps = ["Conda", "Dates", "Libdl", "LinearAlgebra", "MacroTools", "Serialization", "VersionParsing"] -git-tree-sha1 = "169bb8ea6b1b143c5cf57df6d34d022a7b60c6db" -uuid = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" -version = "1.92.3" - -[[QuadGK]] -deps = ["DataStructures", "LinearAlgebra"] -git-tree-sha1 = "78aadffb3efd2155af139781b8a8df1ef279ea39" -uuid = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" -version = "2.4.2" - -[[REPL]] -deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] -uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" - -[[Random]] -deps = ["Serialization"] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" - -[[Reexport]] -git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" -uuid = "189a3867-3050-52da-a836-e630ba90ab69" -version = "1.2.2" - -[[Requires]] -deps = ["UUIDs"] -git-tree-sha1 = "4036a3bd08ac7e968e27c203d45f5fff15020621" -uuid = "ae029012-a4dd-5104-9daa-d747884805df" -version = "1.1.3" - -[[Rmath]] -deps = ["Random", "Rmath_jll"] -git-tree-sha1 = "bf3188feca147ce108c76ad82c2792c57abe7b1f" -uuid = "79098fc4-a85e-5d69-aa6a-4863f24498fa" -version = "0.7.0" - -[[Rmath_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "68db32dff12bb6127bac73c209881191bf0efbb7" -uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" -version = "0.3.0+0" - -[[SHA]] -uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" - -[[SentinelArrays]] -deps = ["Dates", "Random"] -git-tree-sha1 = "54f37736d8934a12a200edea2f9206b03bdf3159" -uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" -version = "1.3.7" - -[[Serialization]] -uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" - -[[SharedArrays]] -deps = ["Distributed", "Mmap", "Random", "Serialization"] -uuid = "1a1011a3-84de-559e-8e89-a11a2f7dc383" - -[[Sockets]] -uuid = "6462fe0b-24de-5631-8697-dd941f90decc" - -[[SortingAlgorithms]] -deps = ["DataStructures"] -git-tree-sha1 = "b3363d7460f7d098ca0912c69b082f75625d7508" -uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" -version = "1.0.1" - -[[SparseArrays]] -deps = ["LinearAlgebra", "Random"] -uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" - -[[SpecialFunctions]] -deps = ["ChainRulesCore", "LogExpFunctions", "OpenSpecFun_jll"] -git-tree-sha1 = "a322a9493e49c5f3a10b50df3aedaf1cdb3244b7" -uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "1.6.1" - -[[StaticArrays]] -deps = ["LinearAlgebra", "Random", "Statistics"] -git-tree-sha1 = "3240808c6d463ac46f1c1cd7638375cd22abbccb" -uuid = "90137ffa-7385-5640-81b9-e52037218182" -version = "1.2.12" - -[[Statistics]] -deps = ["LinearAlgebra", "SparseArrays"] -uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" - -[[StatsAPI]] -git-tree-sha1 = "1958272568dc176a1d881acb797beb909c785510" -uuid = "82ae8749-77ed-4fe6-ae5f-f523153014b0" -version = "1.0.0" - -[[StatsBase]] -deps = ["DataAPI", "DataStructures", "LinearAlgebra", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] -git-tree-sha1 = "8cbbc098554648c84f79a463c9ff0fd277144b6c" -uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" -version = "0.33.10" - -[[StatsFuns]] -deps = ["ChainRulesCore", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] -git-tree-sha1 = "46d7ccc7104860c38b11966dd1f72ff042f382e4" -uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" -version = "0.9.10" - -[[SuiteSparse]] -deps = ["Libdl", "LinearAlgebra", "Serialization", "SparseArrays"] -uuid = "4607b0f0-06f3-5cda-b6b1-a6196a1729e9" - -[[TOML]] -deps = ["Dates"] -uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" - -[[TableTraits]] -deps = ["IteratorInterfaceExtensions"] -git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39" -uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" -version = "1.0.1" - -[[Tables]] -deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"] -git-tree-sha1 = "1162ce4a6c4b7e31e0e6b14486a6986951c73be9" -uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" -version = "1.5.2" - -[[Tar]] -deps = ["ArgTools", "SHA"] -uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" - -[[Test]] -deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] -uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - -[[TimerOutputs]] -deps = ["ExprTools", "Printf"] -git-tree-sha1 = "209a8326c4f955e2442c07b56029e88bb48299c7" -uuid = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" -version = "0.5.12" - -[[TranscodingStreams]] -deps = ["Random", "Test"] -git-tree-sha1 = "216b95ea110b5972db65aa90f88d8d89dcb8851c" -uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" -version = "0.9.6" - -[[URIs]] -git-tree-sha1 = "97bbe755a53fe859669cd907f2d96aee8d2c1355" -uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" -version = "1.3.0" - -[[UUIDs]] -deps = ["Random", "SHA"] -uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" - -[[Unicode]] -uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" - -[[UnitCommitment]] -deps = ["DataStructures", "Distributed", "Distributions", "GZip", "JSON", "JuMP", "LinearAlgebra", "Logging", "MathOptInterface", "PackageCompiler", "Printf", "Random", "SparseArrays"] -path = "/home/axavier/Packages/UnitCommitment/dev/" -uuid = "64606440-39ea-11e9-0f29-3303a1d3d877" -version = "0.2.2" - -[[VersionParsing]] -git-tree-sha1 = "80229be1f670524750d905f8fc8148e5a8c4537f" -uuid = "81def892-9a0e-5fdd-b105-ffc91e053289" -version = "1.2.0" - -[[WeakRefStrings]] -deps = ["DataAPI", "Parsers"] -git-tree-sha1 = "4a4cfb1ae5f26202db4f0320ac9344b3372136b0" -uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5" -version = "1.3.0" - -[[Zlib_jll]] -deps = ["Libdl"] -uuid = "83775a58-1f1d-513f-b197-d71354ab007a" - -[[nghttp2_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" - -[[p7zip_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" diff --git a/docs/benchmarks/Project.toml b/docs/benchmarks/Project.toml deleted file mode 100644 index 93a94b0..0000000 --- a/docs/benchmarks/Project.toml +++ /dev/null @@ -1,5 +0,0 @@ -[deps] -Gurobi = "2e9cd046-0924-5485-92f1-d5272153d98b" -JuMP = "4076af6c-e467-56ae-b986-b466b2749572" -MIPLearn = "2b1277c3-b477-4c49-a15e-7ba350325c68" -UnitCommitment = "64606440-39ea-11e9-0f29-3303a1d3d877" diff --git a/docs/benchmarks/Untitled.ipynb b/docs/benchmarks/Untitled.ipynb deleted file mode 100644 index 8af511f..0000000 --- a/docs/benchmarks/Untitled.ipynb +++ /dev/null @@ -1,68 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "id": "1ab068f7", - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "df = pd.read_csv(\"/tmp/jl_depmrX\")" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "e64d7608", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0 True\n", - "1 True\n", - "2 True\n", - "Name: mip_sense, dtype: bool" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df[\"mip_sense\"] == \"min\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b204b538", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "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.7.10" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/benchmarks/facility.ipynb b/docs/benchmarks/facility.ipynb deleted file mode 100644 index 69f6aa4..0000000 --- a/docs/benchmarks/facility.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "792bbfa2", - "metadata": {}, - "source": [ - "# Facility Location\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/benchmarks/gurobi.env b/docs/benchmarks/gurobi.env deleted file mode 100644 index d14d2bc..0000000 --- a/docs/benchmarks/gurobi.env +++ /dev/null @@ -1,3 +0,0 @@ -OutputFlag 1 -Threads 1 -TimeLimit 3600 diff --git a/docs/benchmarks/knapsack.ipynb b/docs/benchmarks/knapsack.ipynb deleted file mode 100644 index 83145da..0000000 --- a/docs/benchmarks/knapsack.ipynb +++ /dev/null @@ -1,3989 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ed5d6503", - "metadata": {}, - "source": [ - "# Multidimensional Knapsack\n", - "\n", - "### Problem definition\n", - "\n", - "Given a set of $n$ items and $m$ types of resources (also called *knapsacks*), the problem is to find a subset of items that maximizes profit without consuming more resources than it is available. More precisely, the problem is:\n", - "\n", - "$$\n", - "\\begin{align*}\n", - " \\text{maximize}\n", - " & \\sum_{j=1}^n p_j x_j\n", - " \\\\\n", - " \\text{subject to}\n", - " & \\sum_{j=1}^n w_{ij} x_j \\leq b_i\n", - " & \\forall i=1,\\ldots,m \\\\\n", - " & x_j \\in \\{0,1\\}\n", - " & \\forall j=1,\\ldots,n\n", - "\\end{align*}\n", - "$$\n", - "\n", - "### Random instance generator\n", - "\n", - "The class `MultiKnapsackGenerator` can be used to generate random instances of this problem. The number of items $n$ and knapsacks $m$ are sampled from the user-provided probability distributions `n` and `m`. The weights $w_{ij}$ are sampled independently from the provided distribution `w`. The capacity of knapsack $i$ is set to\n", - "\n", - "$$\n", - " b_i = \\alpha_i \\sum_{j=1}^n w_{ij}\n", - "$$\n", - "\n", - "where $\\alpha_i$, the tightness ratio, is sampled from the provided probability\n", - "distribution `alpha`. To make the instances more challenging, the costs of the items\n", - "are linearly correlated to their average weights. More specifically, the price of each\n", - "item $j$ is set to:\n", - "\n", - "$$\n", - " p_j = \\sum_{i=1}^m \\frac{w_{ij}}{m} + K u_j,\n", - "$$\n", - "\n", - "where $K$, the correlation coefficient, and $u_j$, the correlation multiplier, are sampled\n", - "from the provided probability distributions `K` and `u`.\n", - "\n", - "If `fix_w=True` is provided, then $w_{ij}$ are kept the same in all generated instances. This also implies that $n$ and $m$ are kept fixed. Although the prices and capacities are derived from $w_{ij}$, as long as `u` and `K` are not constants, the generated instances will still not be completely identical.\n", - "\n", - "\n", - "If a probability distribution `w_jitter` is provided, then item weights will be set to $w_{ij} \\gamma_{ij}$ where $\\gamma_{ij}$ is sampled from `w_jitter`. When combined with `fix_w=True`, this argument may be used to generate instances where the weight of each item is roughly the same, but not exactly identical, across all instances. The prices of the items and the capacities of the knapsacks will be calculated as above, but using these perturbed weights instead.\n", - "\n", - "By default, all generated prices, weights and capacities are rounded to the nearest integer number. If `round=False` is provided, this rounding will be disabled.\n", - "\n", - "\n", - "
\n", - "References\n", - "\n", - "* **Freville, Arnaud, and Gérard Plateau.** *An efficient preprocessing procedure for the multidimensional 0–1 knapsack problem.* Discrete applied mathematics 49.1-3 (1994): 189-212.\n", - "* **Fréville, Arnaud.** *The multidimensional 0–1 knapsack problem: An overview.* European Journal of Operational Research 155.1 (2004): 1-21.\n", - "
\n", - " \n", - "### Challenge A\n", - "\n", - "* 100 variables, 10 constraints, fixed weights\n", - "* $w \\sim U(0, 1000), \\gamma \\sim U(0.95, 1.05)$\n", - "* $K = 500, u \\sim U(0, 1), \\alpha = 0.25$\n", - "* 512 training instances, 64 test instances\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "8710002b", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 26 s, sys: 1min 6s, total: 1min 32s\n", - "Wall time: 16min 21s\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " 2021-09-10T14:32:17.908751\n", - " image/svg+xml\n", - " \n", - " \n", - " Matplotlib v3.4.2, https://matplotlib.org/\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n" - ], - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%time\n", - "%config InlineBackend.figure_formats = ['svg']\n", - "%matplotlib inline\n", - "\n", - "from miplearn.benchmark import run_benchmarks\n", - "from miplearn.problems.knapsack import MultiKnapsackGenerator\n", - "from scipy.stats import uniform, randint\n", - "\n", - "instances = MultiKnapsackGenerator(\n", - " n=randint(low=100, high=101),\n", - " m=randint(low=10, high=11),\n", - " w=uniform(loc=0.0, scale=1000.0),\n", - " K=uniform(loc=500.0, scale=0.0),\n", - " u=uniform(loc=0.0, scale=1.0),\n", - " alpha=uniform(loc=0.25, scale=0.0),\n", - " fix_w=True,\n", - " w_jitter=uniform(loc=0.95, scale=0.1),\n", - ").generate(576)\n", - "\n", - "run_benchmarks(\n", - " train_instances = instances[:512],\n", - " test_instances = instances[512:],\n", - " n_jobs=16,\n", - ");" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "miplearn", - "language": "python", - "name": "miplearn" - }, - "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.8.10" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/benchmarks/preliminaries.ipynb b/docs/benchmarks/preliminaries.ipynb deleted file mode 100644 index 002c441..0000000 --- a/docs/benchmarks/preliminaries.ipynb +++ /dev/null @@ -1,51 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "cf77634b", - "metadata": {}, - "source": [ - "# Preliminaries\n", - "\n", - "## Benchmark challenges\n", - "\n", - "When evaluating the performance of a conventional MIP solver, *benchmark sets*, such as MIPLIB and TSPLIB, are typically used. The performance of newly proposed solvers or solution techniques are typically measured as the average (or total) running time the solver takes to solve the entire benchmark set. For Learning-Enhanced MIP solvers, it is also necessary to specify what instances should the solver be trained on (the *training instances*) before solving the actual set of instances we are interested in (the *test instances*). If the training instances are very similar to the test instances, we would expect a Learning-Enhanced Solver to present stronger perfomance benefits.\n", - "\n", - "In MIPLearn, each optimization problem comes with a set of **benchmark challenges**, which specify how should the training and test instances be generated. The first challenges are typically easier, in the sense that training and test instances are very similar. Later challenges gradually make the sets more distinct, and therefore harder to learn from.\n", - "\n", - "## Baseline results\n", - "\n", - "To illustrate the performance of `LearningSolver`, and to set a baseline for newly proposed techniques, we present in this page, for each benchmark challenge, a small set of computational results measuring the solution speed of the solver and the solution quality with default parameters. For more detailed computational studies, see [references](index.md#references). We compare three solvers:\n", - "\n", - "* **baseline:** Gurobi 9.1 with default settings (a conventional state-of-the-art MIP solver)\n", - "* **ml-exact:** `LearningSolver` with default settings, using Gurobi as internal MIP solver\n", - "* **ml-heuristic:** Same as above, but with `mode=\"heuristic\"`\n", - "\n", - "All experiments presented here were performed on a Linux workstation (Ubuntu Linux 20.04 LTS) with AMD Ryzen 3950X (16 cores, 32 threads) and 64 GB RAM (DDR4, 3200 MHz). All solvers were restricted to use a single thread, 3600 second time limit, and 16 instances were solved simultaneously at a time." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4f4597c3", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/benchmarks/stab.ipynb b/docs/benchmarks/stab.ipynb deleted file mode 100644 index 4937b32..0000000 --- a/docs/benchmarks/stab.ipynb +++ /dev/null @@ -1,3978 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "e4a9abff", - "metadata": {}, - "source": [ - "# Maximum Weight Stable Set\n", - "\n", - "## Problem definition\n", - "\n", - "Given a simple undirected graph $G=(V,E)$ and weights $w \\in \\mathbb{R}^V$, the problem is to find a stable set $S \\subseteq V$ that maximizes $\\sum_{v \\in V} w_v$. We recall that a subset $S \\subseteq V$ is a *stable set* if no two vertices of $S$ are adjacent. This is one of Karp's 21 NP-complete problems.\n", - "\n", - "## Random instance generator\n", - "\n", - "The class `MaxWeightStableSetGenerator` can be used to generate random instances of this problem, with user-specified probability distributions. When the constructor parameter `fix_graph=True` is provided, one random Erdős-Rényi graph $G_{n,p}$ is generated during the constructor, where $n$ and $p$ are sampled from user-provided probability distributions `n` and `p`. To generate each instance, the generator independently samples each $w_v$ from the user-provided probability distribution `w`. When `fix_graph=False`, a new random graph is generated for each instance, while the remaining parameters are sampled in the same way.\n", - "\n", - "## Challenge A\n", - "\n", - "* Fixed random Erdős-Rényi graph $G_{n,p}$ with $n=200$ and $p=5\\%$\n", - "* Random vertex weights $w_v \\sim U(100, 125)$\n", - "* 512 training instances, 64 test instances" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "f99c2c79", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 43.6 s, sys: 1min 42s, total: 2min 25s\n", - "Wall time: 1min 56s\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " 2021-09-10T14:49:28.604088\n", - " image/svg+xml\n", - " \n", - " \n", - " Matplotlib v3.4.2, https://matplotlib.org/\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n" - ], - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%time\n", - "%config InlineBackend.figure_formats = ['svg']\n", - "%matplotlib inline\n", - "\n", - "from miplearn.benchmark import run_benchmarks\n", - "from miplearn.problems.stab import MaxWeightStableSetGenerator\n", - "from scipy.stats import uniform, randint\n", - "\n", - "instances = MaxWeightStableSetGenerator(\n", - " w=uniform(loc=100.0, scale=25.0),\n", - " n=randint(low=150, high=151),\n", - " p=uniform(loc=0.05, scale=0.0),\n", - " fix_graph=True,\n", - ").generate(576)\n", - "\n", - "run_benchmarks(\n", - " train_instances = instances[:512],\n", - " test_instances = instances[512:],\n", - " n_jobs=16,\n", - ");" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6973a585", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "miplearn", - "language": "python", - "name": "miplearn" - }, - "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.8.10" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/benchmarks/tsp.ipynb b/docs/benchmarks/tsp.ipynb deleted file mode 100644 index 7ab7271..0000000 --- a/docs/benchmarks/tsp.ipynb +++ /dev/null @@ -1,3895 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "bf2bdd0f", - "metadata": {}, - "source": [ - "# Traveling Salesman\n", - "\n", - "### Problem definition\n", - "\n", - "Given a list of cities and the distance between each pair of cities, the problem asks for the\n", - "shortest route starting at the first city, visiting each other city exactly once, then returning\n", - "to the first city. This problem is a generalization of the Hamiltonian path problem, one of Karp's\n", - "21 NP-complete problems.\n", - "\n", - "### Random problem generator\n", - "\n", - "The class `miplearn.problems.tsp.TravelingSalesmanGenerator` can be used to generate random instances of this\n", - "problem. Initially, the generator creates $n$ cities $(x_1,y_1),\\ldots,(x_n,y_n) \\in \\mathbb{R}^2$,\n", - "where $n, x_i$ and $y_i$ are sampled independently from the provided probability distributions `n`,\n", - "`x` and `y`. For each pair of cities $(i,j)$, the distance $d_{i,j}$ between them is set to:\n", - "$$\n", - " d_{i,j} = \\gamma_{i,j} \\sqrt{(x_i-x_j)^2 + (y_i - y_j)^2}\n", - "$$\n", - "where $\\gamma_{i,j}$ is sampled from the distribution `gamma`.\n", - "\n", - "If `fix_cities=True` is provided, the list of cities is kept the same for all generated instances.\n", - "The $\\gamma$ values, and therefore also the distances, are still different.\n", - "\n", - "By default, all distances $d_{i,j}$ are rounded to the nearest integer. If `round=False`\n", - "is provided, this rounding will be disabled.\n", - "\n", - "### Challenge A\n", - "\n", - "* Fixed list of 100 cities in the $[0, 1000]^2$ square\n", - "* $\\gamma_{i,j} \\sim U(0.95, 1.05)$\n", - "* 512 training instances, 64 test instances" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "f7f4490f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CPU times: user 5min 13s, sys: 4min 46s, total: 10min\n", - "Wall time: 32min 2s\n" - ] - }, - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " 2021-09-10T13:57:43.953359\n", - " image/svg+xml\n", - " \n", - " \n", - " Matplotlib v3.4.2, https://matplotlib.org/\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "\n" - ], - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%time\n", - "%config InlineBackend.figure_formats = ['svg']\n", - "%matplotlib inline\n", - "\n", - "from miplearn.benchmark import run_benchmarks\n", - "from miplearn.problems.tsp import TravelingSalesmanGenerator\n", - "from scipy.stats import uniform, randint\n", - "\n", - "instances = TravelingSalesmanGenerator(\n", - " x=uniform(loc=0.0, scale=1000.0),\n", - " y=uniform(loc=0.0, scale=1000.0),\n", - " n=randint(low=100, high=101),\n", - " gamma=uniform(loc=0.95, scale=0.1),\n", - " fix_cities=True,\n", - " round=True,\n", - ").generate(576)\n", - "\n", - "run_benchmarks(\n", - " train_instances = instances[:512],\n", - " test_instances = instances[512:],\n", - " n_jobs=16,\n", - ");" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "miplearn", - "language": "python", - "name": "miplearn" - }, - "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.8.10" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/benchmarks/uc.ipynb b/docs/benchmarks/uc.ipynb deleted file mode 100644 index ccf4091..0000000 --- a/docs/benchmarks/uc.ipynb +++ /dev/null @@ -1,151 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "b1b21dfb", - "metadata": {}, - "source": [ - "# Unit Commitment\n", - "\n", - "TODO" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "5c0dec00", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\u001b[32m\u001b[1m Activating\u001b[22m\u001b[39m environment at `~/Packages/MIPLearn/dev/docs/benchmarks/Project.toml`\n" - ] - } - ], - "source": [ - "using Distributed\n", - "# addprocs(17 - nprocs())\n", - "@everywhere using Pkg\n", - "@everywhere Pkg.activate(\".\")" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "5da58a62", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "┌ Info: Precompiling MIPLearn [2b1277c3-b477-4c49-a15e-7ba350325c68]\n", - "└ @ Base loading.jl:1317\n" - ] - } - ], - "source": [ - "@everywhere using MIPLearn\n", - "@everywhere using MIPLearn.BB\n", - "@everywhere using UnitCommitment\n", - "@everywhere using Gurobi\n", - "@everywhere using Random\n", - "@everywhere using JuMP\n", - "@everywhere using Logging\n", - "@everywhere import UnitCommitment: XavQiuAhm2021\n", - "@everywhere Logging.disable_logging(Logging.Warn);" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "444dbbb1", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "0.0%┣ ┫ 0/45 [00:00<00:-2, -0s/it]\n", - "2.2%┣█ ┫ 1/45 [00:42\"` or `\"=\"`) |\n", + "| `static_obj_offset` | `float` | Constant value added to the objective function |\n", + "| `static_sense` | `str` | `\"min\"` if minimization problem or `\"max\"` otherwise |\n", + "| `static_var_lower_bounds` | `(nvars,)` | Variable lower bounds |\n", + "| `static_var_names` | `(nvars,)` | Variable names |\n", + "| `static_var_obj_coeffs` | `(nvars,)` | Objective coefficients |\n", + "| `static_var_types` | `(nvars,)` | Types of the decision variables (`\"C\"`, `\"B\"` and `\"I\"` for continuous, binary and integer, respectively) |\n", + "| `static_var_upper_bounds` | `(nvars,)` | Variable upper bounds |\n", + "| `lp_constr_basis_status` | `(nconstr,)` | Constraint basis status (`0` for basic, `-1` for non-basic) |\n", + "| `lp_constr_dual_values` | `(nconstr,)` | Constraint dual value (or shadow price) |\n", + "| `lp_constr_sa_rhs_{up,down}` | `(nconstr,)` | Sensitivity information for the constraint RHS |\n", + "| `lp_constr_slacks` | `(nconstr,)` | Constraint slack in the solution to the LP relaxation |\n", + "| `lp_obj_value` | `float` | Optimal value of the LP relaxation |\n", + "| `lp_var_basis_status` | `(nvars,)` | Variable basis status (`0`, `-1`, `-2` or `-3` for basic, non-basic at lower bound, non-basic at upper bound, and superbasic, respectively) |\n", + "| `lp_var_reduced_costs` | `(nvars,)` | Variable reduced costs |\n", + "| `lp_var_sa_{obj,ub,lb}_{up,down}` | `(nvars,)` | Sensitivity information for the variable objective coefficient, lower and upper bound. |\n", + "| `lp_var_values` | `(nvars,)` | Optimal solution to the LP relaxation |\n", + "| `lp_wallclock_time` | `float` | Time taken to solve the LP relaxation (in seconds) |\n", + "| `mip_constr_slacks` | `(nconstrs,)` | Constraint slacks in the best MIP solution |\n", + "| `mip_gap` | `float` | Relative MIP optimality gap |\n", + "| `mip_node_count` | `float` | Number of explored branch-and-bound nodes |\n", + "| `mip_obj_bound` | `float` | Dual bound |\n", + "| `mip_obj_value` | `float` | Value of the best MIP solution |\n", + "| `mip_var_values` | `(nvars,)` | Best MIP solution |\n", + "| `mip_wallclock_time` | `float` | Time taken to solve the MIP (in seconds) |" + ] + }, + { + "cell_type": "markdown", + "id": "f2894594", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "The example below shows how to generate a few random instances of the traveling salesman problem, store its problem data, run the collector and print some of the training data to screen." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ac6f8c6f", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "lp_obj_value = 2909.0\n", + "mip_obj_value = 2921.0\n" + ] + } + ], + "source": [ + "import random\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from glob import glob\n", + "\n", + "from miplearn.problems.tsp import (\n", + " TravelingSalesmanGenerator,\n", + " build_tsp_model,\n", + ")\n", + "from miplearn.io import write_pkl_gz\n", + "from miplearn.h5 import H5File\n", + "from miplearn.collectors.basic import BasicCollector\n", + "\n", + "# Set random seed to make example reproducible.\n", + "random.seed(42)\n", + "np.random.seed(42)\n", + "\n", + "# Generate a few instances of the traveling salesman problem.\n", + "data = TravelingSalesmanGenerator(\n", + " n=randint(low=10, high=11),\n", + " x=uniform(loc=0.0, scale=1000.0),\n", + " y=uniform(loc=0.0, scale=1000.0),\n", + " gamma=uniform(loc=0.90, scale=0.20),\n", + " fix_cities=True,\n", + " round=True,\n", + ").generate(10)\n", + "\n", + "# Save instance data to data/tsp/00000.pkl.gz, data/tsp/00001.pkl.gz, ...\n", + "write_pkl_gz(data, \"data/tsp\")\n", + "\n", + "# Solve all instances and collect basic solution information.\n", + "# Process at most four instances in parallel.\n", + "bc = BasicCollector()\n", + "bc.collect(glob(\"data/tsp/*.pkl.gz\"), build_tsp_model, n_jobs=4)\n", + "\n", + "# Read and print some training data for the first instance.\n", + "with H5File(\"data/tsp/00000.h5\", \"r\") as h5:\n", + " print(\"lp_obj_value = \", h5.get_scalar(\"lp_obj_value\"))\n", + " print(\"mip_obj_value = \", h5.get_scalar(\"mip_obj_value\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "78f0b07a", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "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.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/guide/features.ipynb b/docs/guide/features.ipynb new file mode 100644 index 0000000..2abbece --- /dev/null +++ b/docs/guide/features.ipynb @@ -0,0 +1,334 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cdc6ebe9-d1d4-4de1-9b5a-4fc8ef57b11b", + "metadata": {}, + "source": [ + "# Feature Extractors\n", + "\n", + "In the previous page, we introduced *training data collectors*, which solve the optimization problem and collect raw training data, such as the optimal solution. In this page, we introduce **feature extractors**, which take the raw training data, stored in HDF5 files, and extract relevant information in order to train a machine learning model." + ] + }, + { + "cell_type": "markdown", + "id": "b4026de5", + "metadata": {}, + "source": [ + "\n", + "## Overview\n", + "\n", + "Feature extraction is an important step of the process of building a machine learning model because it helps to reduce the complexity of the data and convert it into a format that is more easily processed. Previous research has proposed converting absolute variable coefficients, for example, into relative values which are invariant to various transformations, such as problem scaling, making them more amenable to learning. Various other transformations have also been described.\n", + "\n", + "In the framework, we treat data collection and feature extraction as two separate steps to accelerate the model development cycle. Specifically, collectors are typically time-consuming, as they often need to solve the problem to optimality, and therefore focus on collecting and storing all data that may or may not be relevant, in its raw format. Feature extractors, on the other hand, focus entirely on filtering the data and improving its representation, and are therefore much faster to run. Experimenting with new data representations, therefore, can be done without resolving the instances.\n", + "\n", + "In MIPLearn, extractors implement the abstract class [FeatureExtractor][FeatureExtractor], which has methods that take as input an [H5File][H5File] and produce either: (i) instance features, which describe the entire instances; (ii) variable features, which describe a particular decision variables; or (iii) constraint features, which describe a particular constraint. The extractor is free to implement only a subset of these methods, if it is known that it will not be used with a machine learning component that requires the other types of features.\n", + "\n", + "[FeatureExtractor]: ../../api/collectors/#miplearn.features.fields.FeaturesExtractor\n", + "[H5File]: ../../api/helpers/#miplearn.h5.H5File" + ] + }, + { + "cell_type": "markdown", + "id": "b2d9736c", + "metadata": {}, + "source": [ + "\n", + "## H5FieldsExtractor\n", + "\n", + "[H5FieldsExtractor][H5FieldsExtractor], the most simple extractor in MIPLearn, simple extracts data that is already available in the HDF5 file, assembles it into a matrix and returns it as-is. The fields used to build instance, variable and constraint features are user-specified. The class also performs checks to ensure that the shapes of the returned matrices make sense." + ] + }, + { + "cell_type": "markdown", + "id": "e8184dff", + "metadata": {}, + "source": [ + "### Example\n", + "\n", + "The example below demonstrates the usage of H5FieldsExtractor in a randomly generated instance of the multi-dimensional knapsack problem." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ed9a18c8", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "instance features (11,) \n", + " [-1531.24308771 -350. -692. -454.\n", + " -709. -605. -543. -321.\n", + " -674. -571. -341. ]\n", + "variable features (10, 4) \n", + " [[-1.53124309e+03 -3.50000000e+02 0.00000000e+00 9.43468018e+01]\n", + " [-1.53124309e+03 -6.92000000e+02 2.51703322e-01 0.00000000e+00]\n", + " [-1.53124309e+03 -4.54000000e+02 0.00000000e+00 8.25504150e+01]\n", + " [-1.53124309e+03 -7.09000000e+02 1.11373022e-01 0.00000000e+00]\n", + " [-1.53124309e+03 -6.05000000e+02 1.00000000e+00 -1.26055283e+02]\n", + " [-1.53124309e+03 -5.43000000e+02 0.00000000e+00 1.68693771e+02]\n", + " [-1.53124309e+03 -3.21000000e+02 1.07488781e-01 0.00000000e+00]\n", + " [-1.53124309e+03 -6.74000000e+02 8.82293701e-01 0.00000000e+00]\n", + " [-1.53124309e+03 -5.71000000e+02 0.00000000e+00 1.41129074e+02]\n", + " [-1.53124309e+03 -3.41000000e+02 1.28830120e-01 0.00000000e+00]]\n", + "constraint features (5, 3) \n", + " [[ 1.3100000e+03 -1.5978307e-01 0.0000000e+00]\n", + " [ 9.8800000e+02 -3.2881632e-01 0.0000000e+00]\n", + " [ 1.0040000e+03 -4.0601316e-01 0.0000000e+00]\n", + " [ 1.2690000e+03 -1.3659772e-01 0.0000000e+00]\n", + " [ 1.0070000e+03 -2.8800571e-01 0.0000000e+00]]\n" + ] + } + ], + "source": [ + "from glob import glob\n", + "from shutil import rmtree\n", + "\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "\n", + "from miplearn.collectors.basic import BasicCollector\n", + "from miplearn.extractors.fields import H5FieldsExtractor\n", + "from miplearn.h5 import H5File\n", + "from miplearn.io import write_pkl_gz\n", + "from miplearn.problems.multiknapsack import (\n", + " MultiKnapsackGenerator,\n", + " build_multiknapsack_model,\n", + ")\n", + "\n", + "# Set random seed to make example reproducible\n", + "np.random.seed(42)\n", + "\n", + "# Generate some random multiknapsack instances\n", + "rmtree(\"data/multiknapsack/\", ignore_errors=True)\n", + "write_pkl_gz(\n", + " MultiKnapsackGenerator(\n", + " n=randint(low=10, high=11),\n", + " m=randint(low=5, high=6),\n", + " w=uniform(loc=0, scale=1000),\n", + " K=uniform(loc=100, scale=0),\n", + " u=uniform(loc=1, scale=0),\n", + " alpha=uniform(loc=0.25, scale=0),\n", + " w_jitter=uniform(loc=0.95, scale=0.1),\n", + " p_jitter=uniform(loc=0.75, scale=0.5),\n", + " fix_w=True,\n", + " ).generate(10),\n", + " \"data/multiknapsack\",\n", + ")\n", + "\n", + "# Run the basic collector\n", + "BasicCollector().collect(\n", + " glob(\"data/multiknapsack/*\"),\n", + " build_multiknapsack_model,\n", + " n_jobs=4,\n", + ")\n", + "\n", + "ext = H5FieldsExtractor(\n", + " # Use as instance features the value of the LP relaxation and the\n", + " # vector of objective coefficients.\n", + " instance_fields=[\n", + " \"lp_obj_value\",\n", + " \"static_var_obj_coeffs\",\n", + " ],\n", + " # For each variable, use as features the optimal value of the LP\n", + " # relaxation, the variable objective coefficient, the variable's\n", + " # value its reduced cost.\n", + " var_fields=[\n", + " \"lp_obj_value\",\n", + " \"static_var_obj_coeffs\",\n", + " \"lp_var_values\",\n", + " \"lp_var_reduced_costs\",\n", + " ],\n", + " # For each constraint, use as features the RHS, dual value and slack.\n", + " constr_fields=[\n", + " \"static_constr_rhs\",\n", + " \"lp_constr_dual_values\",\n", + " \"lp_constr_slacks\",\n", + " ],\n", + ")\n", + "\n", + "with H5File(\"data/multiknapsack/00000.h5\") as h5:\n", + " # Extract and print instance features\n", + " x1 = ext.get_instance_features(h5)\n", + " print(\"instance features\", x1.shape, \"\\n\", x1)\n", + "\n", + " # Extract and print variable features\n", + " x2 = ext.get_var_features(h5)\n", + " print(\"variable features\", x2.shape, \"\\n\", x2)\n", + "\n", + " # Extract and print constraint features\n", + " x3 = ext.get_constr_features(h5)\n", + " print(\"constraint features\", x3.shape, \"\\n\", x3)\n" + ] + }, + { + "cell_type": "markdown", + "id": "2da2e74e", + "metadata": {}, + "source": [ + "\n", + "[H5FieldsExtractor]: ../../api/collectors/#miplearn.features.fields.H5FieldsExtractor" + ] + }, + { + "cell_type": "markdown", + "id": "d879c0d3", + "metadata": {}, + "source": [ + "
\n", + "Warning\n", + "\n", + "You should ensure that the number of features remains the same for all relevant HDF5 files. In the previous example, to illustrate this issue, we used variable objective coefficients as instance features. While this is allowed, note that this requires all problem instances to have the same number of variables; otherwise the number of features would vary from instance to instance and MIPLearn would be unable to concatenate the matrices.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "cd0ba071", + "metadata": {}, + "source": [ + "## AlvLouWeh2017Extractor\n", + "\n", + "Alvarez, Louveaux and Wehenkel (2017) proposed a set features to describe a particular decision variable in a given node of the branch-and-bound tree, and applied it to the problem of mimicking strong branching decisions. The class [AlvLouWeh2017Extractor][] implements a subset of these features (40 out of 64), which are available outside of the branch-and-bound tree. Some features are derived from the static defintion of the problem (i.e. from objective function and constraint data), while some features are derived from the solution to the LP relaxation. The features have been designed to be: (i) independent of the size of the problem; (ii) invariant with respect to irrelevant problem transformations, such as row and column permutation; and (iii) independent of the scale of the problem. We refer to the paper for a more complete description.\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a1bc38fe", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "x1 (10, 40) \n", + " [[-1.00e+00 1.00e+20 1.00e-01 1.00e+00 0.00e+00 1.00e+00 6.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 6.00e-01 1.00e+00 1.75e+01 1.00e+00 2.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 -1.00e+00 0.00e+00 1.00e+20]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 1.00e-01 1.00e+00 1.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 7.00e-01 1.00e+00 5.10e+00 1.00e+00 2.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 3.00e-01 -1.00e+00 -1.00e+00 0.00e+00 0.00e+00]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 0.00e+00 1.00e+00 9.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 5.00e-01 1.00e+00 1.30e+01 1.00e+00 2.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 -1.00e+00 0.00e+00 1.00e+20]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 2.00e-01 1.00e+00 9.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 8.00e-01 1.00e+00 3.40e+00 1.00e+00 2.00e-01\n", + " 1.00e+00 1.00e-01 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 1.00e-01 -1.00e+00 -1.00e+00 0.00e+00 0.00e+00]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 1.00e-01 1.00e+00 7.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 6.00e-01 1.00e+00 3.80e+00 1.00e+00 2.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 -1.00e+00 -1.00e+00 0.00e+00 0.00e+00]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 1.00e-01 1.00e+00 8.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 7.00e-01 1.00e+00 3.30e+00 1.00e+00 2.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 -1.00e+00 0.00e+00 1.00e+20]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 0.00e+00 1.00e+00 3.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 1.00e+00 1.00e+00 5.70e+00 1.00e+00 1.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 1.00e-01 -1.00e+00 -1.00e+00 0.00e+00 0.00e+00]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 1.00e-01 1.00e+00 6.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 8.00e-01 1.00e+00 6.80e+00 1.00e+00 2.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 1.00e-01 -1.00e+00 -1.00e+00 0.00e+00 0.00e+00]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 4.00e-01 1.00e+00 6.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 8.00e-01 1.00e+00 1.40e+00 1.00e+00 1.00e-01\n", + " 1.00e+00 1.00e-01 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 -1.00e+00 0.00e+00 1.00e+20]\n", + " [-1.00e+00 1.00e+20 1.00e-01 1.00e+00 0.00e+00 1.00e+00 5.00e-01\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 1.00e+00 5.00e-01 1.00e+00 7.60e+00 1.00e+00 1.00e-01\n", + " 1.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00 0.00e+00\n", + " 1.00e-01 -1.00e+00 -1.00e+00 0.00e+00 0.00e+00]]\n" + ] + } + ], + "source": [ + "from miplearn.extractors.AlvLouWeh2017 import AlvLouWeh2017Extractor\n", + "from miplearn.h5 import H5File\n", + "\n", + "# Build the extractor\n", + "ext = AlvLouWeh2017Extractor()\n", + "\n", + "# Open previously-created multiknapsack training data\n", + "with H5File(\"data/multiknapsack/00000.h5\") as h5:\n", + " # Extract and print variable features\n", + " x1 = ext.get_var_features(h5)\n", + " print(\"x1\", x1.shape, \"\\n\", x1.round(1))" + ] + }, + { + "cell_type": "markdown", + "id": "286c9927", + "metadata": {}, + "source": [ + "
\n", + "References\n", + "\n", + "* **Alvarez, Alejandro Marcos.** *Computational and theoretical synergies between linear optimization and supervised machine learning.* (2016). University of Liège.\n", + "* **Alvarez, Alejandro Marcos, Quentin Louveaux, and Louis Wehenkel.** *A machine learning-based approximation of strong branching.* INFORMS Journal on Computing 29.1 (2017): 185-195.\n", + "\n", + "
" + ] + } + ], + "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.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/guide/primal.ipynb b/docs/guide/primal.ipynb new file mode 100644 index 0000000..3aedb9c --- /dev/null +++ b/docs/guide/primal.ipynb @@ -0,0 +1,291 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "880cf4c7-d3c4-4b92-85c7-04a32264cdae", + "metadata": {}, + "source": [ + "# Primal Components\n", + "\n", + "In MIPLearn, a **primal component** is class that uses machine learning to predict a (potentially partial) assignment of values to the decision variables of the problem. Predicting high-quality primal solutions may be beneficial, as they allow the MIP solver to prune potentially large portions of the search space. Alternatively, if proof of optimality is not required, the MIP solver can be used to complete the partial solution generated by the machine learning model and and double-check its feasibility. MIPLearn allows both of these usage patterns.\n", + "\n", + "In this page, we describe the four primal components currently included in MIPLearn, which employ machine learning in different ways. Each component is highly configurable, and accepts an user-provided machine learning model, which it uses for all predictions. Each component can also be configured to provide the solution to the solver in multiple ways, depending on whether proof of optimality is required.\n", + "\n", + "## Primal component actions\n", + "\n", + "Before presenting the primal components themselves, we briefly discuss the three ways a solution may be provided to the solver. Each approach has benefits and limitations, which we also discuss in this section. All primal components can be configured to use any of the following approaches.\n", + "\n", + "The first approach is to provide the solution to the solver as a **warm start**. This is implemented by the class [SetWarmStart](SetWarmStart). The main advantage is that this method maintains all optimality and feasibility guarantees of the MIP solver, while still providing significant performance benefits for various classes of problems. If the machine learning model is able to predict multiple solutions, it is also possible to set multiple warm starts. In this case, the solver evaluates each warm start, discards the infeasible ones, then proceeds with the one that has the best objective value. The main disadvantage of this approach, compared to the next two, is that it provides relatively modest speedups for most problem classes, and no speedup at all for many others, even when the machine learning predictions are 100% accurate.\n", + "\n", + "[SetWarmStart]: ../../api/components/#miplearn.components.primal.actions.SetWarmStart\n", + "\n", + "The second approach is to **fix the decision variables** to their predicted values, then solve a restricted optimization problem on the remaining variables. This approach is implemented by the class `FixVariables`. The main advantage is its potential speedup: if machine learning can accurately predict values for a significant portion of the decision variables, then the MIP solver can typically complete the solution in a small fraction of the time it would take to find the same solution from scratch. The main disadvantage of this approach is that it loses optimality guarantees; that is, the complete solution found by the MIP solver may no longer be globally optimal. Also, if the machine learning predictions are not sufficiently accurate, there might not even be a feasible assignment for the variables that were left free.\n", + "\n", + "Finally, the third approach, which tries to strike a balance between the two previous ones, is to **enforce proximity** to a given solution. This strategy is implemented by the class `EnforceProximity`. More precisely, given values $\\bar{x}_1,\\ldots,\\bar{x}_n$ for a subset of binary decision variables $x_1,\\ldots,x_n$, this approach adds the constraint\n", + "\n", + "$$\n", + "\\sum_{i : \\bar{x}_i=0} x_i + \\sum_{i : \\bar{x}_i=1} \\left(1 - x_i\\right) \\leq k,\n", + "$$\n", + "to the problem, where $k$ is a user-defined parameter, which indicates how many of the predicted variables are allowed to deviate from the machine learning suggestion. The main advantage of this approach, compared to fixing variables, is its tolerance to lower-quality machine learning predictions. Its main disadvantage is that it typically leads to smaller speedups, especially for larger values of $k$. This approach also loses optimality guarantees.\n", + "\n", + "## Memorizing primal component\n", + "\n", + "A simple machine learning strategy for the prediction of primal solutions is to memorize all distinct solutions seen during training, then try to predict, during inference time, which of those memorized solutions are most likely to be feasible and to provide a good objective value for the current instance. The most promising solutions may alternatively be combined into a single partial solution, which is then provided to the MIP solver. Both variations of this strategy are implemented by the `MemorizingPrimalComponent` class. Note that it is only applicable if the problem size, and in fact if the meaning of the decision variables, remains the same across problem instances.\n", + "\n", + "More precisely, let $I_1,\\ldots,I_n$ be the training instances, and let $\\bar{x}^1,\\ldots,\\bar{x}^n$ be their respective optimal solutions. Given a new instance $I_{n+1}$, `MemorizingPrimalComponent` expects a user-provided binary classifier that assigns (through the `predict_proba` method, following scikit-learn's conventions) a score $\\delta_i$ to each solution $\\bar{x}^i$, such that solutions with higher score are more likely to be good solutions for $I_{n+1}$. The features provided to the classifier are the instance features computed by an user-provided extractor. Given these scores, the component then performs one of the following to actions, as decided by the user:\n", + "\n", + "1. Selects the top $k$ solutions with the highest scores and provides them to the solver; this is implemented by `SelectTopSolutions`, and it is typically used with the `SetWarmStart` action.\n", + "\n", + "2. Merges the top $k$ solutions into a single partial solution, then provides it to the solver. This is implemented by `MergeTopSolutions`. More precisely, suppose that the machine learning regressor ordered the solutions in the sequence $\\bar{x}^{i_1},\\ldots,\\bar{x}^{i_n}$, with the most promising solutions appearing first, and with ties being broken arbitrarily. The component starts by keeping only the $k$ most promising solutions $\\bar{x}^{i_1},\\ldots,\\bar{x}^{i_k}$. Then it computes, for each binary decision variable $x_l$, its average assigned value $\\tilde{x}_l$:\n", + "$$\n", + " \\tilde{x}_l = \\frac{1}{k} \\sum_{j=1}^k \\bar{x}^{i_j}_l.\n", + "$$\n", + " Finally, the component constructs a merged solution $y$, defined as:\n", + "$$\n", + " y_j = \\begin{cases}\n", + " 0 & \\text{ if } \\tilde{x}_l \\le \\theta_0 \\\\\n", + " 1 & \\text{ if } \\tilde{x}_l \\ge \\theta_1 \\\\\n", + " \\square & \\text{otherwise,}\n", + " \\end{cases}\n", + "$$\n", + " where $\\theta_0$ and $\\theta_1$ are user-specified parameters, and where $\\square$ indicates that the variable is left undefined. The solution $y$ is then provided by the solver using any of the three approaches defined in the previous section.\n", + "\n", + "The above specification of `MemorizingPrimalComponent` is meant to be as general as possible. Simpler strategies can be implemented by configuring this component in specific ways. For example, a simpler approach employed in the literature is to collect all optimal solutions, then provide the entire list of solutions to the solver as warm starts, without any filtering or post-processing. This strategy can be implemented with `MemorizingPrimalComponent` by using a model that returns a constant value for all solutions (e.g. [scikit-learn's DummyClassifier][DummyClassifier]), then selecting the top $n$ (instead of $k$) solutions. See example below. Another simple approach is taking the solution to the most similar instance, and using it, by itself, as a warm start. This can be implemented by using a model that computes distances between the current instance and the training ones (e.g. [scikit-learn's KNeighborsClassifier][KNeighborsClassifier]), then select the solution to the nearest one. See also example below. More complex strategies, of course, can also be configured.\n", + "\n", + "[DummyClassifier]: https://scikit-learn.org/stable/modules/generated/sklearn.dummy.DummyClassifier.html\n", + "[KNeighborsClassifier]: https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html\n", + "\n", + "### Examples" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "253adbf4", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "from sklearn.dummy import DummyClassifier\n", + "from sklearn.neighbors import KNeighborsClassifier\n", + "\n", + "from miplearn.components.primal.actions import (\n", + " SetWarmStart,\n", + " FixVariables,\n", + " EnforceProximity,\n", + ")\n", + "from miplearn.components.primal.mem import (\n", + " MemorizingPrimalComponent,\n", + " SelectTopSolutions,\n", + " MergeTopSolutions,\n", + ")\n", + "from miplearn.extractors.dummy import DummyExtractor\n", + "from miplearn.extractors.fields import H5FieldsExtractor\n", + "\n", + "# Configures a memorizing primal component that collects\n", + "# all distinct solutions seen during training and provides\n", + "# them to the solver without any filtering or post-processing.\n", + "comp1 = MemorizingPrimalComponent(\n", + " clf=DummyClassifier(),\n", + " extractor=DummyExtractor(),\n", + " constructor=SelectTopSolutions(1_000_000),\n", + " action=SetWarmStart(),\n", + ")\n", + "\n", + "# Configures a memorizing primal component that finds the\n", + "# training instance with the closest objective function, then\n", + "# fixes the decision variables to the values they assumed\n", + "# at the optimal solution for that instance.\n", + "comp2 = MemorizingPrimalComponent(\n", + " clf=KNeighborsClassifier(n_neighbors=1),\n", + " extractor=H5FieldsExtractor(\n", + " instance_fields=[\"static_var_obj_coeffs\"],\n", + " ),\n", + " constructor=SelectTopSolutions(1),\n", + " action=FixVariables(),\n", + ")\n", + "\n", + "# Configures a memorizing primal component that finds the distinct\n", + "# solutions to the 10 most similar training problem instances,\n", + "# selects the 3 solutions that were most often optimal to these\n", + "# training instances, combines them into a single partial solution,\n", + "# then enforces proximity, allowing at most 3 variables to deviate\n", + "# from the machine learning suggestion.\n", + "comp3 = MemorizingPrimalComponent(\n", + " clf=KNeighborsClassifier(n_neighbors=10),\n", + " extractor=H5FieldsExtractor(instance_fields=[\"static_var_obj_coeffs\"]),\n", + " constructor=MergeTopSolutions(k=3, thresholds=[0.25, 0.75]),\n", + " action=EnforceProximity(3),\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "f194a793", + "metadata": {}, + "source": [ + "## Independent vars primal component\n", + "\n", + "Instead of memorizing previously-seen primal solutions, it is also natural to use machine learning models to directly predict the values of the decision variables, constructing a solution from scratch. This approach has the benefit of potentially constructing novel high-quality solutions, never observed in the training data. Two variations of this strategy are supported by MIPLearn: (i) predicting the values of the decision variables independently, using multiple ML models; or (ii) predicting the values jointly, with a single model. We describe the first variation in this section, and the second variation in the next section.\n", + "\n", + "Let $I_1,\\ldots,I_n$ be the training instances, and let $\\bar{x}^1,\\ldots,\\bar{x}^n$ be their respective optimal solutions. For each binary decision variable $x_j$, the component `IndependentVarsPrimalComponent` creates a copy of a user-provided binary classifier and trains it to predict the optimal value of $x_j$, given $\\bar{x}^1_j,\\ldots,\\bar{x}^n_j$ as training labels. The features provided to the model are the variable features computed by an user-provided extractor. During inference time, the component uses these $n$ binary classifiers to construct a solution and provides it to the solver using one of the available actions.\n", + "\n", + "Three issues often arise in practice when using this approach:\n", + "\n", + " 1. For certain binary variables $x_j$, it is frequently the case that its optimal value is either always zero or always one in the training dataset, which poses problems to some standard scikit-learn classifiers, since they do not expect a single class. The wrapper `SingleClassFix` can be used to fix this issue (see example below).\n", + "2. It is also frequently the case that machine learning classifier can only reliably predict the values of some variables with high accuracy, not all of them. In this situation, instead of computing a complete primal solution, it may be more beneficial to construct a partial solution containing values only for the variables for which the ML made a high-confidence prediction. The meta-classifier `MinProbabilityClassifier` can be used for this purpose. It asks the base classifier for the probability of the value being zero or one (using the `predict_proba` method) and erases from the primal solution all values whose probabilities are below a given threshold.\n", + "3. To make multiple copies of the provided ML classifier, MIPLearn uses the standard `sklearn.base.clone` method, which may not be suitable for classifiers from other frameworks. To handle this, it is possible to override the clone function using the `clone_fn` constructor argument.\n", + "\n", + "### Examples" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3fc0b5d1", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "from sklearn.linear_model import LogisticRegression\n", + "from miplearn.classifiers.minprob import MinProbabilityClassifier\n", + "from miplearn.classifiers.singleclass import SingleClassFix\n", + "from miplearn.components.primal.indep import IndependentVarsPrimalComponent\n", + "from miplearn.extractors.AlvLouWeh2017 import AlvLouWeh2017Extractor\n", + "from miplearn.components.primal.actions import SetWarmStart\n", + "\n", + "# Configures a primal component that independently predicts the value of each\n", + "# binary variable using logistic regression and provides it to the solver as\n", + "# warm start. Erases predictions with probability less than 99%; applies\n", + "# single-class fix; and uses AlvLouWeh2017 features.\n", + "comp = IndependentVarsPrimalComponent(\n", + " base_clf=SingleClassFix(\n", + " MinProbabilityClassifier(\n", + " base_clf=LogisticRegression(),\n", + " thresholds=[0.99, 0.99],\n", + " ),\n", + " ),\n", + " extractor=AlvLouWeh2017Extractor(),\n", + " action=SetWarmStart(),\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "45107a0c", + "metadata": {}, + "source": [ + "## Joint vars primal component\n", + "In the previous subsection, we used multiple machine learning models to independently predict the values of the binary decision variables. When these values are correlated, an alternative approach is to jointly predict the values of all binary variables using a single machine learning model. This strategy is implemented by `JointVarsPrimalComponent`. Compared to the previous ones, this component is much more straightforwad. It simply extracts instance features, using the user-provided feature extractor, then directly trains the user-provided binary classifier (using the `fit` method), without making any copies. The trained classifier is then used to predict entire solutions (using the `predict` method), which are given to the solver using one of the previously discussed methods. In the example below, we illustrate the usage of this component with a simple feed-forward neural network.\n", + "\n", + "`JointVarsPrimalComponent` can also be used to implement strategies that use multiple machine learning models, but not indepedently. For example, a common strategy in multioutput prediction is building a *classifier chain*. In this approach, the first decision variable is predicted using the instance features alone; but the $n$-th decision variable is predicted using the instance features plus the predicted values of the $n-1$ previous variables. This can be easily implemented using scikit-learn's `ClassifierChain` estimator, as shown in the example below.\n", + "\n", + "### Examples" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cf9b52dd", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "from sklearn.multioutput import ClassifierChain\n", + "from sklearn.neural_network import MLPClassifier\n", + "from miplearn.components.primal.joint import JointVarsPrimalComponent\n", + "from miplearn.extractors.fields import H5FieldsExtractor\n", + "from miplearn.components.primal.actions import SetWarmStart\n", + "\n", + "# Configures a primal component that uses a feedforward neural network\n", + "# to jointly predict the values of the binary variables, based on the\n", + "# objective cost function, and provides the solution to the solver as\n", + "# a warm start.\n", + "comp = JointVarsPrimalComponent(\n", + " clf=MLPClassifier(),\n", + " extractor=H5FieldsExtractor(\n", + " instance_fields=[\"static_var_obj_coeffs\"],\n", + " ),\n", + " action=SetWarmStart(),\n", + ")\n", + "\n", + "# Configures a primal component that uses a chain of logistic regression\n", + "# models to jointly predict the values of the binary variables, based on\n", + "# the objective function.\n", + "comp = JointVarsPrimalComponent(\n", + " clf=ClassifierChain(SingleClassFix(LogisticRegression())),\n", + " extractor=H5FieldsExtractor(\n", + " instance_fields=[\"static_var_obj_coeffs\"],\n", + " ),\n", + " action=SetWarmStart(),\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "dddf7be4", + "metadata": {}, + "source": [ + "## Expert primal component\n", + "\n", + "Before spending time and effort choosing a machine learning strategy and tweaking its parameters, it is usually a good idea to evaluate what would be the performance impact of the model if its predictions were 100% accurate. This is especially important for the prediction of warm starts, since they are not always very beneficial. To simplify this task, MIPLearn provides `ExpertPrimalComponent`, a component which simply loads the optimal solution from the HDF5 file, assuming that it has already been computed, then directly provides it to the solver using one of the available methods. This component is useful in benchmarks, to evaluate how close to the best theoretical performance the machine learning components are.\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9e2e81b9", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "from miplearn.components.primal.expert import ExpertPrimalComponent\n", + "from miplearn.components.primal.actions import SetWarmStart\n", + "\n", + "# Configures an expert primal component, which reads a pre-computed\n", + "# optimal solution from the HDF5 file and provides it to the solver\n", + "# as warm start.\n", + "comp = ExpertPrimalComponent(action=SetWarmStart())\n" + ] + } + ], + "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.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/guide/problems.ipynb b/docs/guide/problems.ipynb new file mode 100644 index 0000000..948a7ff --- /dev/null +++ b/docs/guide/problems.ipynb @@ -0,0 +1,1567 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f89436b4-5bc5-4ae3-a20a-522a2cd65274", + "metadata": {}, + "source": [ + "# Benchmark Problems\n", + "\n", + "## Overview\n", + "\n", + "Benchmark sets such as [MIPLIB](https://miplib.zib.de/) or [TSPLIB](http://comopt.ifi.uni-heidelberg.de/software/TSPLIB95/) are usually employed to evaluate the performance of conventional MIP solvers. Two shortcomings, however, make existing benchmark sets less suitable for evaluating the performance of learning-enhanced MIP solvers: (i) while existing benchmark sets typically contain hundreds or thousands of instances, machine learning (ML) methods typically benefit from having orders of magnitude more instances available for training; (ii) current machine learning methods typically provide best performance on sets of homogeneous instances, buch general-purpose benchmark sets contain relatively few examples of each problem type.\n", + "\n", + "To tackle this challenge, MIPLearn provides random instance generators for a wide variety of classical optimization problems, covering applications from different fields, that can be used to evaluate new learning-enhanced MIP techniques in a measurable and reproducible way. As of MIPLearn 0.3, nine problem generators are available, each customizable with user-provided probability distribution and flexible parameters. The generators can be configured, for example, to produce large sets of very similar instances of same size, where only the objective function changes, or more diverse sets of instances, with various sizes and characteristics, belonging to a particular problem class.\n", + "\n", + "In the following, we describe the problems included in the library, their MIP formulation and the generation algorithm." + ] + }, + { + "cell_type": "markdown", + "id": "bd99c51f", + "metadata": {}, + "source": [ + "
\n", + "Warning\n", + "\n", + "The random instance generators and formulations shown below are subject to change. If you use them in your research, for reproducibility, you should specify the MIPLearn version and all parameters.\n", + "
\n", + "\n", + "
\n", + "Note\n", + "\n", + "- To make the instances easier to process, all formulations are written as a minimization problem.\n", + "- Some problem formulations, such as the one for the *traveling salesman problem*, contain an exponential number of constraints, which are enforced through constraint generation. The MPS files for these problems contain only the constraints that were generated during a trial run, not the entire set of constraints. Resolving the MPS file, therefore, may not generate a feasible primal solution for the problem.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "830f3784-a3fc-4e2f-a484-e7808841ffe8", + "metadata": { + "jp-MarkdownHeadingCollapsed": true, + "tags": [] + }, + "source": [ + "## Bin Packing\n", + "\n", + "**Bin packing** is a combinatorial optimization problem that asks for the optimal way to pack a given set of items into a finite number of containers (or bins) of fixed capacity. More specifically, the problem is to assign indivisible items of different sizes to identical bins, while minimizing the number of bins used. The problem is NP-hard and has many practical applications, including logistics and warehouse management, where it is used to determine how to best store and transport goods using a limited amount of space." + ] + }, + { + "cell_type": "markdown", + "id": "af933298-92a9-4c5d-8d07-0d4918dedbb8", + "metadata": { + "tags": [] + }, + "source": [ + "### Formulation\n", + "\n", + "Let $n$ be the number of items, and $s_i$ the size of the $i$-th item. Also let $B$ be the size of the bins. For each bin $j$, let $y_j$ be a binary decision variable which equals one if the bin is used. For every item-bin pair $(i,j)$, let $x_{ij}$ be a binary decision variable which equals one if item $i$ is assigned to bin $j$. The bin packing problem is formulated as:" + ] + }, + { + "cell_type": "markdown", + "id": "5e502345", + "metadata": {}, + "source": [ + "\n", + "$$\n", + "\\begin{align*}\n", + "\\text{minimize} \\;\\;\\;\n", + " & \\sum_{j=1}^n y_j \\\\\n", + "\\text{subject to} \\;\\;\\;\n", + " & \\sum_{i=1}^n s_i x_{ij} \\leq B y_j & \\forall j=1,\\ldots,n \\\\\n", + " & \\sum_{j=1}^n x_{ij} = 1 & \\forall i=1,\\ldots,n \\\\\n", + " & y_i \\in \\{0,1\\} & \\forall i=1,\\ldots,n \\\\\n", + " & x_{ij} \\in \\{0,1\\} & \\forall i,j=1,\\ldots,n \\\\\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "9cba2077", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "Random instances of the bin packing problem can be generated using the class [BinPackGenerator][BinPackGenerator].\n", + "\n", + "If `fix_items=False`, the class samples the user-provided probability distributions `n`, `sizes` and `capacity` to decide, respectively, the number of items, the sizes of the items and capacity of the bin. All values are sampled independently.\n", + "\n", + "If `fix_items=True`, the class creates a reference instance, using the method previously described, then generates additional instances by perturbing its item sizes and bin capacity. More specifically, the sizes of the items are set to $s_i \\gamma_i$, where $s_i$ is the size of the $i$-th item in the reference instance and $\\gamma_i$ is sampled from `sizes_jitter`. Similarly, the bin size is set to $B \\beta$, where $B$ is the reference bin size and $\\beta$ is sampled from `capacity_jitter`. The number of items remains the same across all generated instances.\n", + "\n", + "[BinPackGenerator]: ../../api/problems/#miplearn.problems.binpack.BinPackGenerator" + ] + }, + { + "cell_type": "markdown", + "id": "2bc62803", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "f14e560c-ef9f-4c48-8467-72d6acce5f9f", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0 [ 8.47 26. 19.52 14.11 3.65 3.65 1.4 21.76 14.82 16.96] 102.24\n", + "1 [ 8.69 22.78 17.81 14.83 4.12 3.67 1.46 22.05 13.66 18.08] 93.41\n", + "2 [ 8.55 25.9 20. 15.89 3.75 3.59 1.51 21.4 13.89 17.68] 90.69\n", + "3 [10.13 22.62 18.89 14.4 3.92 3.94 1.36 23.69 15.85 19.26] 107.9\n", + "4 [ 9.55 25.77 16.79 14.06 3.55 3.76 1.42 20.66 16.02 17.19] 95.62\n", + "5 [ 9.44 22.06 19.41 13.69 4.28 4.11 1.36 19.51 15.98 18.43] 104.58\n", + "6 [ 9.87 21.74 17.78 13.82 4.18 4. 1.4 19.76 14.46 17.08] 104.59\n", + "7 [ 9.62 25.61 18.2 13.83 4.07 4.1 1.47 22.83 15.01 17.78] 98.55\n", + "8 [ 8.47 21.9 16.58 15.37 3.76 3.91 1.57 20.57 14.76 18.61] 94.58\n", + "9 [ 8.57 22.77 17.06 16.25 4.14 4. 1.56 22.97 14.09 19.09] 100.79\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 20 rows, 110 columns and 210 nonzeros\n", + "Model fingerprint: 0x1ff9913f\n", + "Variable types: 0 continuous, 110 integer (110 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+02]\n", + " Objective range [1e+00, 1e+00]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 1e+00]\n", + "Found heuristic solution: objective 5.0000000\n", + "Presolve time: 0.00s\n", + "Presolved: 20 rows, 110 columns, 210 nonzeros\n", + "Variable types: 0 continuous, 110 integer (110 binary)\n", + "\n", + "Root relaxation: objective 1.274844e+00, 38 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 1.27484 0 4 5.00000 1.27484 74.5% - 0s\n", + "H 0 0 4.0000000 1.27484 68.1% - 0s\n", + "H 0 0 2.0000000 1.27484 36.3% - 0s\n", + " 0 0 1.27484 0 4 2.00000 1.27484 36.3% - 0s\n", + "\n", + "Explored 1 nodes (38 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 3: 2 4 5 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.000000000000e+00, best bound 2.000000000000e+00, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/axavier/.conda/envs/miplearn2/lib/python3.9/site-packages/tqdm/auto.py:22: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.binpack import BinPackGenerator, build_binpack_model\n", + "\n", + "# Set random seed, to make example reproducible\n", + "np.random.seed(42)\n", + "\n", + "# Generate random instances of the binpack problem with ten items\n", + "data = BinPackGenerator(\n", + " n=randint(low=10, high=11),\n", + " sizes=uniform(loc=0, scale=25),\n", + " capacity=uniform(loc=100, scale=0),\n", + " sizes_jitter=uniform(loc=0.9, scale=0.2),\n", + " capacity_jitter=uniform(loc=0.9, scale=0.2),\n", + " fix_items=True,\n", + ").generate(10)\n", + "\n", + "# Print sizes and capacities\n", + "for i in range(10):\n", + " print(i, data[i].sizes, data[i].capacity)\n", + "print()\n", + "\n", + "# Optimize first instance\n", + "model = build_binpack_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "9a3df608-4faf-444b-b5c2-18d3e90cbb5a", + "metadata": { + "tags": [] + }, + "source": [ + "## Multi-Dimensional Knapsack\n", + "\n", + "The **multi-dimensional knapsack problem** is a generalization of the classic knapsack problem, which involves selecting a subset of items to be placed in a knapsack such that the total value of the items is maximized without exceeding a maximum weight. In this generalization, items have multiple weights (representing multiple resources), and multiple weight constraints must be satisfied." + ] + }, + { + "cell_type": "markdown", + "id": "8d989002-d837-4ccf-a224-0504a6d66473", + "metadata": { + "tags": [] + }, + "source": [ + "### Formulation\n", + "\n", + "Let $n$ be the number of items and $m$ be the number of resources. For each item $j$ and resource $i$, let $p_j$ be the price of the item, let $w_{ij}$ be the amount of resource $j$ item $i$ consumes (i.e. the $j$-th weight of the item), and let $b_i$ be the total amount of resource $i$ available (or the size of the $j$-th knapsack). The formulation is given by:" + ] + }, + { + "cell_type": "markdown", + "id": "d0d3ea42", + "metadata": {}, + "source": [ + "\n", + "$$\n", + "\\begin{align*}\n", + " \\text{minimize}\\;\\;\\;\n", + " & - \\sum_{j=1}^n p_j x_j\n", + " \\\\\n", + " \\text{subject to}\\;\\;\\;\n", + " & \\sum_{j=1}^n w_{ij} x_j \\leq b_i\n", + " & \\forall i=1,\\ldots,m \\\\\n", + " & x_j \\in \\{0,1\\}\n", + " & \\forall j=1,\\ldots,n\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "81b5b085-cfa9-45ce-9682-3aeb9be96cba", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "The class [MultiKnapsackGenerator][MultiKnapsackGenerator] can be used to generate random instances of this problem. The number of items $n$ and knapsacks $m$ are sampled from the user-provided probability distributions `n` and `m`. The weights $w_{ij}$ are sampled independently from the provided distribution `w`. The capacity of knapsack $i$ is set to\n", + "\n", + "[MultiKnapsackGenerator]: ../../api/problems/#miplearn.problems.multiknapsack.MultiKnapsackGenerator\n", + "\n", + "$$\n", + " b_i = \\alpha_i \\sum_{j=1}^n w_{ij}\n", + "$$\n", + "\n", + "where $\\alpha_i$, the tightness ratio, is sampled from the provided probability\n", + "distribution `alpha`. To make the instances more challenging, the costs of the items\n", + "are linearly correlated to their average weights. More specifically, the price of each\n", + "item $j$ is set to:\n", + "\n", + "$$\n", + " p_j = \\sum_{i=1}^m \\frac{w_{ij}}{m} + K u_j,\n", + "$$\n", + "\n", + "where $K$, the correlation coefficient, and $u_j$, the correlation multiplier, are sampled\n", + "from the provided probability distributions `K` and `u`.\n", + "\n", + "If `fix_w=True` is provided, then $w_{ij}$ are kept the same in all generated instances. This also implies that $n$ and $m$ are kept fixed. Although the prices and capacities are derived from $w_{ij}$, as long as `u` and `K` are not constants, the generated instances will still not be completely identical.\n", + "\n", + "\n", + "If a probability distribution `w_jitter` is provided, then item weights will be set to $w_{ij} \\gamma_{ij}$ where $\\gamma_{ij}$ is sampled from `w_jitter`. When combined with `fix_w=True`, this argument may be used to generate instances where the weight of each item is roughly the same, but not exactly identical, across all instances. The prices of the items and the capacities of the knapsacks will be calculated as above, but using these perturbed weights instead.\n", + "\n", + "By default, all generated prices, weights and capacities are rounded to the nearest integer number. If `round=False` is provided, this rounding will be disabled." + ] + }, + { + "cell_type": "markdown", + "id": "f92135b8-67e7-4ec5-aeff-2fc17ad5e46d", + "metadata": {}, + "source": [ + "
\n", + "References\n", + "\n", + "* **Freville, Arnaud, and Gérard Plateau.** *An efficient preprocessing procedure for the multidimensional 0–1 knapsack problem.* Discrete applied mathematics 49.1-3 (1994): 189-212.\n", + "* **Fréville, Arnaud.** *The multidimensional 0–1 knapsack problem: An overview.* European Journal of Operational Research 155.1 (2004): 1-21.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "f12a066f", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1ce5f8fb-2769-4fbd-a40c-fd62b897690a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "prices\n", + " [350. 692. 454. 709. 605. 543. 321. 674. 571. 341.]\n", + "weights\n", + " [[392. 977. 764. 622. 158. 163. 56. 840. 574. 696.]\n", + " [ 20. 948. 860. 209. 178. 184. 293. 541. 414. 305.]\n", + " [629. 135. 278. 378. 466. 803. 205. 492. 584. 45.]\n", + " [630. 173. 64. 907. 947. 794. 312. 99. 711. 439.]\n", + " [117. 506. 35. 915. 266. 662. 312. 516. 521. 178.]]\n", + "capacities\n", + " [1310. 988. 1004. 1269. 1007.]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 5 rows, 10 columns and 50 nonzeros\n", + "Model fingerprint: 0xaf3ac15e\n", + "Variable types: 0 continuous, 10 integer (10 binary)\n", + "Coefficient statistics:\n", + " Matrix range [2e+01, 1e+03]\n", + " Objective range [3e+02, 7e+02]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+03, 1e+03]\n", + "Found heuristic solution: objective -804.0000000\n", + "Presolve removed 0 rows and 3 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 5 rows, 7 columns, 34 nonzeros\n", + "Variable types: 0 continuous, 7 integer (7 binary)\n", + "\n", + "Root relaxation: objective -1.428726e+03, 4 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 -1428.7265 0 4 -804.00000 -1428.7265 77.7% - 0s\n", + "H 0 0 -1279.000000 -1428.7265 11.7% - 0s\n", + "\n", + "Cutting planes:\n", + " Cover: 1\n", + "\n", + "Explored 1 nodes (4 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 2: -1279 -804 \n", + "No other solutions better than -1279\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective -1.279000000000e+03, best bound -1.279000000000e+03, gap 0.0000%\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.multiknapsack import (\n", + " MultiKnapsackGenerator,\n", + " build_multiknapsack_model,\n", + ")\n", + "\n", + "# Set random seed, to make example reproducible\n", + "np.random.seed(42)\n", + "\n", + "# Generate ten similar random instances of the multiknapsack problem with\n", + "# ten items, five resources and weights around [0, 1000].\n", + "data = MultiKnapsackGenerator(\n", + " n=randint(low=10, high=11),\n", + " m=randint(low=5, high=6),\n", + " w=uniform(loc=0, scale=1000),\n", + " K=uniform(loc=100, scale=0),\n", + " u=uniform(loc=1, scale=0),\n", + " alpha=uniform(loc=0.25, scale=0),\n", + " w_jitter=uniform(loc=0.95, scale=0.1),\n", + " p_jitter=uniform(loc=0.75, scale=0.5),\n", + " fix_w=True,\n", + ").generate(10)\n", + "\n", + "# Print data for one of the instances\n", + "print(\"prices\\n\", data[0].prices)\n", + "print(\"weights\\n\", data[0].weights)\n", + "print(\"capacities\\n\", data[0].capacities)\n", + "print()\n", + "\n", + "# Build model and optimize\n", + "model = build_multiknapsack_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "e20376b0-0781-4bfa-968f-ded5fa47e176", + "metadata": { + "tags": [] + }, + "source": [ + "## Capacitated P-Median\n", + "\n", + "The **capacitated p-median** problem is a variation of the classic $p$-median problem, in which a set of customers must be served by a set of facilities. In the capacitated $p$-Median problem, each facility has a fixed capacity, and the goal is to minimize the total cost of serving the customers while ensuring that the capacity of each facility is not exceeded. Variations of problem are often used in logistics and supply chain management to determine the most efficient locations for warehouses or distribution centers." + ] + }, + { + "cell_type": "markdown", + "id": "2af65137-109e-4ca0-8753-bd999825204f", + "metadata": { + "tags": [] + }, + "source": [ + "### Formulation\n", + "\n", + "Let $I=\\{1,\\ldots,n\\}$ be the set of customers. For each customer $i \\in I$, let $d_i$ be its demand and let $y_i$ be a binary decision variable that equals one if we decide to open a facility at that customer's location. For each pair $(i,j) \\in I \\times I$, let $x_{ij}$ be a binary decision variable that equals one if customer $i$ is assigned to facility $j$. Furthermore, let $w_{ij}$ be the cost of serving customer $i$ from facility $j$, let $p$ be the number of facilities we must open, and let $c_j$ be the capacity of facility $j$. The problem is formulated as:" + ] + }, + { + "cell_type": "markdown", + "id": "a2494ab1-d306-4db7-a100-8f1dfd4a55d7", + "metadata": { + "tags": [] + }, + "source": [ + "$$\n", + "\\begin{align*}\n", + " \\text{minimize}\\;\\;\\;\n", + " & \\sum_{i \\in I} \\sum_{j \\in I} w_{ij} x_{ij}\n", + " \\\\\n", + " \\text{subject to}\\;\\;\\;\n", + " & \\sum_{j \\in I} x_{ij} = 1 & \\forall i \\in I \\\\\n", + " & \\sum_{j \\in I} y_j = p \\\\\n", + " & \\sum_{i \\in I} d_i x_{ij} \\leq c_j y_j & \\forall j \\in I \\\\\n", + " & x_{ij} \\in \\{0, 1\\} & \\forall i, j \\in I \\\\\n", + " & y_j \\in \\{0, 1\\} & \\forall j \\in I\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "9dddf0d6-1f86-40d4-93a8-ccfe93d38e0d", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "The class [PMedianGenerator][PMedianGenerator] can be used to generate random instances of this problem. First, it decides the number of customers and the parameter $p$ by sampling the provided `n` and `p` distributions, respectively. Then, for each customer $i$, the class builds its geographical location $(x_i, y_i)$ by sampling the provided `x` and `y` distributions. For each $i$, the demand for customer $i$ and the capacity of facility $i$ are decided by sampling the provided distributions `demands` and `capacities`, respectively. Finally, the costs $w_{ij}$ are set to the Euclidean distance between the locations of customers $i$ and $j$.\n", + "\n", + "If `fixed=True`, then the number of customers, their locations, the parameter $p$, the demands and the capacities are only sampled from their respective distributions exactly once, to build a reference instance which is then randomly perturbed. Specifically, in each perturbation, the distances, demands and capacities are multiplied by random scaling factors sampled from the distributions `distances_jitter`, `demands_jitter` and `capacities_jitter`, respectively. The result is a list of instances that have the same set of customers, but slightly different demands, capacities and distances.\n", + "\n", + "[PMedianGenerator]: ../../api/problems/#miplearn.problems.pmedian.PMedianGenerator" + ] + }, + { + "cell_type": "markdown", + "id": "4e701397", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4e0e4223-b4e0-4962-a157-82a23a86e37d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "p = 5\n", + "distances =\n", + " [[ 0. 50.17 82.42 32.76 33.2 35.45 86.88 79.11 43.17 66.2 ]\n", + " [ 50.17 0. 72.64 72.51 17.06 80.25 39.92 68.93 43.41 42.96]\n", + " [ 82.42 72.64 0. 71.69 70.92 82.51 67.88 3.76 39.74 30.73]\n", + " [ 32.76 72.51 71.69 0. 56.56 11.03 101.35 69.39 42.09 68.58]\n", + " [ 33.2 17.06 70.92 56.56 0. 63.68 54.71 67.16 34.89 44.99]\n", + " [ 35.45 80.25 82.51 11.03 63.68 0. 111.04 80.29 52.78 79.36]\n", + " [ 86.88 39.92 67.88 101.35 54.71 111.04 0. 65.13 61.37 40.82]\n", + " [ 79.11 68.93 3.76 69.39 67.16 80.29 65.13 0. 36.26 27.24]\n", + " [ 43.17 43.41 39.74 42.09 34.89 52.78 61.37 36.26 0. 26.62]\n", + " [ 66.2 42.96 30.73 68.58 44.99 79.36 40.82 27.24 26.62 0. ]]\n", + "demands = [6.12 1.39 2.92 3.66 4.56 7.85 2. 5.14 5.92 0.46]\n", + "capacities = [151.89 42.63 16.26 237.22 241.41 202.1 76.15 24.42 171.06 110.04]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 21 rows, 110 columns and 220 nonzeros\n", + "Model fingerprint: 0x8d8d9346\n", + "Variable types: 0 continuous, 110 integer (110 binary)\n", + "Coefficient statistics:\n", + " Matrix range [5e-01, 2e+02]\n", + " Objective range [4e+00, 1e+02]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 5e+00]\n", + "Found heuristic solution: objective 368.7900000\n", + "Presolve time: 0.00s\n", + "Presolved: 21 rows, 110 columns, 220 nonzeros\n", + "Variable types: 0 continuous, 110 integer (110 binary)\n", + "Found heuristic solution: objective 245.6400000\n", + "\n", + "Root relaxation: objective 0.000000e+00, 18 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 0.00000 0 6 245.64000 0.00000 100% - 0s\n", + "H 0 0 185.1900000 0.00000 100% - 0s\n", + "H 0 0 148.6300000 17.14595 88.5% - 0s\n", + "H 0 0 113.1800000 17.14595 84.9% - 0s\n", + " 0 0 17.14595 0 10 113.18000 17.14595 84.9% - 0s\n", + "H 0 0 99.5000000 17.14595 82.8% - 0s\n", + "H 0 0 98.3900000 17.14595 82.6% - 0s\n", + "H 0 0 93.9800000 64.28872 31.6% - 0s\n", + " 0 0 64.28872 0 15 93.98000 64.28872 31.6% - 0s\n", + "H 0 0 93.9200000 64.28872 31.5% - 0s\n", + " 0 0 86.06884 0 15 93.92000 86.06884 8.36% - 0s\n", + "* 0 0 0 91.2300000 91.23000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (70 simplex iterations) in 0.02 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 10: 91.23 93.92 93.98 ... 368.79\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 9.123000000000e+01, best bound 9.123000000000e+01, gap 0.0000%\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.pmedian import PMedianGenerator, build_pmedian_model\n", + "\n", + "# Set random seed, to make example reproducible\n", + "np.random.seed(42)\n", + "\n", + "# Generate random instances with ten customers located in a\n", + "# 100x100 square, with demands in [0,10], capacities in [0, 250].\n", + "data = PMedianGenerator(\n", + " x=uniform(loc=0.0, scale=100.0),\n", + " y=uniform(loc=0.0, scale=100.0),\n", + " n=randint(low=10, high=11),\n", + " p=randint(low=5, high=6),\n", + " demands=uniform(loc=0, scale=10),\n", + " capacities=uniform(loc=0, scale=250),\n", + " distances_jitter=uniform(loc=0.9, scale=0.2),\n", + " demands_jitter=uniform(loc=0.9, scale=0.2),\n", + " capacities_jitter=uniform(loc=0.9, scale=0.2),\n", + " fixed=True,\n", + ").generate(10)\n", + "\n", + "# Print data for one of the instances\n", + "print(\"p =\", data[0].p)\n", + "print(\"distances =\\n\", data[0].distances)\n", + "print(\"demands =\", data[0].demands)\n", + "print(\"capacities =\", data[0].capacities)\n", + "print()\n", + "\n", + "# Build and optimize model\n", + "model = build_pmedian_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "36129dbf-ecba-4026-ad4d-f2356bad4a26", + "metadata": {}, + "source": [ + "## Set cover\n", + "\n", + "The **set cover problem** is a classical NP-hard optimization problem which aims to minimize the number of sets needed to cover all elements in a given universe. Each set may contain a different number of elements, and sets may overlap with each other. This problem can be useful in various real-world scenarios such as scheduling, resource allocation, and network design." + ] + }, + { + "cell_type": "markdown", + "id": "d5254e7a", + "metadata": {}, + "source": [ + "### Formulation\n", + "\n", + "Let $U = \\{1,\\ldots,n\\}$ be a given universe set, and let $S=\\{S_1,\\ldots,S_m\\}$ be a collection of sets whose union equal $U$. For each $j \\in \\{1,\\ldots,m\\}$, let $w_j$ be the weight of set $S_j$, and let $x_j$ be a binary decision variable that equals one if set $S_j$ is chosen. The set cover problem is formulated as:" + ] + }, + { + "cell_type": "markdown", + "id": "5062d606-678c-45ba-9a45-d3c8b7401ad1", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align*}\n", + " \\text{minimize}\\;\\;\\;\n", + " & \\sum_{j=1}^m w_j x_j\n", + " \\\\\n", + " \\text{subject to}\\;\\;\\;\n", + " & \\sum_{j : i \\in S_j} x_j \\geq 1 & \\forall i \\in \\{1,\\ldots,n\\} \\\\\n", + " & x_j \\in \\{0, 1\\} & \\forall j \\in \\{1,\\ldots,m\\}\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "2732c050-2e11-44fc-bdd1-1b804a60f166", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "The class [SetCoverGenerator] can generate random instances of this problem. The class first decides the number of elements and sets by sampling the provided distributions `n_elements` and `n_sets`, respectively. Then it generates a random incidence matrix $M$, as follows:\n", + "\n", + "1. The density $d$ of $M$ is decided by sampling the provided probability distribution `density`.\n", + "2. Each entry of $M$ is then sampled from the Bernoulli distribution, with probability $d$.\n", + "3. To ensure that each element belongs to at least one set, the class identifies elements that are not contained in any set, then assigns them to a random set (chosen uniformly).\n", + "4. Similarly, to ensure that each set contains at least one element, the class identifies empty sets, then modifies them to include one random element (chosen uniformly).\n", + "\n", + "Finally, the weight of set $j$ is set to $w_j + K | S_j |$, where $w_j$ and $k$ are sampled from `costs` and `K`, respectively, and where $|S_j|$ denotes the size of set $S_j$. The parameter $K$ is used to introduce some correlation between the size of the set and its weight, making the instance more challenging. Note that `K` is only sampled once for the entire instance.\n", + "\n", + "If `fix_sets=True`, then all generated instances have exactly the same sets and elements. The costs of the sets, however, are multiplied by random scaling factors sampled from the provided probability distribution `costs_jitter`.\n", + "\n", + "[SetCoverGenerator]: ../../api/problems/#miplearn.problems.setcover.SetCoverGenerator" + ] + }, + { + "cell_type": "markdown", + "id": "569aa5ec-d475-41fa-a5d9-0b1a675fdf95", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "3224845b-9afd-463e-abf4-e0e93d304859", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "matrix\n", + " [[1 0 0 0 1 1 1 0 0 0]\n", + " [1 0 0 1 1 1 1 0 1 1]\n", + " [0 1 1 1 1 0 1 0 0 1]\n", + " [0 1 1 0 0 0 1 1 0 1]\n", + " [1 1 1 0 1 0 1 0 0 1]]\n", + "costs [1044.58 850.13 1014.5 944.83 697.9 971.87 213.49 220.98 70.23\n", + " 425.33]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 5 rows, 10 columns and 28 nonzeros\n", + "Model fingerprint: 0xe5c2d4fa\n", + "Variable types: 0 continuous, 10 integer (10 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [7e+01, 1e+03]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 1e+00]\n", + "Found heuristic solution: objective 213.4900000\n", + "Presolve removed 5 rows and 10 columns\n", + "Presolve time: 0.00s\n", + "Presolve: All rows and columns removed\n", + "\n", + "Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n", + "Thread count was 1 (of 32 available processors)\n", + "\n", + "Solution count 1: 213.49 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.134900000000e+02, best bound 2.134900000000e+02, gap 0.0000%\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.setcover import SetCoverGenerator, build_setcover_model_gurobipy\n", + "\n", + "# Set random seed, to make example reproducible\n", + "np.random.seed(42)\n", + "\n", + "# Build random instances with five elements, ten sets and costs\n", + "# in the [0, 1000] interval, with a correlation factor of 25 and\n", + "# an incidence matrix with 25% density.\n", + "data = SetCoverGenerator(\n", + " n_elements=randint(low=5, high=6),\n", + " n_sets=randint(low=10, high=11),\n", + " costs=uniform(loc=0.0, scale=1000.0),\n", + " costs_jitter=uniform(loc=0.90, scale=0.20),\n", + " density=uniform(loc=0.5, scale=0.00),\n", + " K=uniform(loc=25.0, scale=0.0),\n", + " fix_sets=True,\n", + ").generate(10)\n", + "\n", + "# Print problem data for one instance\n", + "print(\"matrix\\n\", data[0].incidence_matrix)\n", + "print(\"costs\", data[0].costs)\n", + "print()\n", + "\n", + "# Build and optimize model\n", + "model = build_setcover_model_gurobipy(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "255a4e88-2e38-4a1b-ba2e-806b6bd4c815", + "metadata": {}, + "source": [ + "## Set Packing\n", + "\n", + "**Set packing** is a classical optimization problem that asks for the maximum number of disjoint sets within a given list. This problem often arises in real-world situations where a finite number of resources need to be allocated to tasks, such as airline flight crew scheduling." + ] + }, + { + "cell_type": "markdown", + "id": "19342eb1", + "metadata": {}, + "source": [ + "### Formulation\n", + "\n", + "Let $U=\\{1,\\ldots,n\\}$ be a given universe set, and let $S = \\{S_1, \\ldots, S_m\\}$ be a collection of subsets of $U$. For each subset $j \\in \\{1, \\ldots, m\\}$, let $w_j$ be the weight of $S_j$ and let $x_j$ be a binary decision variable which equals one if set $S_j$ is chosen. The problem is formulated as:" + ] + }, + { + "cell_type": "markdown", + "id": "0391b35b", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align*}\n", + " \\text{minimize}\\;\\;\\;\n", + " & -\\sum_{j=1}^m w_j x_j\n", + " \\\\\n", + " \\text{subject to}\\;\\;\\;\n", + " & \\sum_{j : i \\in S_j} x_j \\leq 1 & \\forall i \\in \\{1,\\ldots,n\\} \\\\\n", + " & x_j \\in \\{0, 1\\} & \\forall j \\in \\{1,\\ldots,m\\}\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "c2d7df7b", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "The class [SetPackGenerator][SetPackGenerator] can generate random instances of this problem. It accepts exactly the same arguments, and generates instance data in exactly the same way as [SetCoverGenerator][SetCoverGenerator]. For more details, please see the documentation for that class.\n", + "\n", + "[SetPackGenerator]: ../../api/problems/#miplearn.problems.setpack.SetPackGenerator\n", + "[SetCoverGenerator]: ../../api/problems/#miplearn.problems.setcover.SetCoverGenerator\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cc797da7", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "matrix\n", + " [[1 0 0 0 1 1 1 0 0 0]\n", + " [1 0 0 1 1 1 1 0 1 1]\n", + " [0 1 1 1 1 0 1 0 0 1]\n", + " [0 1 1 0 0 0 1 1 0 1]\n", + " [1 1 1 0 1 0 1 0 0 1]]\n", + "costs [1044.58 850.13 1014.5 944.83 697.9 971.87 213.49 220.98 70.23\n", + " 425.33]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 5 rows, 10 columns and 28 nonzeros\n", + "Model fingerprint: 0x4ee91388\n", + "Variable types: 0 continuous, 10 integer (10 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [7e+01, 1e+03]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 1e+00]\n", + "Found heuristic solution: objective -1265.560000\n", + "Presolve removed 5 rows and 10 columns\n", + "Presolve time: 0.00s\n", + "Presolve: All rows and columns removed\n", + "\n", + "Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n", + "Thread count was 1 (of 32 available processors)\n", + "\n", + "Solution count 2: -1986.37 -1265.56 \n", + "No other solutions better than -1986.37\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective -1.986370000000e+03, best bound -1.986370000000e+03, gap 0.0000%\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.setpack import SetPackGenerator, build_setpack_model\n", + "\n", + "# Set random seed, to make example reproducible\n", + "np.random.seed(42)\n", + "\n", + "# Build random instances with five elements, ten sets and costs\n", + "# in the [0, 1000] interval, with a correlation factor of 25 and\n", + "# an incidence matrix with 25% density.\n", + "data = SetPackGenerator(\n", + " n_elements=randint(low=5, high=6),\n", + " n_sets=randint(low=10, high=11),\n", + " costs=uniform(loc=0.0, scale=1000.0),\n", + " costs_jitter=uniform(loc=0.90, scale=0.20),\n", + " density=uniform(loc=0.5, scale=0.00),\n", + " K=uniform(loc=25.0, scale=0.0),\n", + " fix_sets=True,\n", + ").generate(10)\n", + "\n", + "# Print problem data for one instance\n", + "print(\"matrix\\n\", data[0].incidence_matrix)\n", + "print(\"costs\", data[0].costs)\n", + "print()\n", + "\n", + "# Build and optimize model\n", + "model = build_setpack_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "373e450c-8f8b-4b59-bf73-251bdd6ff67e", + "metadata": {}, + "source": [ + "## Stable Set\n", + "\n", + "The **maximum-weight stable set problem** is a classical optimization problem in graph theory which asks for the maximum-weight subset of vertices in a graph such that no two vertices in the subset are adjacent. The problem often arises in real-world scheduling or resource allocation situations, where stable sets represent tasks or resources that can be chosen simultaneously without conflicts.\n", + "\n", + "### Formulation\n", + "\n", + "Let $G=(V,E)$ be a simple undirected graph, and for each vertex $v \\in V$, let $w_v$ be its weight. The problem is formulated as:" + ] + }, + { + "cell_type": "markdown", + "id": "2f74dd10", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align*}\n", + "\\text{minimize} \\;\\;\\; & -\\sum_{v \\in V} w_v x_v \\\\\n", + "\\text{such that} \\;\\;\\; & \\sum_{v \\in C} x_v \\leq 1 & \\forall C \\in \\mathcal{C} \\\\\n", + "& x_v \\in \\{0, 1\\} & \\forall v \\in V\n", + "\\end{align*}\n", + "$$\n", + "where $\\mathcal{C}$ is the set of cliques in $G$. We recall that a clique is a subset of vertices in which every pair of vertices is adjacent." + ] + }, + { + "cell_type": "markdown", + "id": "ef030168", + "metadata": {}, + "source": [ + "\n", + "### Random instance generator\n", + "\n", + "The class [MaxWeightStableSetGenerator][MaxWeightStableSetGenerator] can be used to generate random instances of this problem. The class first samples the user-provided probability distributions `n` and `p` to decide the number of vertices and the density of the graph. Then, it generates a random Erdős-Rényi graph $G_{n,p}$. We recall that, in such a graph, each potential edge is included with probabilty $p$, independently for each other. The class then samples the provided probability distribution `w` to decide the vertex weights.\n", + "\n", + "[MaxWeightStableSetGenerator]: ../../api/problems/#miplearn.problems.stab.MaxWeightStableSetGenerator\n", + "\n", + "If `fix_graph=True`, then all generated instances have the same random graph. For each instance, the weights are decided by sampling `w`, as described above.\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "0f996e99-0ec9-472b-be8a-30c9b8556931", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "graph [(0, 2), (0, 4), (0, 8), (1, 2), (1, 3), (1, 5), (1, 6), (1, 9), (2, 5), (2, 9), (3, 6), (3, 7), (6, 9), (7, 8), (8, 9)]\n", + "weights[0] [37.45 95.07 73.2 59.87 15.6 15.6 5.81 86.62 60.11 70.81]\n", + "weights[1] [ 2.06 96.99 83.24 21.23 18.18 18.34 30.42 52.48 43.19 29.12]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 10 rows, 10 columns and 24 nonzeros\n", + "Model fingerprint: 0xf4c21689\n", + "Variable types: 0 continuous, 10 integer (10 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [6e+00, 1e+02]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 1e+00]\n", + "Found heuristic solution: objective -219.1400000\n", + "Presolve removed 2 rows and 2 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 8 rows, 8 columns, 19 nonzeros\n", + "Variable types: 0 continuous, 8 integer (8 binary)\n", + "\n", + "Root relaxation: objective -2.205650e+02, 4 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 infeasible 0 -219.14000 -219.14000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (4 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 1: -219.14 \n", + "No other solutions better than -219.14\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective -2.191400000000e+02, best bound -2.191400000000e+02, gap 0.0000%\n" + ] + } + ], + "source": [ + "import random\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.stab import (\n", + " MaxWeightStableSetGenerator,\n", + " build_stab_model_gurobipy,\n", + ")\n", + "\n", + "# Set random seed to make example reproducible\n", + "random.seed(42)\n", + "np.random.seed(42)\n", + "\n", + "# Generate random instances with a fixed 10-node graph,\n", + "# 25% density and random weights in the [0, 100] interval.\n", + "data = MaxWeightStableSetGenerator(\n", + " w=uniform(loc=0.0, scale=100.0),\n", + " n=randint(low=10, high=11),\n", + " p=uniform(loc=0.25, scale=0.0),\n", + " fix_graph=True,\n", + ").generate(10)\n", + "\n", + "# Print the graph and weights for two instances\n", + "print(\"graph\", data[0].graph.edges)\n", + "print(\"weights[0]\", data[0].weights)\n", + "print(\"weights[1]\", data[1].weights)\n", + "print()\n", + "\n", + "# Load and optimize the first instance\n", + "model = build_stab_model_gurobipy(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "444d1092-fd83-4957-b691-a198d56ba066", + "metadata": {}, + "source": [ + "## Traveling Salesman\n", + "\n", + "Given a list of cities and the distances between them, the **traveling salesman problem** asks for the shortest route starting at the first city, visiting each other city exactly once, then returning to the first city. This problem is a generalization of the Hamiltonian path problem, one of Karp's 21 NP-complete problems, and has many practical applications, including routing delivery trucks and scheduling airline routes." + ] + }, + { + "cell_type": "markdown", + "id": "da3ca69c", + "metadata": {}, + "source": [ + "### Formulation\n", + "\n", + "Let $G=(V,E)$ be a simple undirected graph. For each edge $e \\in E$, let $d_e$ be its weight (or distance) and let $x_e$ be a binary decision variable which equals one if $e$ is included in the route. The problem is formulated as:" + ] + }, + { + "cell_type": "markdown", + "id": "9cf296e9", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align*}\n", + "\\text{minimize} \\;\\;\\;\n", + " & \\sum_{e \\in E} d_e x_e \\\\\n", + "\\text{such that} \\;\\;\\;\n", + " & \\sum_{e : \\delta(v)} x_e = 2 & \\forall v \\in V, \\\\\n", + " & \\sum_{e \\in \\delta(S)} x_e \\geq 2 & \\forall S \\subsetneq V, |S| \\neq \\emptyset, \\\\\n", + " & x_e \\in \\{0, 1\\} & \\forall e \\in E,\n", + "\\end{align*}\n", + "$$\n", + "where $\\delta(v)$ denotes the set of edges adjacent to vertex $v$, and $\\delta(S)$ denotes the set of edges that have one extremity in $S$ and one in $V \\setminus S$. Because of its exponential size, we enforce the second set of inequalities as lazy constraints." + ] + }, + { + "cell_type": "markdown", + "id": "eba3dbe5", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "The class [TravelingSalesmanGenerator][TravelingSalesmanGenerator] can be used to generate random instances of this problem. Initially, the class samples the user-provided probability distribution `n` to decide how many cities to generate. Then, for each city $i$, the class generates its geographical location $(x_i, y_i)$ by sampling the provided distributions `x` and `y`. The distance $d_{ij}$ between cities $i$ and $j$ is then set to\n", + "$$\n", + "\\gamma_{ij} \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2},\n", + "$$\n", + "where $\\gamma$ is a random scaling factor sampled from the provided probability distribution `gamma`.\n", + "\n", + "If `fix_cities=True`, then the list of cities is kept the same for all generated instances. The $\\gamma$ values, however, and therefore also the distances, are still different. By default, all distances $d_{ij}$ are rounded to the nearest integer. If `round=False` is provided, this rounding will be disabled.\n", + "\n", + "[TravelingSalesmanGenerator]: ../../api/problems/#miplearn.problems.tsp.TravelingSalesmanGenerator" + ] + }, + { + "cell_type": "markdown", + "id": "61f16c56", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "9d0c56c6", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "distances[0]\n", + " [[ 0. 513. 762. 358. 325. 374. 932. 731. 391. 634.]\n", + " [ 513. 0. 726. 765. 163. 754. 409. 719. 446. 400.]\n", + " [ 762. 726. 0. 780. 756. 744. 656. 40. 383. 334.]\n", + " [ 358. 765. 780. 0. 549. 117. 925. 702. 422. 728.]\n", + " [ 325. 163. 756. 549. 0. 663. 526. 708. 377. 462.]\n", + " [ 374. 754. 744. 117. 663. 0. 1072. 802. 501. 853.]\n", + " [ 932. 409. 656. 925. 526. 1072. 0. 654. 603. 433.]\n", + " [ 731. 719. 40. 702. 708. 802. 654. 0. 381. 255.]\n", + " [ 391. 446. 383. 422. 377. 501. 603. 381. 0. 287.]\n", + " [ 634. 400. 334. 728. 462. 853. 433. 255. 287. 0.]]\n", + "distances[1]\n", + " [[ 0. 493. 900. 354. 323. 367. 841. 727. 444. 668.]\n", + " [ 493. 0. 690. 687. 175. 725. 368. 744. 398. 446.]\n", + " [ 900. 690. 0. 666. 728. 827. 736. 41. 371. 317.]\n", + " [ 354. 687. 666. 0. 570. 104. 1090. 712. 454. 648.]\n", + " [ 323. 175. 728. 570. 0. 655. 521. 650. 356. 469.]\n", + " [ 367. 725. 827. 104. 655. 0. 1146. 779. 476. 752.]\n", + " [ 841. 368. 736. 1090. 521. 1146. 0. 681. 565. 394.]\n", + " [ 727. 744. 41. 712. 650. 779. 681. 0. 374. 286.]\n", + " [ 444. 398. 371. 454. 356. 476. 565. 374. 0. 274.]\n", + " [ 668. 446. 317. 648. 469. 752. 394. 286. 274. 0.]]\n", + "\n", + "Set parameter LazyConstraints to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 10 rows, 45 columns and 90 nonzeros\n", + "Model fingerprint: 0x719675e5\n", + "Variable types: 0 continuous, 45 integer (45 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [4e+01, 1e+03]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [2e+00, 2e+00]\n", + "Presolve time: 0.00s\n", + "Presolved: 10 rows, 45 columns, 90 nonzeros\n", + "Variable types: 0 continuous, 45 integer (45 binary)\n", + "\n", + "Root relaxation: objective 2.921000e+03, 17 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + "* 0 0 0 2921.0000000 2921.00000 0.00% - 0s\n", + "\n", + "Cutting planes:\n", + " Lazy constraints: 3\n", + "\n", + "Explored 1 nodes (17 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 1: 2921 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.921000000000e+03, best bound 2.921000000000e+03, gap 0.0000%\n", + "\n", + "User-callback calls 106, time in user-callback 0.00 sec\n" + ] + } + ], + "source": [ + "import random\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.tsp import TravelingSalesmanGenerator, build_tsp_model\n", + "\n", + "# Set random seed to make example reproducible\n", + "random.seed(42)\n", + "np.random.seed(42)\n", + "\n", + "# Generate random instances with a fixed ten cities in the 1000x1000 box\n", + "# and random distance scaling factors in the [0.90, 1.10] interval.\n", + "data = TravelingSalesmanGenerator(\n", + " n=randint(low=10, high=11),\n", + " x=uniform(loc=0.0, scale=1000.0),\n", + " y=uniform(loc=0.0, scale=1000.0),\n", + " gamma=uniform(loc=0.90, scale=0.20),\n", + " fix_cities=True,\n", + " round=True,\n", + ").generate(10)\n", + "\n", + "# Print distance matrices for the first two instances\n", + "print(\"distances[0]\\n\", data[0].distances)\n", + "print(\"distances[1]\\n\", data[1].distances)\n", + "print()\n", + "\n", + "# Load and optimize the first instance\n", + "model = build_tsp_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "26dfc157-11f4-4564-b368-95ee8200875e", + "metadata": {}, + "source": [ + "## Unit Commitment\n", + "\n", + "The **unit commitment problem** is a mixed-integer optimization problem which asks which power generation units should be turned on and off, at what time, and at what capacity, in order to meet the demand for electricity generation at the lowest cost. Numerous operational constraints are typically enforced, such as *ramping constraints*, which prevent generation units from changing power output levels too quickly from one time step to the next, and *minimum-up* and *minimum-down* constraints, which prevent units from switching on and off too frequently. The unit commitment problem is widely used in power systems planning and operations." + ] + }, + { + "cell_type": "markdown", + "id": "7048d771", + "metadata": {}, + "source": [ + "\n", + "
\n", + "Note\n", + "\n", + "MIPLearn includes a simple formulation for the unit commitment problem, which enforces only minimum and maximum power production, as well as minimum-up and minimum-down constraints. The formulation does not enforce, for example, ramping trajectories, piecewise-linear cost curves, start-up costs or transmission and n-1 security constraints. For a more complete set of formulations, solution methods and realistic benchmark instances for the problem, see [UnitCommitment.jl](https://github.com/ANL-CEEESA/UnitCommitment.jl).\n", + "
\n", + "\n", + "### Formulation\n", + "\n", + "Let $T$ be the number of time steps, $G$ be the number of generation units, and let $D_t$ be the power demand (in MW) at time $t$. For each generating unit $g$, let $P^\\max_g$ and $P^\\min_g$ be the maximum and minimum amount of power the unit is able to produce when switched on; let $L_g$ and $l_g$ be the minimum up- and down-time for unit $g$; let $C^\\text{fixed}$ be the cost to keep unit $g$ on for one time step, regardless of its power output level; let $C^\\text{start}$ be the cost to switch unit $g$ on; and let $C^\\text{var}$ be the cost for generator $g$ to produce 1 MW of power. In this formulation, we assume linear production costs. For each generator $g$ and time $t$, let $x_{gt}$ be a binary variable which equals one if unit $g$ is on at time $t$, let $w_{gt}$ be a binary variable which equals one if unit $g$ switches from being off at time $t-1$ to being on at time $t$, and let $p_{gt}$ be a continuous variable which indicates the amount of power generated. The formulation is given by:" + ] + }, + { + "cell_type": "markdown", + "id": "bec5ee1c", + "metadata": {}, + "source": [ + "\n", + "$$\n", + "\\begin{align*}\n", + "\\text{minimize} \\;\\;\\;\n", + " & \\sum_{t=1}^T \\sum_{g=1}^G \\left(\n", + " x_{gt} C^\\text{fixed}_g\n", + " + w_{gt} C^\\text{start}_g\n", + " + p_{gt} C^\\text{var}_g\n", + " \\right)\n", + " \\\\\n", + "\\text{such that} \\;\\;\\;\n", + " & \\sum_{k=t-L_g+1}^t w_{gk} \\leq x_{gt}\n", + " & \\forall g\\; \\forall t=L_g-1,\\ldots,T-1 \\\\\n", + " & \\sum_{k=g-l_g+1}^T w_{gt} \\leq 1 - x_{g,t-l_g+1}\n", + " & \\forall g \\forall t=l_g-1,\\ldots,T-1 \\\\\n", + " & w_{gt} \\geq x_{gt} - x_{g,t-1}\n", + " & \\forall g \\forall t=1,\\ldots,T-1 \\\\\n", + " & \\sum_{g=1}^G p_{gt} \\geq D_t\n", + " & \\forall t \\\\\n", + " & P^\\text{min}_g x_{gt} \\leq p_{gt}\n", + " & \\forall g, t \\\\\n", + " & p_{gt} \\leq P^\\text{max}_g x_{gt}\n", + " & \\forall g, t \\\\\n", + " & x_{gt} \\in \\{0, 1\\}\n", + " & \\forall g, t.\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "4a1ffb4c", + "metadata": {}, + "source": [ + "\n", + "The first set of inequalities enforces minimum up-time constraints: if unit $g$ is down at time $t$, then it cannot start up during the previous $L_g$ time steps. The second set of inequalities enforces minimum down-time constraints, and is symmetrical to the previous one. The third set ensures that if unit $g$ starts up at time $t$, then the start up variable must be one. The fourth set ensures that demand is satisfied at each time period. The fifth and sixth sets enforce bounds to the quantity of power generated by each unit.\n", + "\n", + "
\n", + "References\n", + "\n", + "- *Bendotti, P., Fouilhoux, P. & Rottner, C.* **The min-up/min-down unit commitment polytope.** J Comb Optim 36, 1024-1058 (2018). https://doi.org/10.1007/s10878-018-0273-y\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "01bed9fc", + "metadata": {}, + "source": [ + "\n", + "### Random instance generator\n", + "\n", + "The class `UnitCommitmentGenerator` can be used to generate random instances of this problem.\n", + "\n", + "First, the user-provided probability distributions `n_units` and `n_periods` are sampled to determine the number of generating units and the number of time steps, respectively. Then, for each unit, the probabilities `max_power` and `min_power` are sampled to determine the unit's maximum and minimum power output. To make it easier to generate valid ranges, `min_power` is not specified as the absolute power level in MW, but rather as a multiplier of `max_power`; for example, if `max_power` samples to 100 and `min_power` samples to 0.5, then the unit's power range is set to `[50,100]`. Then, the distributions `cost_startup`, `cost_prod` and `cost_fixed` are sampled to determine the unit's startup, variable and fixed costs, while the distributions `min_uptime` and `min_downtime` are sampled to determine its minimum up/down-time.\n", + "\n", + "After parameters for the units have been generated, the class then generates a periodic demand curve, with a peak every 12 time steps, in the range $(0.4C, 0.8C)$, where $C$ is the sum of all units' maximum power output. Finally, all costs and demand values are perturbed by random scaling factors independently sampled from the distributions `cost_jitter` and `demand_jitter`, respectively.\n", + "\n", + "If `fix_units=True`, then the list of generators (with their respective parameters) is kept the same for all generated instances. If `cost_jitter` and `demand_jitter` are provided, the instances will still have slightly different costs and demands." + ] + }, + { + "cell_type": "markdown", + "id": "855b87b4", + "metadata": {}, + "source": [ + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "6217da7c", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "min_power[0] [117.79 245.85 271.85 207.7 81.38]\n", + "max_power[0] [218.54 477.82 379.4 319.4 120.21]\n", + "min_uptime[0] [7 6 3 5 7]\n", + "min_downtime[0] [7 3 5 6 2]\n", + "min_power[0] [117.79 245.85 271.85 207.7 81.38]\n", + "cost_startup[0] [3042.42 5247.56 4319.45 2912.29 6118.53]\n", + "cost_prod[0] [ 6.97 14.61 18.32 22.8 39.26]\n", + "cost_fixed[0] [199.67 514.23 592.41 46.45 607.54]\n", + "demand[0]\n", + " [ 905.06 915.41 1166.52 1212.29 1127.81 953.52 905.06 796.21 783.78\n", + " 866.23 768.62 899.59 905.06 946.23 1087.61 1004.24 1048.36 992.03\n", + " 905.06 750.82 691.48 606.15 658.5 809.95]\n", + "\n", + "min_power[1] [117.79 245.85 271.85 207.7 81.38]\n", + "max_power[1] [218.54 477.82 379.4 319.4 120.21]\n", + "min_uptime[1] [7 6 3 5 7]\n", + "min_downtime[1] [7 3 5 6 2]\n", + "min_power[1] [117.79 245.85 271.85 207.7 81.38]\n", + "cost_startup[1] [2458.08 6200.26 4585.74 2666.05 4783.34]\n", + "cost_prod[1] [ 6.31 13.33 20.42 24.37 46.86]\n", + "cost_fixed[1] [196.9 416.42 655.57 52.51 626.15]\n", + "demand[1]\n", + " [ 981.42 840.07 1095.59 1102.03 1088.41 932.29 863.67 848.56 761.33\n", + " 828.28 775.18 834.99 959.76 865.72 1193.52 1058.92 985.19 893.92\n", + " 962.16 781.88 723.15 639.04 602.4 787.02]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 578 rows, 360 columns and 2128 nonzeros\n", + "Model fingerprint: 0x4dc1c661\n", + "Variable types: 120 continuous, 240 integer (240 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 5e+02]\n", + " Objective range [7e+00, 6e+03]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 1e+03]\n", + "Presolve removed 244 rows and 131 columns\n", + "Presolve time: 0.02s\n", + "Presolved: 334 rows, 229 columns, 842 nonzeros\n", + "Variable types: 116 continuous, 113 integer (113 binary)\n", + "Found heuristic solution: objective 440662.46430\n", + "Found heuristic solution: objective 429461.97680\n", + "Found heuristic solution: objective 374043.64040\n", + "\n", + "Root relaxation: objective 3.361348e+05, 142 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 336134.820 0 18 374043.640 336134.820 10.1% - 0s\n", + "H 0 0 368600.14450 336134.820 8.81% - 0s\n", + "H 0 0 364721.76610 336134.820 7.84% - 0s\n", + " 0 0 cutoff 0 364721.766 364721.766 0.00% - 0s\n", + "\n", + "Cutting planes:\n", + " Gomory: 3\n", + " Cover: 8\n", + " Implied bound: 29\n", + " Clique: 222\n", + " MIR: 7\n", + " Flow cover: 7\n", + " RLT: 1\n", + " Relax-and-lift: 7\n", + "\n", + "Explored 1 nodes (234 simplex iterations) in 0.04 seconds (0.02 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 5: 364722 368600 374044 ... 440662\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 3.647217661000e+05, best bound 3.647217661000e+05, gap 0.0000%\n" + ] + } + ], + "source": [ + "import random\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.uc import UnitCommitmentGenerator, build_uc_model\n", + "\n", + "# Set random seed to make example reproducible\n", + "random.seed(42)\n", + "np.random.seed(42)\n", + "\n", + "# Generate a random instance with 5 generators and 24 time steps\n", + "data = UnitCommitmentGenerator(\n", + " n_units=randint(low=5, high=6),\n", + " n_periods=randint(low=24, high=25),\n", + " max_power=uniform(loc=50, scale=450),\n", + " min_power=uniform(loc=0.5, scale=0.25),\n", + " cost_startup=uniform(loc=0, scale=10_000),\n", + " cost_prod=uniform(loc=0, scale=50),\n", + " cost_fixed=uniform(loc=0, scale=1_000),\n", + " min_uptime=randint(low=2, high=8),\n", + " min_downtime=randint(low=2, high=8),\n", + " cost_jitter=uniform(loc=0.75, scale=0.5),\n", + " demand_jitter=uniform(loc=0.9, scale=0.2),\n", + " fix_units=True,\n", + ").generate(10)\n", + "\n", + "# Print problem data for the two first instances\n", + "for i in range(2):\n", + " print(f\"min_power[{i}]\", data[i].min_power)\n", + " print(f\"max_power[{i}]\", data[i].max_power)\n", + " print(f\"min_uptime[{i}]\", data[i].min_uptime)\n", + " print(f\"min_downtime[{i}]\", data[i].min_downtime)\n", + " print(f\"min_power[{i}]\", data[i].min_power)\n", + " print(f\"cost_startup[{i}]\", data[i].cost_startup)\n", + " print(f\"cost_prod[{i}]\", data[i].cost_prod)\n", + " print(f\"cost_fixed[{i}]\", data[i].cost_fixed)\n", + " print(f\"demand[{i}]\\n\", data[i].demand)\n", + " print()\n", + "\n", + "# Load and optimize the first instance\n", + "model = build_uc_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "169293c7-33e1-4d28-8d39-9982776251d7", + "metadata": {}, + "source": [ + "## Vertex Cover\n", + "\n", + "**Minimum weight vertex cover** is a classical optimization problem in graph theory where the goal is to find the minimum-weight set of vertices that are connected to all of the edges in the graph. The problem generalizes one of Karp's 21 NP-complete problems and has applications in various fields, including bioinformatics and machine learning." + ] + }, + { + "cell_type": "markdown", + "id": "91f5781a", + "metadata": {}, + "source": [ + "\n", + "### Formulation\n", + "\n", + "Let $G=(V,E)$ be a simple graph. For each vertex $v \\in V$, let $w_g$ be its weight, and let $x_v$ be a binary decision variable which equals one if $v$ is included in the cover. The mixed-integer linear formulation for the problem is given by:" + ] + }, + { + "cell_type": "markdown", + "id": "544754cb", + "metadata": {}, + "source": [ + " $$\n", + "\\begin{align*}\n", + "\\text{minimize} \\;\\;\\;\n", + " & \\sum_{v \\in V} w_v \\\\\n", + "\\text{such that} \\;\\;\\;\n", + " & x_i + x_j \\ge 1 & \\forall \\{i, j\\} \\in E, \\\\\n", + " & x_{i,j} \\in \\{0, 1\\}\n", + " & \\forall \\{i,j\\} \\in E.\n", + "\\end{align*}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "35c99166", + "metadata": {}, + "source": [ + "### Random instance generator\n", + "\n", + "The class [MinWeightVertexCoverGenerator][MinWeightVertexCoverGenerator] can be used to generate random instances of this problem. The class accepts exactly the same parameters and behaves exactly in the same way as [MaxWeightStableSetGenerator][MaxWeightStableSetGenerator]. See the [stable set section](#Stable-Set) for more details.\n", + "\n", + "[MinWeightVertexCoverGenerator]: ../../api/problems/#module-miplearn.problems.vertexcover\n", + "[MaxWeightStableSetGenerator]: ../../api/problems/#miplearn.problems.stab.MaxWeightStableSetGenerator\n", + "\n", + "### Example" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5fff7afe-5b7a-4889-a502-66751ec979bf", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "graph [(0, 2), (0, 4), (0, 8), (1, 2), (1, 3), (1, 5), (1, 6), (1, 9), (2, 5), (2, 9), (3, 6), (3, 7), (6, 9), (7, 8), (8, 9)]\n", + "weights[0] [37.45 95.07 73.2 59.87 15.6 15.6 5.81 86.62 60.11 70.81]\n", + "weights[1] [ 2.06 96.99 83.24 21.23 18.18 18.34 30.42 52.48 43.19 29.12]\n", + "\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 15 rows, 10 columns and 30 nonzeros\n", + "Model fingerprint: 0x2d2d1390\n", + "Variable types: 0 continuous, 10 integer (10 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [6e+00, 1e+02]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+00, 1e+00]\n", + "Found heuristic solution: objective 301.0000000\n", + "Presolve removed 7 rows and 2 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 8 rows, 8 columns, 19 nonzeros\n", + "Variable types: 0 continuous, 8 integer (8 binary)\n", + "\n", + "Root relaxation: objective 2.995750e+02, 8 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 infeasible 0 301.00000 301.00000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (8 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 1: 301 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 3.010000000000e+02, best bound 3.010000000000e+02, gap 0.0000%\n" + ] + } + ], + "source": [ + "import random\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from miplearn.problems.vertexcover import (\n", + " MinWeightVertexCoverGenerator,\n", + " build_vertexcover_model,\n", + ")\n", + "\n", + "# Set random seed to make example reproducible\n", + "random.seed(42)\n", + "np.random.seed(42)\n", + "\n", + "# Generate random instances with a fixed 10-node graph,\n", + "# 25% density and random weights in the [0, 100] interval.\n", + "data = MinWeightVertexCoverGenerator(\n", + " w=uniform(loc=0.0, scale=100.0),\n", + " n=randint(low=10, high=11),\n", + " p=uniform(loc=0.25, scale=0.0),\n", + " fix_graph=True,\n", + ").generate(10)\n", + "\n", + "# Print the graph and weights for two instances\n", + "print(\"graph\", data[0].graph.edges)\n", + "print(\"weights[0]\", data[0].weights)\n", + "print(\"weights[1]\", data[1].weights)\n", + "print()\n", + "\n", + "# Load and optimize the first instance\n", + "model = build_vertexcover_model(data[0])\n", + "model.optimize()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f12e91f", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "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.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/guide/solvers.ipynb b/docs/guide/solvers.ipynb new file mode 100644 index 0000000..e19682d --- /dev/null +++ b/docs/guide/solvers.ipynb @@ -0,0 +1,247 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "9ec1907b-db93-4840-9439-c9005902b968", + "metadata": {}, + "source": [ + "# Learning Solver\n", + "\n", + "On previous pages, we discussed various components of the MIPLearn framework, including training data collectors, feature extractors, and individual machine learning components. In this page, we introduce **LearningSolver**, the main class of the framework which integrates all the aforementioned components into a cohesive whole. Using **LearningSolver** involves three steps: (i) configuring the solver; (ii) training the ML components; and (iii) solving new MIP instances. In the following, we describe each of these steps, then conclude with a complete runnable example.\n", + "\n", + "### Configuring the solver\n", + "\n", + "**LearningSolver** is composed by multiple individual machine learning components, each targeting a different part of the solution process, or implementing a different machine learning strategy. This architecture allows strategies to be easily enabled, disabled or customized, making the framework flexible. By default, no components are provided and **LearningSolver** is equivalent to a traditional MIP solver. To specify additional components, the `components` constructor argument may be used:\n", + "\n", + "```python\n", + "solver = LearningSolver(\n", + " components=[\n", + " comp1,\n", + " comp2,\n", + " comp3,\n", + " ]\n", + ")\n", + "```\n", + "\n", + "In this example, three components `comp1`, `comp2` and `comp3` are provided. The strategies implemented by these components are applied sequentially when solving the problem. For example, `comp1` and `comp2` could fix a subset of decision variables, while `comp3` constructs a warm start for the remaining problem.\n", + "\n", + "### Training and solving new instances\n", + "\n", + "Once a solver is configured, its ML components need to be trained. This can be achieved by the `solver.fit` method, as illustrated below. The method accepts a list of HDF5 files and trains each individual component sequentially. Once the solver is trained, new instances can be solved using `solver.optimize`. The method returns a dictionary of statistics collected by each component, such as the number of variables fixed.\n", + "\n", + "```python\n", + "# Build instances\n", + "train_data = ...\n", + "test_data = ...\n", + "\n", + "# Collect training data\n", + "bc = BasicCollector()\n", + "bc.collect(train_data, build_model)\n", + "\n", + "# Build solver\n", + "solver = LearningSolver(...)\n", + "\n", + "# Train components\n", + "solver.fit(train_data)\n", + "\n", + "# Solve a new test instance\n", + "stats = solver.optimize(test_data[0], build_model)\n", + "\n", + "```\n", + "\n", + "### Complete example\n", + "\n", + "In the example below, we illustrate the usage of **LearningSolver** by building instances of the Traveling Salesman Problem, collecting training data, training the ML components, then solving a new instance." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "92b09b98", + "metadata": { + "collapsed": false, + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 10 rows, 45 columns and 90 nonzeros\n", + "Model fingerprint: 0x6ddcd141\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [4e+01, 1e+03]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [2e+00, 2e+00]\n", + "Presolve time: 0.00s\n", + "Presolved: 10 rows, 45 columns, 90 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.3600000e+02 1.700000e+01 0.000000e+00 0s\n", + " 15 2.7610000e+03 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 15 iterations and 0.00 seconds (0.00 work units)\n", + "Optimal objective 2.761000000e+03\n", + "Set parameter LazyConstraints to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 10 rows, 45 columns and 90 nonzeros\n", + "Model fingerprint: 0x74ca3d0a\n", + "Variable types: 0 continuous, 45 integer (45 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+00]\n", + " Objective range [4e+01, 1e+03]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [2e+00, 2e+00]\n", + "\n", + "User MIP start produced solution with objective 2796 (0.00s)\n", + "Loaded user MIP start with objective 2796\n", + "\n", + "Presolve time: 0.00s\n", + "Presolved: 10 rows, 45 columns, 90 nonzeros\n", + "Variable types: 0 continuous, 45 integer (45 binary)\n", + "\n", + "Root relaxation: objective 2.761000e+03, 14 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 2761.00000 0 - 2796.00000 2761.00000 1.25% - 0s\n", + " 0 0 cutoff 0 2796.00000 2796.00000 0.00% - 0s\n", + "\n", + "Cutting planes:\n", + " Lazy constraints: 3\n", + "\n", + "Explored 1 nodes (16 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 1: 2796 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.796000000000e+03, best bound 2.796000000000e+03, gap 0.0000%\n", + "\n", + "User-callback calls 110, time in user-callback 0.00 sec\n" + ] + }, + { + "data": { + "text/plain": [ + "{'WS: Count': 1, 'WS: Number of variables set': 41.0}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import random\n", + "\n", + "import numpy as np\n", + "from scipy.stats import uniform, randint\n", + "from sklearn.linear_model import LogisticRegression\n", + "\n", + "from miplearn.classifiers.minprob import MinProbabilityClassifier\n", + "from miplearn.classifiers.singleclass import SingleClassFix\n", + "from miplearn.collectors.basic import BasicCollector\n", + "from miplearn.components.primal.actions import SetWarmStart\n", + "from miplearn.components.primal.indep import IndependentVarsPrimalComponent\n", + "from miplearn.extractors.AlvLouWeh2017 import AlvLouWeh2017Extractor\n", + "from miplearn.io import write_pkl_gz\n", + "from miplearn.problems.tsp import (\n", + " TravelingSalesmanGenerator,\n", + " build_tsp_model,\n", + ")\n", + "from miplearn.solvers.learning import LearningSolver\n", + "\n", + "# Set random seed to make example reproducible.\n", + "random.seed(42)\n", + "np.random.seed(42)\n", + "\n", + "# Generate a few instances of the traveling salesman problem.\n", + "data = TravelingSalesmanGenerator(\n", + " n=randint(low=10, high=11),\n", + " x=uniform(loc=0.0, scale=1000.0),\n", + " y=uniform(loc=0.0, scale=1000.0),\n", + " gamma=uniform(loc=0.90, scale=0.20),\n", + " fix_cities=True,\n", + " round=True,\n", + ").generate(50)\n", + "\n", + "# Save instance data to data/tsp/00000.pkl.gz, data/tsp/00001.pkl.gz, ...\n", + "all_data = write_pkl_gz(data, \"data/tsp\")\n", + "\n", + "# Split train/test data\n", + "train_data = all_data[:40]\n", + "test_data = all_data[40:]\n", + "\n", + "# Collect training data\n", + "bc = BasicCollector()\n", + "bc.collect(train_data, build_tsp_model, n_jobs=4)\n", + "\n", + "# Build learning solver\n", + "solver = LearningSolver(\n", + " components=[\n", + " IndependentVarsPrimalComponent(\n", + " base_clf=SingleClassFix(\n", + " MinProbabilityClassifier(\n", + " base_clf=LogisticRegression(),\n", + " thresholds=[0.95, 0.95],\n", + " ),\n", + " ),\n", + " extractor=AlvLouWeh2017Extractor(),\n", + " action=SetWarmStart(),\n", + " )\n", + " ]\n", + ")\n", + "\n", + "# Train ML models\n", + "solver.fit(train_data)\n", + "\n", + "# Solve a test instance\n", + "solver.optimize(test_data[0], build_tsp_model)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e27d2cbd-5341-461d-bbc1-8131aee8d949", + "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.9.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 4623a88..0000000 --- a/docs/index.md +++ /dev/null @@ -1,113 +0,0 @@ -# MIPLearn - -**MIPLearn** is an extensible framework for solving discrete optimization problems using a combination of Mixed-Integer Linear Programming (MIP) and Machine Learning (ML). The framework uses ML methods to automatically identify patterns in previously solved instances of the problem, then uses these patterns to accelerate the performance of conventional state-of-the-art MIP solvers (such as CPLEX, Gurobi or XPRESS). - -Unlike pure ML methods, MIPLearn is not only able to find high-quality solutions to discrete optimization problems, but it can also prove the optimality and feasibility of these solutions. -Unlike conventional MIP solvers, MIPLearn can take full advantage of very specific observations that happen to be true in a particular family of instances (such as the observation that a particular constraint is typically redundant, or that a particular variable typically assumes a certain value). - -## Table of Contents - -```{toctree} ---- -maxdepth: 1 -caption: JuMP Tutorials -numbered: true ---- -jump-tutorials/getting-started.ipynb -#jump-tutorials/lazy-constraints.ipynb -#jump-tutorials/user-cuts.ipynb -#jump-tutorials/customizing-ml.ipynb -``` - -```{toctree} ---- -maxdepth: 1 -caption: Pyomo Tutorials -numbered: true ---- -pyomo-tutorials/getting-started.ipynb -#pyomo-tutorials/lazy-constraints.ipynb -#pyomo-tutorials/user-cuts.ipynb -#pyomo-tutorials/customizing-ml.ipynb -``` - -```{toctree} ---- -maxdepth: 1 -caption: Benchmarks -numbered: true ---- -benchmarks/preliminaries.ipynb -benchmarks/stab.ipynb -#benchmarks/uc.ipynb -#benchmarks/facility.ipynb -benchmarks/knapsack.ipynb -benchmarks/tsp.ipynb -``` - - -```{toctree} ---- -maxdepth: 1 -caption: MIPLearn Internals -numbered: true ---- -#internals/solver-interfaces.ipynb -#internals/data-collection.ipynb -#internals/abstract-component.ipynb -#internals/primal.ipynb -#internals/static-lazy.ipynb -#internals/dynamic-lazy.ipynb -``` - -## Source Code - -* [https://github.com/ANL-CEEESA/MIPLearn](https://github.com/ANL-CEEESA/MIPLearn) - -## Authors - -* **Alinson S. Xavier,** Argonne National Laboratory <> -* **Feng Qiu,** Argonne National Laboratory <> - -## Acknowledgments - -* Based upon work supported by U.S. Department of Energy **Advanced Grid Modeling Program** under Grant DE-OE0000875. -* Based upon work supported by **Laboratory Directed Research and Development** (LDRD) funding from Argonne National Laboratory, provided by the Director, Office of Science, of the U.S. Department of Energy under Contract No. DE-AC02-06CH11357 - -## References - - -If you use MIPLearn in your research, or the included problem generators, we kindly request that you cite the package as follows: -- **Alinson S. Xavier, Feng Qiu.** *MIPLearn: An Extensible Framework for Learning-Enhanced Optimization*. Zenodo (2020). DOI: [10.5281/zenodo.4287567](https://doi.org/10.5281/zenodo.4287567) - -If you use MIPLearn in the field of power systems optimization, we kindly request that you cite the reference below, in which the main techniques implemented in MIPLearn were first developed: -- **Alinson S. Xavier, Feng Qiu, Shabbir Ahmed.** *Learning to Solve Large-Scale Unit Commitment Problems.* INFORMS Journal on Computing (2021). DOI: [10.1287/ijoc.2020.0976](https://doi.org/10.1287/ijoc.2020.0976) - -## License - -```text -MIPLearn, an extensible framework for Learning-Enhanced Mixed-Integer Optimization -Copyright © 2020, UChicago Argonne, LLC. All Rights Reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted -provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this list of - conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, this list of - conditions and the following disclaimer in the documentation and/or other materials provided - with the distribution. -3. Neither the name of the copyright holder nor the names of its contributors may be used to - endorse or promote products derived from this software without specific prior written - permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR -OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -``` diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..5a36032 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,67 @@ +MIPLearn +======== +**MIPLearn** is an extensible framework for solving discrete optimization problems using a combination of Mixed-Integer Linear Programming (MIP) and Machine Learning (ML). MIPLearn uses ML methods to automatically identify patterns in previously solved instances of the problem, then uses these patterns to accelerate the performance of conventional state-of-the-art MIP solvers such as CPLEX, Gurobi or XPRESS. + +Unlike pure ML methods, MIPLearn is not only able to find high-quality solutions to discrete optimization problems, but it can also prove the optimality and feasibility of these solutions. Unlike conventional MIP solvers, MIPLearn can take full advantage of very specific observations that happen to be true in a particular family of instances (such as the observation that a particular constraint is typically redundant, or that a particular variable typically assumes a certain value). For certain classes of problems, this approach may provide significant performance benefits. + + +Contents +-------- + +.. toctree:: + :maxdepth: 1 + :caption: Tutorials + :numbered: 2 + + tutorials/getting-started-pyomo + tutorials/getting-started-gurobipy + tutorials/getting-started-jump + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + :numbered: 2 + + guide/problems + guide/collectors + guide/features + guide/primal + guide/solvers + +.. toctree:: + :maxdepth: 1 + :caption: Python API Reference + :numbered: 2 + + api/problems + api/collectors + api/components + api/solvers + api/helpers + + +Authors +------- + +- **Alinson S. Xavier** (Argonne National Laboratory) +- **Feng Qiu** (Argonne National Laboratory) +- **Xiaoyi Gu** (Georgia Institute of Technology) +- **Berkay Becu** (Georgia Institute of Technology) +- **Santanu S. Dey** (Georgia Institute of Technology) + + +Acknowledgments +--------------- +* Based upon work supported by **Laboratory Directed Research and Development** (LDRD) funding from Argonne National Laboratory, provided by the Director, Office of Science, of the U.S. Department of Energy. +* Based upon work supported by the **U.S. Department of Energy Advanced Grid Modeling Program**. + +Citing MIPLearn +--------------- + +If you use MIPLearn in your research (either the solver or the included problem generators), we kindly request that you cite the package as follows: + +* **Alinson S. Xavier, Feng Qiu, Xiaoyi Gu, Berkay Becu, Santanu S. Dey.** *MIPLearn: An Extensible Framework for Learning-Enhanced Optimization (Version 0.3)*. Zenodo (2023). DOI: https://doi.org/10.5281/zenodo.4287567 + +If you use MIPLearn in the field of power systems optimization, we kindly request that you cite the reference below, in which the main techniques implemented in MIPLearn were first developed: + +* **Alinson S. Xavier, Feng Qiu, Shabbir Ahmed.** *Learning to Solve Large-Scale Unit Commitment Problems.* INFORMS Journal on Computing (2020). DOI: https://doi.org/10.1287/ijoc.2020.0976 diff --git a/docs/internals/abstract-component.ipynb b/docs/internals/abstract-component.ipynb deleted file mode 100644 index f93983c..0000000 --- a/docs/internals/abstract-component.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ad9274ff", - "metadata": {}, - "source": [ - "# Abstract component\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/internals/data-collection.ipynb b/docs/internals/data-collection.ipynb deleted file mode 100644 index 88cd584..0000000 --- a/docs/internals/data-collection.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "780b4172", - "metadata": {}, - "source": [ - "# Training data collection\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/internals/dynamic-lazy.ipynb b/docs/internals/dynamic-lazy.ipynb deleted file mode 100644 index fac0ca5..0000000 --- a/docs/internals/dynamic-lazy.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "5e3dd4c0", - "metadata": {}, - "source": [ - "# Dynamic lazy constraints & user cuts\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/internals/primal.ipynb b/docs/internals/primal.ipynb deleted file mode 100644 index 568e030..0000000 --- a/docs/internals/primal.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "c6d0d8dc", - "metadata": {}, - "source": [ - "# Primal solutions\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/internals/solver-interfaces.ipynb b/docs/internals/solver-interfaces.ipynb deleted file mode 100644 index 0f3c457..0000000 --- a/docs/internals/solver-interfaces.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ac509ea5", - "metadata": {}, - "source": [ - "# Solver interfaces\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/internals/static-lazy.ipynb b/docs/internals/static-lazy.ipynb deleted file mode 100644 index ec8fc3c..0000000 --- a/docs/internals/static-lazy.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ae350662", - "metadata": {}, - "source": [ - "# Static lazy constraints\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/jump-tutorials/Manifest.toml b/docs/jump-tutorials/Manifest.toml deleted file mode 100644 index 290a9c1..0000000 --- a/docs/jump-tutorials/Manifest.toml +++ /dev/null @@ -1,772 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -[[ASL_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "6252039f98492252f9e47c312c8ffda0e3b9e78d" -uuid = "ae81ac8f-d209-56e5-92de-9978fef736f9" -version = "0.1.3+0" - -[[ArgTools]] -uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" - -[[Artifacts]] -uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" - -[[Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" - -[[BenchmarkTools]] -deps = ["JSON", "Logging", "Printf", "Profile", "Statistics", "UUIDs"] -git-tree-sha1 = "4c10eee4af024676200bc7752e536f858c6b8f93" -uuid = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" -version = "1.3.1" - -[[BinaryProvider]] -deps = ["Libdl", "Logging", "SHA"] -git-tree-sha1 = "ecdec412a9abc8db54c0efc5548c64dfce072058" -uuid = "b99e7846-7c00-51b0-8f62-c81ae34c0232" -version = "0.5.10" - -[[Bzip2_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "19a35467a82e236ff51bc17a3a44b69ef35185a2" -uuid = "6e34b625-4abd-537c-b88f-471c36dfa7a0" -version = "1.0.8+0" - -[[CEnum]] -git-tree-sha1 = "215a9aa4a1f23fbd05b92769fdd62559488d70e9" -uuid = "fa961155-64e5-5f13-b03f-caf6b980ea82" -version = "0.4.1" - -[[CSV]] -deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings"] -git-tree-sha1 = "9519274b50500b8029973d241d32cfbf0b127d97" -uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" -version = "0.10.2" - -[[Calculus]] -deps = ["LinearAlgebra"] -git-tree-sha1 = "f641eb0a4f00c343bbc32346e1217b86f3ce9dad" -uuid = "49dc2e85-a5d0-5ad3-a950-438e2897f1b9" -version = "0.5.1" - -[[Cbc]] -deps = ["BinaryProvider", "CEnum", "Cbc_jll", "Libdl", "MathOptInterface", "SparseArrays"] -git-tree-sha1 = "98e3692f90b26a340f32e17475c396c3de4180de" -uuid = "9961bab8-2fa3-5c5a-9d89-47fab24efd76" -version = "0.8.1" - -[[Cbc_jll]] -deps = ["ASL_jll", "Artifacts", "Cgl_jll", "Clp_jll", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "OpenBLAS32_jll", "Osi_jll", "Pkg"] -git-tree-sha1 = "7693a7ca006d25e0d0097a5eee18ce86368e00cd" -uuid = "38041ee0-ae04-5750-a4d2-bb4d0d83d27d" -version = "200.1000.500+1" - -[[Cgl_jll]] -deps = ["Artifacts", "Clp_jll", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Osi_jll", "Pkg"] -git-tree-sha1 = "b5557f48e0e11819bdbda0200dbfa536dd12d9d9" -uuid = "3830e938-1dd0-5f3e-8b8e-b3ee43226782" -version = "0.6000.200+0" - -[[ChainRulesCore]] -deps = ["Compat", "LinearAlgebra", "SparseArrays"] -git-tree-sha1 = "c9a6160317d1abe9c44b3beb367fd448117679ca" -uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" -version = "1.13.0" - -[[ChangesOfVariables]] -deps = ["ChainRulesCore", "LinearAlgebra", "Test"] -git-tree-sha1 = "bf98fa45a0a4cee295de98d4c1462be26345b9a1" -uuid = "9e997f8a-9a97-42d5-a9f1-ce6bfc15e2c0" -version = "0.1.2" - -[[Clp]] -deps = ["BinaryProvider", "CEnum", "Clp_jll", "Libdl", "MathOptInterface", "SparseArrays"] -git-tree-sha1 = "3df260c4a5764858f312ec2a17f5925624099f3a" -uuid = "e2554f3b-3117-50c0-817c-e040a3ddf72d" -version = "0.8.4" - -[[Clp_jll]] -deps = ["Artifacts", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "METIS_jll", "MUMPS_seq_jll", "OpenBLAS32_jll", "Osi_jll", "Pkg"] -git-tree-sha1 = "5e4f9a825408dc6356e6bf1015e75d2b16250ec8" -uuid = "06985876-5285-5a41-9fcb-8948a742cc53" -version = "100.1700.600+0" - -[[CodeTracking]] -deps = ["InteractiveUtils", "UUIDs"] -git-tree-sha1 = "759a12cefe1cd1bb49e477bc3702287521797483" -uuid = "da1fd8a2-8d9e-5ec2-8556-3022fb5608a2" -version = "1.0.7" - -[[CodecBzip2]] -deps = ["Bzip2_jll", "Libdl", "TranscodingStreams"] -git-tree-sha1 = "2e62a725210ce3c3c2e1a3080190e7ca491f18d7" -uuid = "523fee87-0ab8-5b00-afb7-3ecf72e48cfd" -version = "0.7.2" - -[[CodecZlib]] -deps = ["TranscodingStreams", "Zlib_jll"] -git-tree-sha1 = "ded953804d019afa9a3f98981d99b33e3db7b6da" -uuid = "944b1d66-785c-5afd-91f1-9de20f533193" -version = "0.7.0" - -[[CoinUtils_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "44173e61256f32918c6c132fc41f772bab1fb6d1" -uuid = "be027038-0da8-5614-b30d-e42594cb92df" -version = "200.1100.400+0" - -[[CommonSubexpressions]] -deps = ["MacroTools", "Test"] -git-tree-sha1 = "7b8a93dba8af7e3b42fecabf646260105ac373f7" -uuid = "bbf7d656-a473-5ed7-a52c-81e309532950" -version = "0.3.0" - -[[Compat]] -deps = ["Base64", "Dates", "DelimitedFiles", "Distributed", "InteractiveUtils", "LibGit2", "Libdl", "LinearAlgebra", "Markdown", "Mmap", "Pkg", "Printf", "REPL", "Random", "SHA", "Serialization", "SharedArrays", "Sockets", "SparseArrays", "Statistics", "Test", "UUIDs", "Unicode"] -git-tree-sha1 = "44c37b4636bc54afac5c574d2d02b625349d6582" -uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" -version = "3.41.0" - -[[CompilerSupportLibraries_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" - -[[Conda]] -deps = ["Downloads", "JSON", "VersionParsing"] -git-tree-sha1 = "6e47d11ea2776bc5627421d59cdcc1296c058071" -uuid = "8f4d0f93-b110-5947-807f-2305c1781a2d" -version = "1.7.0" - -[[Crayons]] -git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" -uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" -version = "4.1.1" - -[[DataAPI]] -git-tree-sha1 = "cc70b17275652eb47bc9e5f81635981f13cea5c8" -uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" -version = "1.9.0" - -[[DataFrames]] -deps = ["Compat", "DataAPI", "Future", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrettyTables", "Printf", "REPL", "Reexport", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"] -git-tree-sha1 = "ae02104e835f219b8930c7664b8012c93475c340" -uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" -version = "1.3.2" - -[[DataStructures]] -deps = ["Compat", "InteractiveUtils", "OrderedCollections"] -git-tree-sha1 = "3daef5523dd2e769dad2365274f760ff5f282c7d" -uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -version = "0.18.11" - -[[DataValueInterfaces]] -git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" -uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" -version = "1.0.0" - -[[Dates]] -deps = ["Printf"] -uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" - -[[DelimitedFiles]] -deps = ["Mmap"] -uuid = "8bb1440f-4735-579b-a4ab-409b98df4dab" - -[[DensityInterface]] -deps = ["InverseFunctions", "Test"] -git-tree-sha1 = "80c3e8639e3353e5d2912fb3a1916b8455e2494b" -uuid = "b429d917-457f-4dbc-8f4c-0cc954292b1d" -version = "0.4.0" - -[[DiffResults]] -deps = ["StaticArrays"] -git-tree-sha1 = "c18e98cba888c6c25d1c3b048e4b3380ca956805" -uuid = "163ba53b-c6d8-5494-b064-1a9d43ac40c5" -version = "1.0.3" - -[[DiffRules]] -deps = ["IrrationalConstants", "LogExpFunctions", "NaNMath", "Random", "SpecialFunctions"] -git-tree-sha1 = "dd933c4ef7b4c270aacd4eb88fa64c147492acf0" -uuid = "b552c78f-8df3-52c6-915a-8e097449b14b" -version = "1.10.0" - -[[Distributed]] -deps = ["Random", "Serialization", "Sockets"] -uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" - -[[Distributions]] -deps = ["ChainRulesCore", "DensityInterface", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "SparseArrays", "SpecialFunctions", "Statistics", "StatsBase", "StatsFuns", "Test"] -git-tree-sha1 = "9d3c0c762d4666db9187f363a76b47f7346e673b" -uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" -version = "0.25.49" - -[[DocStringExtensions]] -deps = ["LibGit2"] -git-tree-sha1 = "b19534d1895d702889b219c382a6e18010797f0b" -uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" -version = "0.8.6" - -[[Downloads]] -deps = ["ArgTools", "LibCURL", "NetworkOptions"] -uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" - -[[DualNumbers]] -deps = ["Calculus", "NaNMath", "SpecialFunctions"] -git-tree-sha1 = "84f04fe68a3176a583b864e492578b9466d87f1e" -uuid = "fa6b7ba4-c1ee-5f82-b5fc-ecf0adba8f74" -version = "0.6.6" - -[[ExprTools]] -git-tree-sha1 = "56559bbef6ca5ea0c0818fa5c90320398a6fbf8d" -uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" -version = "0.1.8" - -[[FileIO]] -deps = ["Pkg", "Requires", "UUIDs"] -git-tree-sha1 = "80ced645013a5dbdc52cf70329399c35ce007fae" -uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" -version = "1.13.0" - -[[FilePathsBase]] -deps = ["Compat", "Dates", "Mmap", "Printf", "Test", "UUIDs"] -git-tree-sha1 = "04d13bfa8ef11720c24e4d840c0033d145537df7" -uuid = "48062228-2e41-5def-b9a4-89aafe57970f" -version = "0.9.17" - -[[FileWatching]] -uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" - -[[FillArrays]] -deps = ["LinearAlgebra", "Random", "SparseArrays", "Statistics"] -git-tree-sha1 = "4c7d3757f3ecbcb9055870351078552b7d1dbd2d" -uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" -version = "0.13.0" - -[[Formatting]] -deps = ["Printf"] -git-tree-sha1 = "8339d61043228fdd3eb658d86c926cb282ae72a8" -uuid = "59287772-0a20-5a39-b81b-1366585eb4c0" -version = "0.4.2" - -[[ForwardDiff]] -deps = ["CommonSubexpressions", "DiffResults", "DiffRules", "LinearAlgebra", "LogExpFunctions", "NaNMath", "Preferences", "Printf", "Random", "SpecialFunctions", "StaticArrays"] -git-tree-sha1 = "1bd6fc0c344fc0cbee1f42f8d2e7ec8253dda2d2" -uuid = "f6369f11-7733-5829-9624-2563aa707210" -version = "0.10.25" - -[[Future]] -deps = ["Random"] -uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" - -[[GMP_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "781609d7-10c4-51f6-84f2-b8444358ff6d" - -[[Glob]] -git-tree-sha1 = "4df9f7e06108728ebf00a0a11edee4b29a482bb2" -uuid = "c27321d9-0574-5035-807b-f59d2c89b15c" -version = "1.3.0" - -[[HTTP]] -deps = ["Base64", "Dates", "IniFile", "MbedTLS", "Sockets"] -git-tree-sha1 = "c7ec02c4c6a039a98a15f955462cd7aea5df4508" -uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "0.8.19" - -[[HypergeometricFunctions]] -deps = ["DualNumbers", "LinearAlgebra", "SpecialFunctions", "Test"] -git-tree-sha1 = "65e4589030ef3c44d3b90bdc5aac462b4bb05567" -uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" -version = "0.3.8" - -[[IniFile]] -git-tree-sha1 = "f550e6e32074c939295eb5ea6de31849ac2c9625" -uuid = "83e8ac13-25f8-5344-8a64-a9f2b223428f" -version = "0.5.1" - -[[InlineStrings]] -deps = ["Parsers"] -git-tree-sha1 = "61feba885fac3a407465726d0c330b3055df897f" -uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" -version = "1.1.2" - -[[InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" - -[[InverseFunctions]] -deps = ["Test"] -git-tree-sha1 = "a7254c0acd8e62f1ac75ad24d5db43f5f19f3c65" -uuid = "3587e190-3f89-42d0-90ee-14403ec27112" -version = "0.1.2" - -[[InvertedIndices]] -git-tree-sha1 = "bee5f1ef5bf65df56bdd2e40447590b272a5471f" -uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f" -version = "1.1.0" - -[[Ipopt_jll]] -deps = ["ASL_jll", "Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "MUMPS_seq_jll", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "82124f27743f2802c23fcb05febc517d0b15d86e" -uuid = "9cc047cb-c261-5740-88fc-0cf96f7bdcc7" -version = "3.13.4+2" - -[[IrrationalConstants]] -git-tree-sha1 = "7fd44fd4ff43fc60815f8e764c0f352b83c49151" -uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" -version = "0.1.1" - -[[IteratorInterfaceExtensions]] -git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" -uuid = "82899510-4779-5014-852e-03e436cf321d" -version = "1.0.0" - -[[JLD2]] -deps = ["FileIO", "MacroTools", "Mmap", "OrderedCollections", "Pkg", "Printf", "Reexport", "TranscodingStreams", "UUIDs"] -git-tree-sha1 = "28b114b3279cdbac9a61c57b3e6548a572142b34" -uuid = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -version = "0.4.21" - -[[JLLWrappers]] -deps = ["Preferences"] -git-tree-sha1 = "abc9885a7ca2052a736a600f7fa66209f96506e1" -uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" -version = "1.4.1" - -[[JSON]] -deps = ["Dates", "Mmap", "Parsers", "Unicode"] -git-tree-sha1 = "3c837543ddb02250ef42f4738347454f95079d4e" -uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "0.21.3" - -[[JSONSchema]] -deps = ["HTTP", "JSON", "ZipFile"] -git-tree-sha1 = "b84ab8139afde82c7c65ba2b792fe12e01dd7307" -uuid = "7d188eb4-7ad8-530c-ae41-71a32a6d4692" -version = "0.3.3" - -[[JuMP]] -deps = ["Calculus", "DataStructures", "ForwardDiff", "JSON", "LinearAlgebra", "MathOptInterface", "MutableArithmetics", "NaNMath", "Printf", "Random", "SparseArrays", "SpecialFunctions", "Statistics"] -git-tree-sha1 = "4358b7cbf2db36596bdbbe3becc6b9d87e4eb8f5" -uuid = "4076af6c-e467-56ae-b986-b466b2749572" -version = "0.21.10" - -[[JuliaInterpreter]] -deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] -git-tree-sha1 = "0a815f0060ab182f6c484b281107bfcd5bbb58dc" -uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" -version = "0.9.7" - -[[LazyArtifacts]] -deps = ["Artifacts", "Pkg"] -uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3" - -[[LibCURL]] -deps = ["LibCURL_jll", "MozillaCACerts_jll"] -uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" - -[[LibCURL_jll]] -deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] -uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" - -[[LibGit2]] -deps = ["Base64", "NetworkOptions", "Printf", "SHA"] -uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" - -[[LibSSH2_jll]] -deps = ["Artifacts", "Libdl", "MbedTLS_jll"] -uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" - -[[Libdl]] -uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" - -[[LinearAlgebra]] -deps = ["Libdl"] -uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" - -[[LogExpFunctions]] -deps = ["ChainRulesCore", "ChangesOfVariables", "DocStringExtensions", "InverseFunctions", "IrrationalConstants", "LinearAlgebra"] -git-tree-sha1 = "e5718a00af0ab9756305a0392832c8952c7426c1" -uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" -version = "0.3.6" - -[[Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" - -[[LoweredCodeUtils]] -deps = ["JuliaInterpreter"] -git-tree-sha1 = "6b0440822974cab904c8b14d79743565140567f6" -uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" -version = "2.2.1" - -[[METIS_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "1d31872bb9c5e7ec1f618e8c4a56c8b0d9bddc7e" -uuid = "d00139f3-1899-568f-a2f0-47f597d42d70" -version = "5.1.1+0" - -[[MIPLearn]] -deps = ["CSV", "Cbc", "Clp", "Conda", "DataFrames", "DataStructures", "Distributed", "JLD2", "JSON", "JuMP", "Logging", "MathOptInterface", "PackageCompiler", "Printf", "PyCall", "Random", "SparseArrays", "Statistics", "TimerOutputs"] -path = "/home/isoron/Developer/MIPLearn.jl/dev" -uuid = "2b1277c3-b477-4c49-a15e-7ba350325c68" -version = "0.2.0" - -[[MUMPS_seq_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "METIS_jll", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "1a11a84b2af5feb5a62a820574804056cdc59c39" -uuid = "d7ed1dd3-d0ae-5e8e-bfb4-87a502085b8d" -version = "5.2.1+4" - -[[MacroTools]] -deps = ["Markdown", "Random"] -git-tree-sha1 = "3d3e902b31198a27340d0bf00d6ac452866021cf" -uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" -version = "0.5.9" - -[[Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" - -[[MathOptInterface]] -deps = ["BenchmarkTools", "CodecBzip2", "CodecZlib", "JSON", "JSONSchema", "LinearAlgebra", "MutableArithmetics", "OrderedCollections", "SparseArrays", "Test", "Unicode"] -git-tree-sha1 = "575644e3c05b258250bb599e57cf73bbf1062901" -uuid = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" -version = "0.9.22" - -[[MbedTLS]] -deps = ["Dates", "MbedTLS_jll", "Random", "Sockets"] -git-tree-sha1 = "1c38e51c3d08ef2278062ebceade0e46cefc96fe" -uuid = "739be429-bea8-5141-9913-cc70e7f3736d" -version = "1.0.3" - -[[MbedTLS_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" - -[[Missings]] -deps = ["DataAPI"] -git-tree-sha1 = "bf210ce90b6c9eed32d25dbcae1ebc565df2687f" -uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" -version = "1.0.2" - -[[Mmap]] -uuid = "a63ad114-7e13-5084-954f-fe012c677804" - -[[MozillaCACerts_jll]] -uuid = "14a3606d-f60d-562e-9121-12d972cd8159" - -[[MutableArithmetics]] -deps = ["LinearAlgebra", "SparseArrays", "Test"] -git-tree-sha1 = "8d9496b2339095901106961f44718920732616bb" -uuid = "d8a4904e-b15c-11e9-3269-09a3773c0cb0" -version = "0.2.22" - -[[NaNMath]] -git-tree-sha1 = "b086b7ea07f8e38cf122f5016af580881ac914fe" -uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" -version = "0.3.7" - -[[NetworkOptions]] -uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" - -[[OpenBLAS32_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "ba4a8f683303c9082e84afba96f25af3c7fb2436" -uuid = "656ef2d0-ae68-5445-9ca0-591084a874a2" -version = "0.3.12+1" - -[[OpenLibm_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "05823500-19ac-5b8b-9628-191a04bc5112" - -[[OpenSpecFun_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "13652491f6856acfd2db29360e1bbcd4565d04f1" -uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" -version = "0.5.5+0" - -[[OrderedCollections]] -git-tree-sha1 = "85f8e6578bf1f9ee0d11e7bb1b1456435479d47c" -uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -version = "1.4.1" - -[[Osi_jll]] -deps = ["Artifacts", "CoinUtils_jll", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "OpenBLAS32_jll", "Pkg"] -git-tree-sha1 = "28e0ddebd069f605ab1988ab396f239a3ac9b561" -uuid = "7da25872-d9ce-5375-a4d3-7a845f58efdd" -version = "0.10800.600+0" - -[[PDMats]] -deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] -git-tree-sha1 = "7e2166042d1698b6072352c74cfd1fca2a968253" -uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" -version = "0.11.6" - -[[PackageCompiler]] -deps = ["Artifacts", "LazyArtifacts", "Libdl", "Pkg", "Printf", "RelocatableFolders", "UUIDs"] -git-tree-sha1 = "4ad92047603f8e955503f92767577b32508c39af" -uuid = "9b87118b-4619-50d2-8e1e-99f35a4d4d9d" -version = "2.0.5" - -[[Parsers]] -deps = ["Dates"] -git-tree-sha1 = "13468f237353112a01b2d6b32f3d0f80219944aa" -uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" -version = "2.2.2" - -[[Pkg]] -deps = ["Artifacts", "Dates", "Downloads", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] -uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" - -[[PooledArrays]] -deps = ["DataAPI", "Future"] -git-tree-sha1 = "db3a23166af8aebf4db5ef87ac5b00d36eb771e2" -uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720" -version = "1.4.0" - -[[Preferences]] -deps = ["TOML"] -git-tree-sha1 = "de893592a221142f3db370f48290e3a2ef39998f" -uuid = "21216c6a-2e73-6563-6e65-726566657250" -version = "1.2.4" - -[[PrettyTables]] -deps = ["Crayons", "Formatting", "Markdown", "Reexport", "Tables"] -git-tree-sha1 = "dfb54c4e414caa595a1f2ed759b160f5a3ddcba5" -uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" -version = "1.3.1" - -[[Printf]] -deps = ["Unicode"] -uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" - -[[Profile]] -deps = ["Printf"] -uuid = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" - -[[ProgressBars]] -deps = ["Printf"] -git-tree-sha1 = "938525cc66a4058f6ed75b84acd13a00fbecea11" -uuid = "49802e3a-d2f1-5c88-81d8-b72133a6f568" -version = "1.4.0" - -[[PyCall]] -deps = ["Conda", "Dates", "Libdl", "LinearAlgebra", "MacroTools", "Serialization", "VersionParsing"] -git-tree-sha1 = "71fd4022ecd0c6d20180e23ff1b3e05a143959c2" -uuid = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" -version = "1.93.0" - -[[QuadGK]] -deps = ["DataStructures", "LinearAlgebra"] -git-tree-sha1 = "78aadffb3efd2155af139781b8a8df1ef279ea39" -uuid = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" -version = "2.4.2" - -[[REPL]] -deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] -uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" - -[[Random]] -deps = ["Serialization"] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" - -[[Reexport]] -git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" -uuid = "189a3867-3050-52da-a836-e630ba90ab69" -version = "1.2.2" - -[[RelocatableFolders]] -deps = ["SHA", "Scratch"] -git-tree-sha1 = "cdbd3b1338c72ce29d9584fdbe9e9b70eeb5adca" -uuid = "05181044-ff0b-4ac5-8273-598c1e38db00" -version = "0.1.3" - -[[Requires]] -deps = ["UUIDs"] -git-tree-sha1 = "838a3a4188e2ded87a4f9f184b4b0d78a1e91cb7" -uuid = "ae029012-a4dd-5104-9daa-d747884805df" -version = "1.3.0" - -[[Revise]] -deps = ["CodeTracking", "Distributed", "FileWatching", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Pkg", "REPL", "Requires", "UUIDs", "Unicode"] -git-tree-sha1 = "606ddc4d3d098447a09c9337864c73d017476424" -uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" -version = "3.3.2" - -[[Rmath]] -deps = ["Random", "Rmath_jll"] -git-tree-sha1 = "bf3188feca147ce108c76ad82c2792c57abe7b1f" -uuid = "79098fc4-a85e-5d69-aa6a-4863f24498fa" -version = "0.7.0" - -[[Rmath_jll]] -deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "68db32dff12bb6127bac73c209881191bf0efbb7" -uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" -version = "0.3.0+0" - -[[SCIP]] -deps = ["Ipopt_jll", "Libdl", "MathOptInterface", "SCIP_jll"] -git-tree-sha1 = "6b799e6a23746633f94f4f10a9ac234f8b86f680" -repo-rev = "7aa79aaa" -repo-url = "https://github.com/scipopt/SCIP.jl.git" -uuid = "82193955-e24f-5292-bf16-6f2c5261a85f" -version = "0.9.8" - -[[SCIP_jll]] -deps = ["Artifacts", "CompilerSupportLibraries_jll", "GMP_jll", "Ipopt_jll", "JLLWrappers", "Libdl", "Pkg", "Zlib_jll", "bliss_jll"] -git-tree-sha1 = "83d35a061885aa73491aa2f8db28310214bbd521" -uuid = "e5ac4fe4-a920-5659-9bf8-f9f73e9e79ce" -version = "0.1.3+0" - -[[SHA]] -uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" - -[[Scratch]] -deps = ["Dates"] -git-tree-sha1 = "0b4b7f1393cff97c33891da2a0bf69c6ed241fda" -uuid = "6c6a2e73-6563-6170-7368-637461726353" -version = "1.1.0" - -[[SentinelArrays]] -deps = ["Dates", "Random"] -git-tree-sha1 = "6a2f7d70512d205ca8c7ee31bfa9f142fe74310c" -uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" -version = "1.3.12" - -[[Serialization]] -uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" - -[[SharedArrays]] -deps = ["Distributed", "Mmap", "Random", "Serialization"] -uuid = "1a1011a3-84de-559e-8e89-a11a2f7dc383" - -[[Sockets]] -uuid = "6462fe0b-24de-5631-8697-dd941f90decc" - -[[SortingAlgorithms]] -deps = ["DataStructures"] -git-tree-sha1 = "b3363d7460f7d098ca0912c69b082f75625d7508" -uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" -version = "1.0.1" - -[[SparseArrays]] -deps = ["LinearAlgebra", "Random"] -uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" - -[[SpecialFunctions]] -deps = ["ChainRulesCore", "IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] -git-tree-sha1 = "cbf21db885f478e4bd73b286af6e67d1beeebe4c" -uuid = "276daf66-3868-5448-9aa4-cd146d93841b" -version = "1.8.4" - -[[StaticArrays]] -deps = ["LinearAlgebra", "Random", "Statistics"] -git-tree-sha1 = "6354dfaf95d398a1a70e0b28238321d5d17b2530" -uuid = "90137ffa-7385-5640-81b9-e52037218182" -version = "1.4.0" - -[[Statistics]] -deps = ["LinearAlgebra", "SparseArrays"] -uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" - -[[StatsAPI]] -deps = ["LinearAlgebra"] -git-tree-sha1 = "c3d8ba7f3fa0625b062b82853a7d5229cb728b6b" -uuid = "82ae8749-77ed-4fe6-ae5f-f523153014b0" -version = "1.2.1" - -[[StatsBase]] -deps = ["DataAPI", "DataStructures", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] -git-tree-sha1 = "8977b17906b0a1cc74ab2e3a05faa16cf08a8291" -uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" -version = "0.33.16" - -[[StatsFuns]] -deps = ["ChainRulesCore", "HypergeometricFunctions", "InverseFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] -git-tree-sha1 = "25405d7016a47cf2bd6cd91e66f4de437fd54a07" -uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" -version = "0.9.16" - -[[SuiteSparse]] -deps = ["Libdl", "LinearAlgebra", "Serialization", "SparseArrays"] -uuid = "4607b0f0-06f3-5cda-b6b1-a6196a1729e9" - -[[TOML]] -deps = ["Dates"] -uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" - -[[TableTraits]] -deps = ["IteratorInterfaceExtensions"] -git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39" -uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" -version = "1.0.1" - -[[Tables]] -deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"] -git-tree-sha1 = "bb1064c9a84c52e277f1096cf41434b675cd368b" -uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" -version = "1.6.1" - -[[Tar]] -deps = ["ArgTools", "SHA"] -uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" - -[[Test]] -deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] -uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - -[[TimerOutputs]] -deps = ["ExprTools", "Printf"] -git-tree-sha1 = "97e999be94a7147d0609d0b9fc9feca4bf24d76b" -uuid = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" -version = "0.5.15" - -[[TranscodingStreams]] -deps = ["Random", "Test"] -git-tree-sha1 = "216b95ea110b5972db65aa90f88d8d89dcb8851c" -uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" -version = "0.9.6" - -[[UUIDs]] -deps = ["Random", "SHA"] -uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" - -[[Unicode]] -uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" - -[[VersionParsing]] -git-tree-sha1 = "58d6e80b4ee071f5efd07fda82cb9fbe17200868" -uuid = "81def892-9a0e-5fdd-b105-ffc91e053289" -version = "1.3.0" - -[[WeakRefStrings]] -deps = ["DataAPI", "InlineStrings", "Parsers"] -git-tree-sha1 = "c69f9da3ff2f4f02e811c3323c22e5dfcb584cfa" -uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5" -version = "1.4.1" - -[[ZipFile]] -deps = ["Libdl", "Printf", "Zlib_jll"] -git-tree-sha1 = "3593e69e469d2111389a9bd06bac1f3d730ac6de" -uuid = "a5390f91-8eb1-5f08-bee0-b1d1ffed6cea" -version = "0.9.4" - -[[Zlib_jll]] -deps = ["Libdl"] -uuid = "83775a58-1f1d-513f-b197-d71354ab007a" - -[[bliss_jll]] -deps = ["Artifacts", "GMP_jll", "JLLWrappers", "Libdl", "Pkg"] -git-tree-sha1 = "f8b75e896a326a162a4f6e998990521d8302c810" -uuid = "508c9074-7a14-5c94-9582-3d4bc1871065" -version = "0.77.0+1" - -[[nghttp2_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" - -[[p7zip_jll]] -deps = ["Artifacts", "Libdl"] -uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" diff --git a/docs/jump-tutorials/Project.toml b/docs/jump-tutorials/Project.toml deleted file mode 100644 index f08e414..0000000 --- a/docs/jump-tutorials/Project.toml +++ /dev/null @@ -1,9 +0,0 @@ -[deps] -Cbc = "9961bab8-2fa3-5c5a-9d89-47fab24efd76" -Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" -Glob = "c27321d9-0574-5035-807b-f59d2c89b15c" -JuMP = "4076af6c-e467-56ae-b986-b466b2749572" -MIPLearn = "2b1277c3-b477-4c49-a15e-7ba350325c68" -ProgressBars = "49802e3a-d2f1-5c88-81d8-b72133a6f568" -Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" -SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" diff --git a/docs/jump-tutorials/customizing-ml.ipynb b/docs/jump-tutorials/customizing-ml.ipynb deleted file mode 100644 index 5e55360..0000000 --- a/docs/jump-tutorials/customizing-ml.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "ea2dc06a", - "metadata": {}, - "source": [ - "# Customizing the ML models\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/jump-tutorials/getting-started.ipynb b/docs/jump-tutorials/getting-started.ipynb deleted file mode 100644 index f6b6eae..0000000 --- a/docs/jump-tutorials/getting-started.ipynb +++ /dev/null @@ -1,691 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "9b0c4eed", - "metadata": {}, - "source": [ - "# Getting started\n", - "\n", - "## Introduction\n", - "\n", - "**MIPLearn** is an open source framework that uses machine learning (ML) to accelerate the performance of both commercial and open source mixed-integer programming solvers (e.g. Gurobi, CPLEX, XPRESS, Cbc or SCIP). In this tutorial, we will:\n", - "\n", - "1. Install the Julia/JuMP version of MIPLearn\n", - "2. Model a simple optimization problem using JuMP\n", - "3. Generate training data and train the ML models\n", - "4. Use the ML models together with Gurobi to solve new instances\n", - "\n", - "
\n", - "Warning\n", - " \n", - "MIPLearn is still in early development stage. If run into any bugs or issues, please submit a bug report in our GitHub repository. Comments, suggestions and pull requests are also very welcome!\n", - " \n", - "
\n" - ] - }, - { - "cell_type": "markdown", - "id": "f0d159b8", - "metadata": {}, - "source": [ - "## Installation\n", - "\n", - "MIPLearn is available in two versions:\n", - "\n", - "- Python version, compatible with the Pyomo modeling language,\n", - "- Julia version, compatible with the JuMP modeling language.\n", - "\n", - "In this tutorial, we will demonstrate how to use and install the Julia/JuMP version of the package. The first step is to install the Julia programming language in your computer. [See the official instructions for more details](https://julialang.org/downloads/). Note that MIPLearn was developed and tested with Julia 1.6, and may not be compatible with newer versions of the language. After Julia is installed, launch its console and run the following commands to download and install the package:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "b16685be", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Path `/home/axavier/Packages/MIPLearn.jl/dev` exists and looks like the correct package. Using existing path.\n", - "\u001b[32m\u001b[1m Resolving\u001b[22m\u001b[39m package versions...\n", - "\u001b[32m\u001b[1m No Changes\u001b[22m\u001b[39m to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Project.toml`\n", - "\u001b[32m\u001b[1m No Changes\u001b[22m\u001b[39m to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Manifest.toml`\n" - ] - } - ], - "source": [ - "using Pkg\n", - "#Pkg.add(PackageSpec(url=\"https://github.com/ANL-CEEESA/MIPLearn.jl.git\"))\n", - "Pkg.develop(PackageSpec(path=\"/home/axavier/Packages/MIPLearn.jl/dev\"))" - ] - }, - { - "cell_type": "markdown", - "id": "e5ed7716", - "metadata": {}, - "source": [ - "In addition to MIPLearn itself, we will also install a few other packages that are required for this tutorial:\n", - "\n", - "- [**Gurobi**](https://www.gurobi.com/), a state-of-the-art MIP solver\n", - "- [**JuMP**](https://jump.dev/), an open source modeling language for Julia\n", - "- [**Distributions.jl**](https://github.com/JuliaStats/Distributions.jl), a statistics package that we will use to generate random inputs" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "f88155c5", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\u001b[32m\u001b[1m Updating\u001b[22m\u001b[39m registry at `~/.julia/registries/General`\n", - "\u001b[32m\u001b[1m Updating\u001b[22m\u001b[39m git-repo `https://github.com/JuliaRegistries/General.git`\n", - "\u001b[32m\u001b[1m Resolving\u001b[22m\u001b[39m package versions...\n", - "\u001b[32m\u001b[1m No Changes\u001b[22m\u001b[39m to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Project.toml`\n", - "\u001b[32m\u001b[1m No Changes\u001b[22m\u001b[39m to `~/Packages/MIPLearn/dev/docs/jump-tutorials/Manifest.toml`\n" - ] - } - ], - "source": [ - "using Pkg\n", - "Pkg.add([\n", - " PackageSpec(name=\"Gurobi\", version=\"0.9.14\"),\n", - " PackageSpec(name=\"JuMP\", version=\"0.21\"),\n", - " PackageSpec(name=\"Distributions\", version=\"0.25\"),\n", - " PackageSpec(name=\"Glob\", version=\"1\"),\n", - "])" - ] - }, - { - "cell_type": "markdown", - "id": "a0e1dda5", - "metadata": {}, - "source": [ - "
\n", - " \n", - "Note\n", - " \n", - "In the code above, we install specific version of all packages to ensure that this tutorial keeps running in the future, even when newer (and possibly incompatible) versions of the packages are released. This is usually a recommended practice for all Julia projects.\n", - " \n", - "
" - ] - }, - { - "cell_type": "markdown", - "id": "378b6a97", - "metadata": {}, - "source": [ - "## Modeling a simple optimization problem\n", - "\n", - "To illustrate how can MIPLearn be used, we will model and solve a small optimization problem related to power systems optimization. The problem we discuss below is a simplification of the **unit commitment problem,** a practical optimization problem solved daily by electric grid operators around the world. \n", - "\n", - "Suppose that you work at a utility company, and that it is your job to decide which electrical generators should be online at a certain hour of the day, as well as how much power should each generator produce. More specifically, assume that your company owns $n$ generators, denoted by $g_1, \\ldots, g_n$. Each generator can either be online or offline. An online generator $g_i$ can produce between $p^\\text{min}_i$ to $p^\\text{max}_i$ megawatts of power, and it costs your company $c^\\text{fix}_i + c^\\text{var}_i y_i$, where $y_i$ is the amount of power produced. An offline generator produces nothing and costs nothing. You also know that the total amount of power to be produced needs to be exactly equal to the total demand $d$ (in megawatts). To minimize the costs to your company, which generators should be online, and how much power should they produce?\n", - "\n", - "This simple problem can be modeled as a *mixed-integer linear optimization* problem as follows. For each generator $g_i$, let $x_i \\in \\{0,1\\}$ be a decision variable indicating whether $g_i$ is online, and let $y_i \\geq 0$ be a decision variable indicating how much power does $g_i$ produce. The problem is then given by:\n", - "\n", - "$$\n", - "\\begin{align}\n", - "\\text{minimize } \\quad & \\sum_{i=1}^n \\left( c^\\text{fix}_i x_i + c^\\text{var}_i y_i \\right) \\\\\n", - "\\text{subject to } \\quad & y_i \\leq p^\\text{max}_i x_i & i=1,\\ldots,n \\\\\n", - "& y_i \\geq p^\\text{min}_i x_i & i=1,\\ldots,n \\\\\n", - "& \\sum_{i=1}^n y_i = d \\\\\n", - "& x_i \\in \\{0,1\\} & i=1,\\ldots,n \\\\\n", - "& y_i \\geq 0 & i=1,\\ldots,n\n", - "\\end{align}\n", - "$$\n", - "\n", - "
\n", - " \n", - "Note\n", - " \n", - "We use a simplified version of the unit commitment problem in this tutorial just to make it easier to follow. MIPLearn can also handle realistic, large-scale versions of this problem. See benchmarks for more details.\n", - " \n", - "
\n", - "\n", - "Next, let us convert this abstract mathematical formulation into a concrete optimization model, using Julia and JuMP. We start by defining a data structure that holds all the input data." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "798b2f6c", - "metadata": {}, - "outputs": [], - "source": [ - "Base.@kwdef struct UnitCommitmentData\n", - " demand::Float64\n", - " pmin::Vector{Float64}\n", - " pmax::Vector{Float64}\n", - " cfix::Vector{Float64}\n", - " cvar::Vector{Float64}\n", - "end;" - ] - }, - { - "cell_type": "markdown", - "id": "104b709a", - "metadata": {}, - "source": [ - "Next, we create a function that converts this data structure into a concrete JuMP model. For more details on the JuMP syntax, see [the official JuMP documentation](https://jump.dev/JuMP.jl/stable/)." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "a7c048e4", - "metadata": {}, - "outputs": [], - "source": [ - "using JuMP\n", - "\n", - "function build_uc_model(data::UnitCommitmentData)::Model\n", - " model = Model()\n", - " n = length(data.pmin)\n", - " @variable(model, x[1:n], Bin)\n", - " @variable(model, y[1:n] >= 0)\n", - " @objective(\n", - " model,\n", - " Min,\n", - " sum(\n", - " data.cfix[i] * x[i] +\n", - " data.cvar[i] * y[i]\n", - " for i in 1:n\n", - " )\n", - " )\n", - " @constraint(model, eq_max_power[i in 1:n], y[i] <= data.pmax[i] * x[i])\n", - " @constraint(model, eq_min_power[i in 1:n], y[i] >= data.pmin[i] * x[i])\n", - " @constraint(model, eq_demand, sum(y[i] for i in 1:n) == data.demand)\n", - " return model\n", - "end;" - ] - }, - { - "cell_type": "markdown", - "id": "5f10142e", - "metadata": {}, - "source": [ - "At this point, we can already use JuMP and any mixed-integer linear programming solver to find optimal solutions to any instance of this problem. To illustrate this, let us solve a small instance with three generators, using SCIP:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "bc2022a4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "obj = 1320.0\n", - " x = [0.0, 1.0, 1.0]\n", - " y = [0.0, 60.0, 40.0]\n" - ] - } - ], - "source": [ - "using Gurobi\n", - "\n", - "model = build_uc_model(\n", - " UnitCommitmentData(\n", - " demand = 100.0,\n", - " pmin = [10, 20, 30],\n", - " pmax = [50, 60, 70],\n", - " cfix = [700, 600, 500],\n", - " cvar = [1.5, 2.0, 2.5],\n", - " )\n", - ")\n", - "\n", - "gurobi = optimizer_with_attributes(Gurobi.Optimizer, \"Threads\" => 1, \"Seed\" => 42)\n", - "set_optimizer(model, gurobi)\n", - "set_silent(model)\n", - "optimize!(model)\n", - "\n", - "println(\"obj = \", objective_value(model))\n", - "println(\" x = \", round.(value.(model[:x])))\n", - "println(\" y = \", round.(value.(model[:y]), digits=2));" - ] - }, - { - "cell_type": "markdown", - "id": "9ee6958b", - "metadata": {}, - "source": [ - "Running the code above, we found that the optimal solution for our small problem instance costs \\$1320. It is achieve by keeping generators 2 and 3 online and producing, respectively, 60 MW and 40 MW of power." - ] - }, - { - "cell_type": "markdown", - "id": "f34e3d44", - "metadata": {}, - "source": [ - "## Generating training data\n", - "\n", - "Although SCIP could solve the small example above in a fraction of a second, it gets slower for larger and more complex versions of the problem. If this is a problem that needs to be solved frequently, as it is often the case in practice, it could make sense to spend some time upfront generating a **trained** version of SCIP, which can solve new instances (similar to the ones it was trained on) faster.\n", - "\n", - "In the following, we will use MIPLearn to train machine learning models that can be used to accelerate SCIP's performance on a particular set of instances. More specifically, MIPLearn will train a model that is able to predict the optimal solution for instances that follow a given probability distribution, then it will provide this predicted solution to SCIP as a warm start.\n", - "\n", - "Before we can train the model, we need to collect training data by solving a large number of instances. In real-world situations, we may construct these training instances based on historical data. In this tutorial, we will construct them using a random instance generator:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a498e1e1", - "metadata": {}, - "outputs": [], - "source": [ - "using Distributions\n", - "using Random\n", - "\n", - "function random_uc_data(; samples::Int, n::Int, seed=42)\n", - " Random.seed!(seed)\n", - " pmin = rand(Uniform(100, 500.0), n)\n", - " pmax = pmin .* rand(Uniform(2.0, 2.5), n)\n", - " cfix = pmin .* rand(Uniform(100.0, 125.0), n)\n", - " cvar = rand(Uniform(1.25, 1.5), n)\n", - " return [\n", - " UnitCommitmentData(;\n", - " pmin,\n", - " pmax,\n", - " cfix,\n", - " cvar,\n", - " demand = sum(pmax) * rand(Uniform(0.5, 0.75)),\n", - " )\n", - " for i in 1:samples\n", - " ]\n", - "end;" - ] - }, - { - "cell_type": "markdown", - "id": "e33bb12c", - "metadata": {}, - "source": [ - "In this example, for simplicity, only the demands change from one instance to the next. We could also have randomized the costs, production limits or even the number of units. The more randomization we have in the training data, however, the more challenging it is for the machine learning models to learn solution patterns.\n", - "\n", - "Now we generate 500 instances of this problem, each one with 50 generators, and we use 450 of these instances for training. After generating the instances, we write them to individual files. MIPLearn uses files during the training process because, for large-scale optimization problems, it is often impractical to hold in memory the entire training data, as well as the concrete JuMP models. Files also make it much easier to solve multiple instances simultaneously, potentially even on multiple machines. We will cover parallel and distributed computing in a future tutorial. The code below generates the files `uc/train/00001.jld2`, `uc/train/00002.jld2`, etc., which contain the input data in [JLD2 format](https://github.com/JuliaIO/JLD2.jl)." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "5358a046", - "metadata": {}, - "outputs": [], - "source": [ - "using MIPLearn\n", - "data = random_uc_data(samples=500, n=50);\n", - "train_files = MIPLearn.save(data[1:450], \"uc/train/\")\n", - "test_files = MIPLearn.save(data[451:500], \"uc/test/\");" - ] - }, - { - "cell_type": "markdown", - "id": "38a27d1c", - "metadata": {}, - "source": [ - "Finally, we use `LearningSolver` to solve all the training instances. `LearningSolver` is the main component provided by MIPLearn, which integrates MIP solvers and ML. The optimal solutions, along with other useful training data, are stored in HDF5 files `uc/train/00001.h5`, `uc/train/00002.h5`, etc." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "c341b12d", - "metadata": {}, - "outputs": [], - "source": [ - "solver = LearningSolver(gurobi)\n", - "solve!(solver, train_files, build_uc_model);" - ] - }, - { - "cell_type": "markdown", - "id": "189b4f60", - "metadata": {}, - "source": [ - "## Solving new instances\n", - "\n", - "With training data in hand, we can now fit the ML models using `MIPLearn.fit!`, then solve the test instances with `MIPLearn.solve!`, as shown below. The `tee=true` parameter asks MIPLearn to print the solver log to the screen." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "1cf11450", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0xfb382c05\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 1e+03]\n", - " Objective range [1e+00, 6e+04]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+04, 2e+04]\n", - "Presolve removed 100 rows and 50 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 1 rows, 50 columns, 50 nonzeros\n", - "\n", - "Iteration Objective Primal Inf. Dual Inf. Time\n", - " 0 7.0629410e+05 6.782322e+02 0.000000e+00 0s\n", - " 1 8.0678161e+05 0.000000e+00 0.000000e+00 0s\n", - "\n", - "Solved in 1 iterations and 0.00 seconds\n", - "Optimal objective 8.067816095e+05\n", - "\n", - "User-callback calls 33, time in user-callback 0.00 sec\n", - "\n", - "Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0x7bb6bbd6\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 1e+03]\n", - " Objective range [1e+00, 6e+04]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+04, 2e+04]\n", - "\n", - "User MIP start produced solution with objective 822175 (0.00s)\n", - "User MIP start produced solution with objective 812767 (0.00s)\n", - "User MIP start produced solution with objective 811628 (0.00s)\n", - "User MIP start produced solution with objective 809648 (0.01s)\n", - "User MIP start produced solution with objective 808536 (0.01s)\n", - "Loaded user MIP start with objective 808536\n", - "\n", - "Presolve time: 0.00s\n", - "Presolved: 101 rows, 100 columns, 250 nonzeros\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "\n", - "Root relaxation: objective 8.067816e+05, 55 iterations, 0.00 seconds\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 806781.610 0 1 808536.496 806781.610 0.22% - 0s\n", - "H 0 0 808091.02482 806781.610 0.16% - 0s\n", - " 0 0 807198.955 0 2 808091.025 807198.955 0.11% - 0s\n", - " 0 0 807198.955 0 1 808091.025 807198.955 0.11% - 0s\n", - " 0 0 807198.955 0 2 808091.025 807198.955 0.11% - 0s\n", - " 0 0 807226.059 0 3 808091.025 807226.059 0.11% - 0s\n", - " 0 0 807240.578 0 5 808091.025 807240.578 0.11% - 0s\n", - " 0 0 807240.663 0 5 808091.025 807240.663 0.11% - 0s\n", - " 0 0 807259.825 0 4 808091.025 807259.825 0.10% - 0s\n", - " 0 0 807275.314 0 5 808091.025 807275.314 0.10% - 0s\n", - " 0 0 807279.037 0 6 808091.025 807279.037 0.10% - 0s\n", - " 0 0 807291.881 0 8 808091.025 807291.881 0.10% - 0s\n", - " 0 0 807325.323 0 6 808091.025 807325.323 0.09% - 0s\n", - " 0 0 807326.015 0 7 808091.025 807326.015 0.09% - 0s\n", - " 0 0 807326.798 0 7 808091.025 807326.798 0.09% - 0s\n", - " 0 0 807328.550 0 8 808091.025 807328.550 0.09% - 0s\n", - " 0 0 807331.193 0 9 808091.025 807331.193 0.09% - 0s\n", - " 0 0 807332.143 0 7 808091.025 807332.143 0.09% - 0s\n", - " 0 0 807335.410 0 8 808091.025 807335.410 0.09% - 0s\n", - " 0 0 807335.452 0 8 808091.025 807335.452 0.09% - 0s\n", - " 0 0 807337.253 0 9 808091.025 807337.253 0.09% - 0s\n", - " 0 0 807337.409 0 9 808091.025 807337.409 0.09% - 0s\n", - " 0 0 807347.720 0 8 808091.025 807347.720 0.09% - 0s\n", - " 0 0 807352.765 0 7 808091.025 807352.765 0.09% - 0s\n", - " 0 0 807366.618 0 9 808091.025 807366.618 0.09% - 0s\n", - " 0 0 807368.345 0 10 808091.025 807368.345 0.09% - 0s\n", - " 0 0 807369.195 0 10 808091.025 807369.195 0.09% - 0s\n", - " 0 0 807392.319 0 8 808091.025 807392.319 0.09% - 0s\n", - " 0 0 807401.436 0 9 808091.025 807401.436 0.09% - 0s\n", - " 0 0 807405.685 0 8 808091.025 807405.685 0.08% - 0s\n", - " 0 0 807411.994 0 8 808091.025 807411.994 0.08% - 0s\n", - " 0 0 807424.710 0 9 808091.025 807424.710 0.08% - 0s\n", - " 0 0 807424.867 0 11 808091.025 807424.867 0.08% - 0s\n", - " 0 0 807427.428 0 12 808091.025 807427.428 0.08% - 0s\n", - " 0 0 807433.211 0 10 808091.025 807433.211 0.08% - 0s\n", - " 0 0 807439.215 0 10 808091.025 807439.215 0.08% - 0s\n", - " 0 0 807439.303 0 11 808091.025 807439.303 0.08% - 0s\n", - " 0 0 807443.312 0 11 808091.025 807443.312 0.08% - 0s\n", - " 0 0 807444.488 0 12 808091.025 807444.488 0.08% - 0s\n", - " 0 0 807444.499 0 13 808091.025 807444.499 0.08% - 0s\n", - " 0 0 807444.499 0 13 808091.025 807444.499 0.08% - 0s\n", - " 0 2 807445.982 0 13 808091.025 807445.982 0.08% - 0s\n", - "\n", - "Cutting planes:\n", - " Cover: 3\n", - " MIR: 18\n", - " StrongCG: 1\n", - " Flow cover: 3\n", - "\n", - "Explored 39 nodes (333 simplex iterations) in 0.03 seconds\n", - "Thread count was 1 (of 32 available processors)\n", - "\n", - "Solution count 6: 808091 808536 809648 ... 822175\n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 8.080910248225e+05, best bound 8.080640878016e+05, gap 0.0033%\n", - "\n", - "User-callback calls 341, time in user-callback 0.00 sec\n", - "\n" - ] - } - ], - "source": [ - "solver_ml = LearningSolver(gurobi)\n", - "fit!(solver_ml, train_files, build_uc_model)\n", - "solve!(solver_ml, test_files[1], build_uc_model, tee=true);" - ] - }, - { - "cell_type": "markdown", - "id": "872211e7", - "metadata": {}, - "source": [ - "By examining the solve log above, specifically the line `Loaded user MIP start with objective...`, we can see that MIPLearn was able to construct an initial solution which turned out to be near optimal for the problem. Now let us repeat the code above, but using an untrained solver. Note that the `fit` line is omitted." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "fc1e3629", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0xfb382c05\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 1e+03]\n", - " Objective range [1e+00, 6e+04]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+04, 2e+04]\n", - "Presolve removed 100 rows and 50 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 1 rows, 50 columns, 50 nonzeros\n", - "\n", - "Iteration Objective Primal Inf. Dual Inf. Time\n", - " 0 7.0629410e+05 6.782322e+02 0.000000e+00 0s\n", - " 1 8.0678161e+05 0.000000e+00 0.000000e+00 0s\n", - "\n", - "Solved in 1 iterations and 0.00 seconds\n", - "Optimal objective 8.067816095e+05\n", - "\n", - "User-callback calls 33, time in user-callback 0.00 sec\n", - "\n", - "Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0x899aac3d\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 1e+03]\n", - " Objective range [1e+00, 6e+04]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+04, 2e+04]\n", - "Found heuristic solution: objective 893073.33620\n", - "Presolve time: 0.00s\n", - "Presolved: 101 rows, 100 columns, 250 nonzeros\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "\n", - "Root relaxation: objective 8.067816e+05, 55 iterations, 0.00 seconds\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 806781.610 0 1 893073.336 806781.610 9.66% - 0s\n", - "H 0 0 842766.25007 806781.610 4.27% - 0s\n", - "H 0 0 818273.05208 806781.610 1.40% - 0s\n", - " 0 0 807198.955 0 2 818273.052 807198.955 1.35% - 0s\n", - "H 0 0 813499.43980 807198.955 0.77% - 0s\n", - " 0 0 807246.085 0 3 813499.440 807246.085 0.77% - 0s\n", - " 0 0 807272.377 0 4 813499.440 807272.377 0.77% - 0s\n", - " 0 0 807284.557 0 1 813499.440 807284.557 0.76% - 0s\n", - " 0 0 807298.666 0 2 813499.440 807298.666 0.76% - 0s\n", - " 0 0 807305.559 0 6 813499.440 807305.559 0.76% - 0s\n", - "H 0 0 812223.58825 807305.559 0.61% - 0s\n", - " 0 0 807309.503 0 4 812223.588 807309.503 0.61% - 0s\n", - " 0 0 807339.469 0 4 812223.588 807339.469 0.60% - 0s\n", - " 0 0 807344.135 0 6 812223.588 807344.135 0.60% - 0s\n", - " 0 0 807359.565 0 7 812223.588 807359.565 0.60% - 0s\n", - " 0 0 807371.997 0 8 812223.588 807371.997 0.60% - 0s\n", - " 0 0 807372.245 0 8 812223.588 807372.245 0.60% - 0s\n", - " 0 0 807378.545 0 9 812223.588 807378.545 0.60% - 0s\n", - " 0 0 807378.545 0 9 812223.588 807378.545 0.60% - 0s\n", - "H 0 0 811628.30751 807378.545 0.52% - 0s\n", - "H 0 0 810280.45754 807378.545 0.36% - 0s\n", - " 0 0 807378.545 0 1 810280.458 807378.545 0.36% - 0s\n", - "H 0 0 810123.10116 807378.545 0.34% - 0s\n", - " 0 0 807378.545 0 1 810123.101 807378.545 0.34% - 0s\n", - " 0 0 807378.545 0 3 810123.101 807378.545 0.34% - 0s\n", - " 0 0 807378.545 0 7 810123.101 807378.545 0.34% - 0s\n", - " 0 0 807379.672 0 8 810123.101 807379.672 0.34% - 0s\n", - " 0 0 807379.905 0 9 810123.101 807379.905 0.34% - 0s\n", - " 0 0 807380.615 0 10 810123.101 807380.615 0.34% - 0s\n", - " 0 0 807402.384 0 10 810123.101 807402.384 0.34% - 0s\n", - " 0 0 807407.299 0 12 810123.101 807407.299 0.34% - 0s\n", - " 0 0 807407.299 0 12 810123.101 807407.299 0.34% - 0s\n", - " 0 2 807408.320 0 12 810123.101 807408.320 0.34% - 0s\n", - "H 3 3 809647.65837 807476.463 0.27% 3.0 0s\n", - "H 84 35 808870.26352 807568.065 0.16% 2.7 0s\n", - "H 99 29 808536.49552 807588.561 0.12% 2.7 0s\n", - "* 310 1 5 808091.02482 808069.217 0.00% 3.3 0s\n", - "\n", - "Cutting planes:\n", - " Gomory: 3\n", - " Cover: 7\n", - " MIR: 9\n", - " Flow cover: 3\n", - "\n", - "Explored 311 nodes (1175 simplex iterations) in 0.06 seconds\n", - "Thread count was 1 (of 32 available processors)\n", - "\n", - "Solution count 10: 808091 808536 808870 ... 818273\n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 8.080910248225e+05, best bound 8.080692169045e+05, gap 0.0027%\n", - "\n", - "User-callback calls 832, time in user-callback 0.00 sec\n", - "\n" - ] - } - ], - "source": [ - "solver_baseline = LearningSolver(gurobi)\n", - "solve!(solver_baseline, test_files[1], build_uc_model, tee=true);" - ] - }, - { - "cell_type": "markdown", - "id": "7b5ce528", - "metadata": {}, - "source": [ - "In the log above, the `MIP start` line is missing, and Gurobi had to start with a significantly inferior initial solution. The solver was still able to find the optimal solution at the end, but it required using its own internal heuristic procedures. In this example, because we solve very small optimization problems, there was almost no difference in terms of running time. For larger problems, however, the difference can be significant. See benchmarks for more details.\n", - "\n", - "
\n", - "Note\n", - " \n", - "In addition to partial initial solutions, MIPLearn is also able to predict lazy constraints, cutting planes and branching priorities. See the next tutorials for more details.\n", - "
\n", - "\n", - "
\n", - "Note\n", - " \n", - "It is not necessary to specify what ML models to use. MIPLearn, by default, will try a number of classical ML models and will choose the one that performs the best, based on k-fold cross validation. MIPLearn is also able to automatically collect features based on the MIP formulation of the problem and the solution to the LP relaxation, among other things, so it does not require handcrafted features. If you do want to customize the models and features, however, that is also possible, as we will see in a later tutorial.\n", - "
" - ] - }, - { - "cell_type": "markdown", - "id": "46da094b", - "metadata": {}, - "source": [ - "## Accessing the solution\n", - "\n", - "In the example above, we used `MIPLearn.solve` together with data files to solve both the training and the test instances. The optimal solutions were saved to HDF5 files in the train/test folders, and could be retrieved by reading theses files, but that is not very convenient. In the following example, we show how to build and solve a JuMP model entirely in-memory, using our trained solver." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "986f0c18", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "obj = 809710.340270503\n", - " x = [1.0, -0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0]\n", - " y = [696.38, 0.0, 249.05, 0.0, 1183.75, 0.0, 504.91, 387.32, 1178.0, 765.25]\n" - ] - } - ], - "source": [ - "# Construct model using previously defined functions\n", - "data = random_uc_data(samples=1, n=50)[1]\n", - "model = build_uc_model(data)\n", - "\n", - "# Solve model\n", - "solve!(solver_ml, model)\n", - "\n", - "# Print part of the optimal solution\n", - "println(\"obj = \", objective_value(model))\n", - "println(\" x = \", round.(value.(model[:x][1:10])))\n", - "println(\" y = \", round.(value.(model[:y][1:10]), digits=2))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f43ed281", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.2", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.2" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/jump-tutorials/lazy-constraints.ipynb b/docs/jump-tutorials/lazy-constraints.ipynb deleted file mode 100644 index c615ca8..0000000 --- a/docs/jump-tutorials/lazy-constraints.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "18dd2957", - "metadata": {}, - "source": [ - "# Modeling lazy constraints\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/jump-tutorials/user-cuts.ipynb b/docs/jump-tutorials/user-cuts.ipynb deleted file mode 100644 index 2427fc8..0000000 --- a/docs/jump-tutorials/user-cuts.ipynb +++ /dev/null @@ -1,29 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "8e6b5f28", - "metadata": {}, - "source": [ - "# Modeling user cuts\n", - "\n", - "TODO" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Julia 1.6.0", - "language": "julia", - "name": "julia-1.6" - }, - "language_info": { - "file_extension": ".jl", - "mimetype": "application/julia", - "name": "julia", - "version": "1.6.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/pyomo-tutorials/getting-started.ipynb b/docs/pyomo-tutorials/getting-started.ipynb deleted file mode 100644 index 46d7c2c..0000000 --- a/docs/pyomo-tutorials/getting-started.ipynb +++ /dev/null @@ -1,625 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "6b8983b1", - "metadata": { - "tags": [] - }, - "source": [ - "# Getting started\n", - "\n", - "## Introduction\n", - "\n", - "**MIPLearn** is an open source framework that uses machine learning (ML) to accelerate the performance of both commercial and open source mixed-integer programming solvers (e.g. Gurobi, CPLEX, XPRESS, Cbc or SCIP). In this tutorial, we will:\n", - "\n", - "1. Install the Python/Pyomo version of MIPLearn\n", - "2. Model a simple optimization problem using JuMP\n", - "3. Generate training data and train the ML models\n", - "4. Use the ML models together Gurobi to solve new instances\n", - "\n", - "
\n", - "Note\n", - " \n", - "The Python/Pyomo version of MIPLearn is currently only compatible with with Gurobi, CPLEX and XPRESS. For broader solver compatibility, see the Julia/JuMP version of the package.\n", - "
\n", - "\n", - "
\n", - "Warning\n", - " \n", - "MIPLearn is still in early development stage. If run into any bugs or issues, please submit a bug report in our GitHub repository. Comments, suggestions and pull requests are also very welcome!\n", - " \n", - "
\n" - ] - }, - { - "cell_type": "markdown", - "id": "02f0a927", - "metadata": {}, - "source": [ - "## Installation\n", - "\n", - "MIPLearn is available in two versions:\n", - "\n", - "- Python version, compatible with the Pyomo modeling language,\n", - "- Julia version, compatible with the JuMP modeling language.\n", - "\n", - "In this tutorial, we will demonstrate how to use and install the Python/Pyomo version of the package. The first step is to install Python 3.8+ in your computer. See the [official Python website for more instructions](https://www.python.org/downloads/). After Python is installed, we proceed to install MIPLearn using `pip`:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "cd8a69c1", - "metadata": {}, - "outputs": [], - "source": [ - "# !pip install MIPLearn==0.2.0.dev13" - ] - }, - { - "cell_type": "markdown", - "id": "e8274543", - "metadata": {}, - "source": [ - "In addition to MIPLearn itself, we will also install Gurobi 9.5, a state-of-the-art commercial MILP solver. This step also install a demo license for Gurobi, which should able to solve the small optimization problems in this tutorial. A paid license is required for solving large-scale problems." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "dcc8756c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Looking in indexes: https://pypi.gurobi.com\n", - "Requirement already satisfied: gurobipy<9.6,>=9.5 in /opt/anaconda3/envs/miplearn/lib/python3.8/site-packages (9.5.1)\n" - ] - } - ], - "source": [ - "!pip install --upgrade -i https://pypi.gurobi.com 'gurobipy>=9.5,<9.6'" - ] - }, - { - "cell_type": "markdown", - "id": "a14e4550", - "metadata": {}, - "source": [ - "
\n", - " \n", - "Note\n", - " \n", - "In the code above, we install specific version of all packages to ensure that this tutorial keeps running in the future, even when newer (and possibly incompatible) versions of the packages are released. This is usually a recommended practice for all Python projects.\n", - " \n", - "
" - ] - }, - { - "cell_type": "markdown", - "id": "16b86823", - "metadata": {}, - "source": [ - "## Modeling a simple optimization problem\n", - "\n", - "To illustrate how can MIPLearn be used, we will model and solve a small optimization problem related to power systems optimization. The problem we discuss below is a simplification of the **unit commitment problem,** a practical optimization problem solved daily by electric grid operators around the world. \n", - "\n", - "Suppose that you work at a utility company, and that it is your job to decide which electrical generators should be online at a certain hour of the day, as well as how much power should each generator produce. More specifically, assume that your company owns $n$ generators, denoted by $g_1, \\ldots, g_n$. Each generator can either be online or offline. An online generator $g_i$ can produce between $p^\\text{min}_i$ to $p^\\text{max}_i$ megawatts of power, and it costs your company $c^\\text{fix}_i + c^\\text{var}_i y_i$, where $y_i$ is the amount of power produced. An offline generator produces nothing and costs nothing. You also know that the total amount of power to be produced needs to be exactly equal to the total demand $d$ (in megawatts). To minimize the costs to your company, which generators should be online, and how much power should they produce?\n", - "\n", - "This simple problem can be modeled as a *mixed-integer linear optimization* problem as follows. For each generator $g_i$, let $x_i \\in \\{0,1\\}$ be a decision variable indicating whether $g_i$ is online, and let $y_i \\geq 0$ be a decision variable indicating how much power does $g_i$ produce. The problem is then given by:\n", - "\n", - "$$\n", - "\\begin{align}\n", - "\\text{minimize } \\quad & \\sum_{i=1}^n \\left( c^\\text{fix}_i x_i + c^\\text{var}_i y_i \\right) \\\\\n", - "\\text{subject to } \\quad & y_i \\leq p^\\text{max}_i x_i & i=1,\\ldots,n \\\\\n", - "& y_i \\geq p^\\text{min}_i x_i & i=1,\\ldots,n \\\\\n", - "& \\sum_{i=1}^n y_i = d \\\\\n", - "& x_i \\in \\{0,1\\} & i=1,\\ldots,n \\\\\n", - "& y_i \\geq 0 & i=1,\\ldots,n\n", - "\\end{align}\n", - "$$\n", - "\n", - "
\n", - " \n", - "Note\n", - " \n", - "We use a simplified version of the unit commitment problem in this tutorial just to make it easier to follow. MIPLearn can also handle realistic, large-scale versions of this problem. See benchmarks for more details.\n", - " \n", - "
\n", - "\n", - "Next, let us convert this abstract mathematical formulation into a concrete optimization model, using Python and Pyomo. We start by defining a data class `UnitCommitmentData`, which holds all the input data." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "22a67170-10b4-43d3-8708-014d91141e73", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from dataclasses import dataclass\n", - "import numpy as np\n", - "\n", - "@dataclass\n", - "class UnitCommitmentData:\n", - " demand: float\n", - " pmin: np.ndarray\n", - " pmax: np.ndarray\n", - " cfix: np.ndarray\n", - " cvar: np.ndarray" - ] - }, - { - "cell_type": "markdown", - "id": "29f55efa-0751-465a-9b0a-a821d46a3d40", - "metadata": {}, - "source": [ - "Next, we write a `build_uc_model` function, which converts the input data into a concrete Pyomo model." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "2f67032f-0d74-4317-b45c-19da0ec859e9", - "metadata": {}, - "outputs": [], - "source": [ - "import pyomo.environ as pe\n", - "\n", - "def build_uc_model(data: UnitCommitmentData) -> pe.ConcreteModel:\n", - " model = pe.ConcreteModel()\n", - " n = len(data.pmin)\n", - " model.x = pe.Var(range(n), domain=pe.Binary)\n", - " model.y = pe.Var(range(n), domain=pe.NonNegativeReals)\n", - " model.obj = pe.Objective(\n", - " expr=sum(\n", - " data.cfix[i] * model.x[i] +\n", - " data.cvar[i] * model.y[i]\n", - " for i in range(n)\n", - " )\n", - " )\n", - " model.eq_max_power = pe.ConstraintList()\n", - " model.eq_min_power = pe.ConstraintList()\n", - " for i in range(n):\n", - " model.eq_max_power.add(model.y[i] <= data.pmax[i] * model.x[i])\n", - " model.eq_min_power.add(model.y[i] >= data.pmin[i] * model.x[i])\n", - " model.eq_demand = pe.Constraint(\n", - " expr=sum(model.y[i] for i in range(n)) == data.demand,\n", - " )\n", - " return model" - ] - }, - { - "cell_type": "markdown", - "id": "c22714a3", - "metadata": {}, - "source": [ - "At this point, we can already use Pyomo and any mixed-integer linear programming solver to find optimal solutions to any instance of this problem. To illustrate this, let us solve a small instance with three generators:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "2a896f47", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Threads to value 1\n", - "Set parameter Seed to value 42\n", - "Restricted license - for non-production use only - expires 2023-10-25\n", - "obj = 1320.0\n", - "x = [-0.0, 1.0, 1.0]\n", - "y = [0.0, 60.0, 40.0]\n" - ] - } - ], - "source": [ - "model = build_uc_model(\n", - " UnitCommitmentData(\n", - " demand = 100.0,\n", - " pmin = [10, 20, 30],\n", - " pmax = [50, 60, 70],\n", - " cfix = [700, 600, 500],\n", - " cvar = [1.5, 2.0, 2.5],\n", - " )\n", - ")\n", - "\n", - "solver = pe.SolverFactory(\"gurobi_persistent\")\n", - "solver.set_instance(model)\n", - "solver.solve()\n", - "print(\"obj =\", model.obj())\n", - "print(\"x =\", [model.x[i].value for i in range(3)])\n", - "print(\"y =\", [model.y[i].value for i in range(3)])" - ] - }, - { - "cell_type": "markdown", - "id": "41b03bbc", - "metadata": {}, - "source": [ - "Running the code above, we found that the optimal solution for our small problem instance costs \\$1320. It is achieve by keeping generators 2 and 3 online and producing, respectively, 60 MW and 40 MW of power." - ] - }, - { - "cell_type": "markdown", - "id": "cf60c1dd", - "metadata": {}, - "source": [ - "## Generating training data\n", - "\n", - "Although Gurobi could solve the small example above in a fraction of a second, it gets slower for larger and more complex versions of the problem. If this is a problem that needs to be solved frequently, as it is often the case in practice, it could make sense to spend some time upfront generating a **trained** version of Gurobi, which can solve new instances (similar to the ones it was trained on) faster.\n", - "\n", - "In the following, we will use MIPLearn to train machine learning models that is able to predict the optimal solution for instances that follow a given probability distribution, then it will provide this predicted solution to Gurobi as a warm start. Before we can train the model, we need to collect training data by solving a large number of instances. In real-world situations, we may construct these training instances based on historical data. In this tutorial, we will construct them using a random instance generator:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "5eb09fab", - "metadata": {}, - "outputs": [], - "source": [ - "from scipy.stats import uniform\n", - "from typing import List\n", - "import random\n", - "\n", - "def random_uc_data(samples: int, n: int, seed: int = 42) -> List[UnitCommitmentData]:\n", - " random.seed(seed)\n", - " np.random.seed(seed)\n", - " pmin = uniform(loc=100_000.0, scale=400_000.0).rvs(n)\n", - " pmax = pmin * uniform(loc=2.0, scale=2.5).rvs(n)\n", - " cfix = pmin * uniform(loc=100.0, scale=25.0).rvs(n)\n", - " cvar = uniform(loc=1.25, scale=0.25).rvs(n)\n", - " return [\n", - " UnitCommitmentData(\n", - " demand = pmax.sum() * uniform(loc=0.5, scale=0.25).rvs(),\n", - " pmin = pmin,\n", - " pmax = pmax,\n", - " cfix = cfix,\n", - " cvar = cvar,\n", - " )\n", - " for i in range(samples)\n", - " ]" - ] - }, - { - "cell_type": "markdown", - "id": "3a03a7ac", - "metadata": {}, - "source": [ - "In this example, for simplicity, only the demands change from one instance to the next. We could also have randomized the costs, production limits or even the number of units. The more randomization we have in the training data, however, the more challenging it is for the machine learning models to learn solution patterns.\n", - "\n", - "Now we generate 500 instances of this problem, each one with 50 generators, and we use 450 of these instances for training. After generating the instances, we write them to individual files. MIPLearn uses files during the training process because, for large-scale optimization problems, it is often impractical to hold in memory the entire training data, as well as the concrete Pyomo models. Files also make it much easier to solve multiple instances simultaneously, potentially even on multiple machines. We will cover parallel and distributed computing in a future tutorial. The code below generates the files `uc/train/00000.pkl.gz`, `uc/train/00001.pkl.gz`, etc., which contain the input data in compressed (gzipped) pickle format." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "6156752c", - "metadata": {}, - "outputs": [], - "source": [ - "from miplearn import save\n", - "data = random_uc_data(samples=500, n=50)\n", - "train_files = save(data[0:450], \"uc/train/\")\n", - "test_files = save(data[450:500], \"uc/test/\")" - ] - }, - { - "cell_type": "markdown", - "id": "b17af877", - "metadata": {}, - "source": [ - "Finally, we use `LearningSolver` to solve all the training instances. `LearningSolver` is the main component provided by MIPLearn, which integrates MIP solvers and ML. The optimal solutions, along with other useful training data, are stored in HDF5 files `uc/train/00000.h5`, `uc/train/00001.h5`, etc." - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "7623f002", - "metadata": {}, - "outputs": [], - "source": [ - "from miplearn import LearningSolver\n", - "solver = LearningSolver()\n", - "solver.solve(train_files, build_uc_model);" - ] - }, - { - "cell_type": "markdown", - "id": "2f24ee83", - "metadata": {}, - "source": [ - "## Solving test instances\n", - "\n", - "With training data in hand, we can now fit the ML models, using the `LearningSolver.fit` method, then solve the test instances with `LearningSolver.solve`, as shown below. The `tee=True` parameter asks MIPLearn to print the solver log to the screen." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "c8385030", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter LogFile to value \"/tmp/tmpvbaqbyty.log\"\n", - "Set parameter QCPDual to value 1\n", - "Gurobi Optimizer version 9.5.1 build v9.5.1rc2 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0x8de73876\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+06]\n", - " Objective range [1e+00, 6e+07]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+07, 2e+07]\n", - "Presolve removed 100 rows and 50 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 1 rows, 50 columns, 50 nonzeros\n", - "\n", - "Iteration Objective Primal Inf. Dual Inf. Time\n", - " 0 5.7349081e+08 1.044003e+04 0.000000e+00 0s\n", - " 1 6.8268465e+08 0.000000e+00 0.000000e+00 0s\n", - "\n", - "Solved in 1 iterations and 0.00 seconds (0.00 work units)\n", - "Optimal objective 6.826846503e+08\n", - "Set parameter LogFile to value \"\"\n", - "Set parameter LogFile to value \"/tmp/tmp48j6n35b.log\"\n", - "Gurobi Optimizer version 9.5.1 build v9.5.1rc2 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0x200d64ba\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+06]\n", - " Objective range [1e+00, 6e+07]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+07, 2e+07]\n", - "\n", - "User MIP start produced solution with objective 6.84841e+08 (0.00s)\n", - "Loaded user MIP start with objective 6.84841e+08\n", - "\n", - "Presolve time: 0.00s\n", - "Presolved: 101 rows, 100 columns, 250 nonzeros\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "\n", - "Root relaxation: objective 6.826847e+08, 56 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 6.8268e+08 0 1 6.8484e+08 6.8268e+08 0.31% - 0s\n", - " 0 0 6.8315e+08 0 3 6.8484e+08 6.8315e+08 0.25% - 0s\n", - " 0 0 6.8315e+08 0 1 6.8484e+08 6.8315e+08 0.25% - 0s\n", - " 0 0 6.8315e+08 0 3 6.8484e+08 6.8315e+08 0.25% - 0s\n", - " 0 0 6.8315e+08 0 4 6.8484e+08 6.8315e+08 0.25% - 0s\n", - " 0 0 6.8315e+08 0 4 6.8484e+08 6.8315e+08 0.25% - 0s\n", - " 0 2 6.8327e+08 0 4 6.8484e+08 6.8327e+08 0.23% - 0s\n", - "\n", - "Cutting planes:\n", - " Flow cover: 3\n", - "\n", - "Explored 32 nodes (155 simplex iterations) in 0.02 seconds (0.00 work units)\n", - "Thread count was 1 (of 32 available processors)\n", - "\n", - "Solution count 1: 6.84841e+08 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 6.848411655488e+08, best bound 6.848411655488e+08, gap 0.0000%\n", - "Set parameter LogFile to value \"\"\n", - "WARNING: Cannot get reduced costs for MIP.\n", - "WARNING: Cannot get duals for MIP.\n" - ] - } - ], - "source": [ - "solver_ml = LearningSolver()\n", - "solver_ml.fit(train_files, build_uc_model)\n", - "solver_ml.solve(test_files[0:1], build_uc_model, tee=True);" - ] - }, - { - "cell_type": "markdown", - "id": "61da6dad-7f56-4edb-aa26-c00eb5f946c0", - "metadata": {}, - "source": [ - "By examining the solve log above, specifically the line `Loaded user MIP start with objective...`, we can see that MIPLearn was able to construct an initial solution which turned out to be the optimal solution to the problem. Now let us repeat the code above, but using an untrained solver. Note that the `fit` line is omitted." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "33d15d6c-6db4-477f-bd4b-fe8e84e5f023", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter LogFile to value \"/tmp/tmp3uhhdurw.log\"\n", - "Set parameter QCPDual to value 1\n", - "Gurobi Optimizer version 9.5.1 build v9.5.1rc2 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0x8de73876\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+06]\n", - " Objective range [1e+00, 6e+07]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+07, 2e+07]\n", - "Presolve removed 100 rows and 50 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 1 rows, 50 columns, 50 nonzeros\n", - "\n", - "Iteration Objective Primal Inf. Dual Inf. Time\n", - " 0 5.7349081e+08 1.044003e+04 0.000000e+00 0s\n", - " 1 6.8268465e+08 0.000000e+00 0.000000e+00 0s\n", - "\n", - "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", - "Optimal objective 6.826846503e+08\n", - "Set parameter LogFile to value \"\"\n", - "Set parameter LogFile to value \"/tmp/tmp18aqg2ic.log\"\n", - "Gurobi Optimizer version 9.5.1 build v9.5.1rc2 (linux64)\n", - "Thread count: 16 physical cores, 32 logical processors, using up to 1 threads\n", - "Optimize a model with 101 rows, 100 columns and 250 nonzeros\n", - "Model fingerprint: 0xb90d1075\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+06]\n", - " Objective range [1e+00, 6e+07]\n", - " Bounds range [1e+00, 1e+00]\n", - " RHS range [2e+07, 2e+07]\n", - "Found heuristic solution: objective 8.056576e+08\n", - "Presolve time: 0.00s\n", - "Presolved: 101 rows, 100 columns, 250 nonzeros\n", - "Variable types: 50 continuous, 50 integer (50 binary)\n", - "\n", - "Root relaxation: objective 6.826847e+08, 56 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 6.8268e+08 0 1 8.0566e+08 6.8268e+08 15.3% - 0s\n", - "H 0 0 7.099498e+08 6.8268e+08 3.84% - 0s\n", - " 0 0 6.8315e+08 0 3 7.0995e+08 6.8315e+08 3.78% - 0s\n", - "H 0 0 6.883227e+08 6.8315e+08 0.75% - 0s\n", - " 0 0 6.8352e+08 0 4 6.8832e+08 6.8352e+08 0.70% - 0s\n", - " 0 0 6.8352e+08 0 4 6.8832e+08 6.8352e+08 0.70% - 0s\n", - " 0 0 6.8352e+08 0 1 6.8832e+08 6.8352e+08 0.70% - 0s\n", - "H 0 0 6.862582e+08 6.8352e+08 0.40% - 0s\n", - " 0 0 6.8352e+08 0 4 6.8626e+08 6.8352e+08 0.40% - 0s\n", - " 0 0 6.8352e+08 0 4 6.8626e+08 6.8352e+08 0.40% - 0s\n", - " 0 0 6.8352e+08 0 1 6.8626e+08 6.8352e+08 0.40% - 0s\n", - " 0 0 6.8352e+08 0 3 6.8626e+08 6.8352e+08 0.40% - 0s\n", - " 0 0 6.8352e+08 0 4 6.8626e+08 6.8352e+08 0.40% - 0s\n", - " 0 0 6.8352e+08 0 4 6.8626e+08 6.8352e+08 0.40% - 0s\n", - " 0 2 6.8354e+08 0 4 6.8626e+08 6.8354e+08 0.40% - 0s\n", - "* 18 5 6 6.849018e+08 6.8413e+08 0.11% 3.1 0s\n", - "H 24 1 6.848412e+08 6.8426e+08 0.09% 3.2 0s\n", - "\n", - "Cutting planes:\n", - " Gomory: 1\n", - " Flow cover: 2\n", - "\n", - "Explored 30 nodes (217 simplex iterations) in 0.02 seconds (0.00 work units)\n", - "Thread count was 1 (of 32 available processors)\n", - "\n", - "Solution count 6: 6.84841e+08 6.84902e+08 6.86258e+08 ... 8.05658e+08\n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 6.848411655488e+08, best bound 6.848411655488e+08, gap 0.0000%\n", - "Set parameter LogFile to value \"\"\n", - "WARNING: Cannot get reduced costs for MIP.\n", - "WARNING: Cannot get duals for MIP.\n" - ] - } - ], - "source": [ - "solver_baseline = LearningSolver()\n", - "solver_baseline.solve(test_files[0:1], build_uc_model, tee=True);" - ] - }, - { - "cell_type": "markdown", - "id": "b6d37b88-9fcc-43ee-ac1e-2a7b1e51a266", - "metadata": {}, - "source": [ - "In the log above, the `MIP start` line is missing, and Gurobi had to start with a significantly inferior initial solution. The solver was still able to find the optimal solution at the end, but it required using its own internal heuristic procedures. In this example, because we solve very small optimization problems, there was almost no difference in terms of running time. For larger problems, however, the difference can be significant. See benchmarks for more details.\n", - "\n", - "
\n", - "Note\n", - " \n", - "In addition to partial initial solutions, MIPLearn is also able to predict lazy constraints, cutting planes and branching priorities. See the next tutorials for more details.\n", - "
\n", - "\n", - "
\n", - "Note\n", - " \n", - "It is not necessary to specify what ML models to use. MIPLearn, by default, will try a number of classical ML models and will choose the one that performs the best, based on k-fold cross validation. MIPLearn is also able to automatically collect features based on the MIP formulation of the problem and the solution to the LP relaxation, among other things, so it does not require handcrafted features. If you do want to customize the models and features, however, that is also possible, as we will see in a later tutorial.\n", - "
" - ] - }, - { - "cell_type": "markdown", - "id": "eec97f06", - "metadata": { - "tags": [] - }, - "source": [ - "## Accessing the solution\n", - "\n", - "In the example above, we used `LearningSolver.solve` together with data files to solve both the training and the test instances. The optimal solutions were saved to HDF5 files in the train/test folders, and could be retrieved by reading theses files, but that is not very convenient. In the following example, we show how to build and solve a Pyomo model entirely in-memory, using our trained solver." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "67a6cd18", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "obj = 903865807.3536932\n", - " x = [1.0, 1.0, 1.0, 1.0, 1.0]\n", - " y = [1105176.593734543, 1891284.5155055337, 1708177.4224033852, 1438329.610189608, 535496.3347187206]\n" - ] - } - ], - "source": [ - "# Construct model using previously defined functions\n", - "data = random_uc_data(samples=1, n=50)[0]\n", - "model = build_uc_model(data)\n", - "\n", - "# Solve model using ML + Gurobi\n", - "solver_ml.solve(model)\n", - "\n", - "# Print part of the optimal solution\n", - "print(\"obj =\", model.obj())\n", - "print(\" x =\", [model.x[i].value for i in range(5)])\n", - "print(\" y =\", [model.y[i].value for i in range(5)])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5593d23a-83bd-4e16-8253-6300f5e3f63b", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "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.8.10" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/pyomo-tutorials/gurobi.env b/docs/pyomo-tutorials/gurobi.env deleted file mode 100644 index 705c8fe..0000000 --- a/docs/pyomo-tutorials/gurobi.env +++ /dev/null @@ -1,3 +0,0 @@ -OutputFlag 1 -Threads 1 -Seed 42 \ No newline at end of file diff --git a/docs/tutorials/Manifest.toml b/docs/tutorials/Manifest.toml new file mode 100644 index 0000000..4f22f1c --- /dev/null +++ b/docs/tutorials/Manifest.toml @@ -0,0 +1,637 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.9.0" +manifest_format = "2.0" +project_hash = "acf9261f767ae18f2b4613fd5590ea6a33f31e10" + +[[deps.ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.1" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" + +[[deps.BenchmarkTools]] +deps = ["JSON", "Logging", "Printf", "Profile", "Statistics", "UUIDs"] +git-tree-sha1 = "d9a9701b899b30332bbcb3e1679c41cce81fb0e8" +uuid = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" +version = "1.3.2" + +[[deps.Bzip2_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "19a35467a82e236ff51bc17a3a44b69ef35185a2" +uuid = "6e34b625-4abd-537c-b88f-471c36dfa7a0" +version = "1.0.8+0" + +[[deps.Calculus]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "f641eb0a4f00c343bbc32346e1217b86f3ce9dad" +uuid = "49dc2e85-a5d0-5ad3-a950-438e2897f1b9" +version = "0.5.1" + +[[deps.CodecBzip2]] +deps = ["Bzip2_jll", "Libdl", "TranscodingStreams"] +git-tree-sha1 = "2e62a725210ce3c3c2e1a3080190e7ca491f18d7" +uuid = "523fee87-0ab8-5b00-afb7-3ecf72e48cfd" +version = "0.7.2" + +[[deps.CodecZlib]] +deps = ["TranscodingStreams", "Zlib_jll"] +git-tree-sha1 = "9c209fb7536406834aa938fb149964b985de6c83" +uuid = "944b1d66-785c-5afd-91f1-9de20f533193" +version = "0.7.1" + +[[deps.CommonSubexpressions]] +deps = ["MacroTools", "Test"] +git-tree-sha1 = "7b8a93dba8af7e3b42fecabf646260105ac373f7" +uuid = "bbf7d656-a473-5ed7-a52c-81e309532950" +version = "0.3.0" + +[[deps.Compat]] +deps = ["UUIDs"] +git-tree-sha1 = "7a60c856b9fa189eb34f5f8a6f6b5529b7942957" +uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" +version = "4.6.1" +weakdeps = ["Dates", "LinearAlgebra"] + + [deps.Compat.extensions] + CompatLinearAlgebraExt = "LinearAlgebra" + +[[deps.CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.0.2+0" + +[[deps.Conda]] +deps = ["Downloads", "JSON", "VersionParsing"] +git-tree-sha1 = "e32a90da027ca45d84678b826fffd3110bb3fc90" +uuid = "8f4d0f93-b110-5947-807f-2305c1781a2d" +version = "1.8.0" + +[[deps.DataAPI]] +git-tree-sha1 = "8da84edb865b0b5b0100c0666a9bc9a0b71c553c" +uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" +version = "1.15.0" + +[[deps.DataStructures]] +deps = ["Compat", "InteractiveUtils", "OrderedCollections"] +git-tree-sha1 = "d1fff3a548102f48987a52a2e0d114fa97d730f0" +uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +version = "0.18.13" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" + +[[deps.DiffResults]] +deps = ["StaticArraysCore"] +git-tree-sha1 = "782dd5f4561f5d267313f23853baaaa4c52ea621" +uuid = "163ba53b-c6d8-5494-b064-1a9d43ac40c5" +version = "1.1.0" + +[[deps.DiffRules]] +deps = ["IrrationalConstants", "LogExpFunctions", "NaNMath", "Random", "SpecialFunctions"] +git-tree-sha1 = "23163d55f885173722d1e4cf0f6110cdbaf7e272" +uuid = "b552c78f-8df3-52c6-915a-8e097449b14b" +version = "1.15.1" + +[[deps.Distributions]] +deps = ["FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "SparseArrays", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns", "Test"] +git-tree-sha1 = "c72970914c8a21b36bbc244e9df0ed1834a0360b" +uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" +version = "0.25.95" + + [deps.Distributions.extensions] + DistributionsChainRulesCoreExt = "ChainRulesCore" + DistributionsDensityInterfaceExt = "DensityInterface" + + [deps.Distributions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + DensityInterface = "b429d917-457f-4dbc-8f4c-0cc954292b1d" + +[[deps.DocStringExtensions]] +deps = ["LibGit2"] +git-tree-sha1 = "2fb1e02f2b635d0845df5d7c167fec4dd739b00d" +uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +version = "0.9.3" + +[[deps.Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.6.0" + +[[deps.DualNumbers]] +deps = ["Calculus", "NaNMath", "SpecialFunctions"] +git-tree-sha1 = "5837a837389fccf076445fce071c8ddaea35a566" +uuid = "fa6b7ba4-c1ee-5f82-b5fc-ecf0adba8f74" +version = "0.6.8" + +[[deps.ExprTools]] +git-tree-sha1 = "c1d06d129da9f55715c6c212866f5b1bddc5fa00" +uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" +version = "0.1.9" + +[[deps.FileIO]] +deps = ["Pkg", "Requires", "UUIDs"] +git-tree-sha1 = "299dc33549f68299137e51e6d49a13b5b1da9673" +uuid = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" +version = "1.16.1" + +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" + +[[deps.FillArrays]] +deps = ["LinearAlgebra", "Random", "SparseArrays", "Statistics"] +git-tree-sha1 = "589d3d3bff204bdd80ecc53293896b4f39175723" +uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" +version = "1.1.1" + +[[deps.ForwardDiff]] +deps = ["CommonSubexpressions", "DiffResults", "DiffRules", "LinearAlgebra", "LogExpFunctions", "NaNMath", "Preferences", "Printf", "Random", "SpecialFunctions"] +git-tree-sha1 = "00e252f4d706b3d55a8863432e742bf5717b498d" +uuid = "f6369f11-7733-5829-9624-2563aa707210" +version = "0.10.35" + + [deps.ForwardDiff.extensions] + ForwardDiffStaticArraysExt = "StaticArrays" + + [deps.ForwardDiff.weakdeps] + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + +[[deps.Gurobi]] +deps = ["LazyArtifacts", "Libdl", "MathOptInterface"] +git-tree-sha1 = "22439b1c2bacb7d50ed0df7dbd10211e0b4cd379" +uuid = "2e9cd046-0924-5485-92f1-d5272153d98b" +version = "1.0.1" + +[[deps.HDF5]] +deps = ["Compat", "HDF5_jll", "Libdl", "Mmap", "Random", "Requires", "UUIDs"] +git-tree-sha1 = "c73fdc3d9da7700691848b78c61841274076932a" +uuid = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" +version = "0.16.15" + +[[deps.HDF5_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LLVMOpenMP_jll", "LazyArtifacts", "LibCURL_jll", "Libdl", "MPICH_jll", "MPIPreferences", "MPItrampoline_jll", "MicrosoftMPI_jll", "OpenMPI_jll", "OpenSSL_jll", "TOML", "Zlib_jll", "libaec_jll"] +git-tree-sha1 = "3b20c3ce9c14aedd0adca2bc8c882927844bd53d" +uuid = "0234f1f7-429e-5d53-9886-15a909be8d59" +version = "1.14.0+0" + +[[deps.HiGHS]] +deps = ["HiGHS_jll", "MathOptInterface", "PrecompileTools", "SparseArrays"] +git-tree-sha1 = "bbd4ab443dfac4c9d5c5b40dd45f598dfad2e26a" +uuid = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" +version = "1.5.2" + +[[deps.HiGHS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] +git-tree-sha1 = "216e7198aeb256e7c7921ef2937d7e1e589ba6fd" +uuid = "8fd58aa0-07eb-5a78-9b36-339c94fd15ea" +version = "1.5.3+0" + +[[deps.HypergeometricFunctions]] +deps = ["DualNumbers", "LinearAlgebra", "OpenLibm_jll", "SpecialFunctions"] +git-tree-sha1 = "84204eae2dd237500835990bcade263e27674a93" +uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" +version = "0.3.16" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" + +[[deps.IrrationalConstants]] +git-tree-sha1 = "630b497eafcc20001bba38a4651b327dcfc491d2" +uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" +version = "0.2.2" + +[[deps.JLD2]] +deps = ["FileIO", "MacroTools", "Mmap", "OrderedCollections", "Pkg", "Printf", "Reexport", "Requires", "TranscodingStreams", "UUIDs"] +git-tree-sha1 = "42c17b18ced77ff0be65957a591d34f4ed57c631" +uuid = "033835bb-8acc-5ee8-8aae-3f567f8a3819" +version = "0.4.31" + +[[deps.JLLWrappers]] +deps = ["Preferences"] +git-tree-sha1 = "abc9885a7ca2052a736a600f7fa66209f96506e1" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.4.1" + +[[deps.JSON]] +deps = ["Dates", "Mmap", "Parsers", "Unicode"] +git-tree-sha1 = "31e996f0a15c7b280ba9f76636b3ff9e2ae58c9a" +uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +version = "0.21.4" + +[[deps.JuMP]] +deps = ["LinearAlgebra", "MathOptInterface", "MutableArithmetics", "OrderedCollections", "Printf", "SnoopPrecompile", "SparseArrays"] +git-tree-sha1 = "3e4a73edf2ca1bfe97f1fc86eb4364f95ef0fccd" +uuid = "4076af6c-e467-56ae-b986-b466b2749572" +version = "1.11.1" + +[[deps.KLU]] +deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse_jll"] +git-tree-sha1 = "764164ed65c30738750965d55652db9c94c59bfe" +uuid = "ef3ab10e-7fda-4108-b977-705223b18434" +version = "0.4.0" + +[[deps.LLVMOpenMP_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "f689897ccbe049adb19a065c495e75f372ecd42b" +uuid = "1d63c593-3942-5779-bab2-d838dc0a180e" +version = "15.0.4+0" + +[[deps.LazyArtifacts]] +deps = ["Artifacts", "Pkg"] +uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3" + +[[deps.LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.3" + +[[deps.LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "7.84.0+0" + +[[deps.LibGit2]] +deps = ["Base64", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "MbedTLS_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.10.2+0" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" + +[[deps.LinearAlgebra]] +deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] +uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" + +[[deps.LogExpFunctions]] +deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] +git-tree-sha1 = "c3ce8e7420b3a6e071e0fe4745f5d4300e37b13f" +uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" +version = "0.3.24" + + [deps.LogExpFunctions.extensions] + LogExpFunctionsChainRulesCoreExt = "ChainRulesCore" + LogExpFunctionsChangesOfVariablesExt = "ChangesOfVariables" + LogExpFunctionsInverseFunctionsExt = "InverseFunctions" + + [deps.LogExpFunctions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + ChangesOfVariables = "9e997f8a-9a97-42d5-a9f1-ce6bfc15e2c0" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" + +[[deps.MIPLearn]] +deps = ["Conda", "DataStructures", "HDF5", "HiGHS", "JLD2", "JuMP", "KLU", "LinearAlgebra", "MathOptInterface", "OrderedCollections", "Printf", "PyCall", "Random", "Requires", "SparseArrays", "Statistics", "TimerOutputs"] +path = "/home/axavier/Packages/MIPLearn.jl/dev/" +uuid = "2b1277c3-b477-4c49-a15e-7ba350325c68" +version = "0.3.0" + +[[deps.MPICH_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML"] +git-tree-sha1 = "d790fbd913f85e8865c55bf4725aff197c5155c8" +uuid = "7cb0a576-ebde-5e09-9194-50597f1243b4" +version = "4.1.1+1" + +[[deps.MPIPreferences]] +deps = ["Libdl", "Preferences"] +git-tree-sha1 = "d86a788b336e8ae96429c0c42740ccd60ac0dfcc" +uuid = "3da0fdf6-3ccc-4f1b-acd9-58baa6c99267" +version = "0.1.8" + +[[deps.MPItrampoline_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML"] +git-tree-sha1 = "b3dcf8e1c610a10458df3c62038c8cc3a4d6291d" +uuid = "f1f71cc9-e9ae-5b93-9b94-4fe0e1ad3748" +version = "5.3.0+0" + +[[deps.MacroTools]] +deps = ["Markdown", "Random"] +git-tree-sha1 = "42324d08725e200c23d4dfb549e0d5d89dede2d2" +uuid = "1914dd2f-81c6-5fcd-8719-6d5c9610ff09" +version = "0.5.10" + +[[deps.Markdown]] +deps = ["Base64"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" + +[[deps.MathOptInterface]] +deps = ["BenchmarkTools", "CodecBzip2", "CodecZlib", "DataStructures", "ForwardDiff", "JSON", "LinearAlgebra", "MutableArithmetics", "NaNMath", "OrderedCollections", "PrecompileTools", "Printf", "SparseArrays", "SpecialFunctions", "Test", "Unicode"] +git-tree-sha1 = "19a3636968e802918f8891d729c74bd64dff6d00" +uuid = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" +version = "1.17.1" + +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.2+0" + +[[deps.MicrosoftMPI_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "a8027af3d1743b3bfae34e54872359fdebb31422" +uuid = "9237b28f-5490-5468-be7b-bb81f5f5e6cf" +version = "10.1.3+4" + +[[deps.Missings]] +deps = ["DataAPI"] +git-tree-sha1 = "f66bdc5de519e8f8ae43bdc598782d35a25b1272" +uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" +version = "1.1.0" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2022.10.11" + +[[deps.MutableArithmetics]] +deps = ["LinearAlgebra", "SparseArrays", "Test"] +git-tree-sha1 = "964cb1a7069723727025ae295408747a0b36a854" +uuid = "d8a4904e-b15c-11e9-3269-09a3773c0cb0" +version = "1.3.0" + +[[deps.NaNMath]] +deps = ["OpenLibm_jll"] +git-tree-sha1 = "0877504529a3e5c3343c6f8b4c0381e57e4387e4" +uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" +version = "1.0.2" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.2.0" + +[[deps.OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.21+4" + +[[deps.OpenLibm_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "05823500-19ac-5b8b-9628-191a04bc5112" +version = "0.8.1+0" + +[[deps.OpenMPI_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML"] +git-tree-sha1 = "f3080f4212a8ba2ceb10a34b938601b862094314" +uuid = "fe0851c0-eecd-5654-98d4-656369965a5c" +version = "4.1.5+0" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "cae3153c7f6cf3f069a853883fd1919a6e5bab5b" +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.0.9+0" + +[[deps.OpenSpecFun_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "13652491f6856acfd2db29360e1bbcd4565d04f1" +uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" +version = "0.5.5+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "d321bf2de576bf25ec4d3e4360faca399afca282" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.6.0" + +[[deps.PDMats]] +deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] +git-tree-sha1 = "67eae2738d63117a196f497d7db789821bce61d1" +uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" +version = "0.11.17" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "a5aef8d4a6e8d81f171b2bd4be5265b01384c74c" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.5.10" + +[[deps.Pkg]] +deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"] +uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" +version = "1.9.0" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "9673d39decc5feece56ef3940e5dafba15ba0f81" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.1.2" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "7eb1686b4f04b82f96ed7a4ea5890a4f0c7a09f1" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.4.0" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" + +[[deps.Profile]] +deps = ["Printf"] +uuid = "9abbd945-dff8-562f-b5e8-e1ebf5ef1b79" + +[[deps.PyCall]] +deps = ["Conda", "Dates", "Libdl", "LinearAlgebra", "MacroTools", "Serialization", "VersionParsing"] +git-tree-sha1 = "62f417f6ad727987c755549e9cd88c46578da562" +uuid = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" +version = "1.95.1" + +[[deps.QuadGK]] +deps = ["DataStructures", "LinearAlgebra"] +git-tree-sha1 = "6ec7ac8412e83d57e313393220879ede1740f9ee" +uuid = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" +version = "2.8.2" + +[[deps.REPL]] +deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" + +[[deps.Random]] +deps = ["SHA", "Serialization"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" + +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + +[[deps.Requires]] +deps = ["UUIDs"] +git-tree-sha1 = "838a3a4188e2ded87a4f9f184b4b0d78a1e91cb7" +uuid = "ae029012-a4dd-5104-9daa-d747884805df" +version = "1.3.0" + +[[deps.Rmath]] +deps = ["Random", "Rmath_jll"] +git-tree-sha1 = "f65dcb5fa46aee0cf9ed6274ccbd597adc49aa7b" +uuid = "79098fc4-a85e-5d69-aa6a-4863f24498fa" +version = "0.7.1" + +[[deps.Rmath_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"] +git-tree-sha1 = "6ed52fdd3382cf21947b15e8870ac0ddbff736da" +uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" +version = "0.4.0+0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" + +[[deps.SnoopPrecompile]] +deps = ["Preferences"] +git-tree-sha1 = "e760a70afdcd461cf01a575947738d359234665c" +uuid = "66db9d55-30c0-4569-8b51-7e840670fc0c" +version = "1.0.3" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" + +[[deps.SortingAlgorithms]] +deps = ["DataStructures"] +git-tree-sha1 = "a4ada03f999bd01b3a25dcaa30b2d929fe537e00" +uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" +version = "1.1.0" + +[[deps.SparseArrays]] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] +uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" + +[[deps.SpecialFunctions]] +deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] +git-tree-sha1 = "ef28127915f4229c971eb43f3fc075dd3fe91880" +uuid = "276daf66-3868-5448-9aa4-cd146d93841b" +version = "2.2.0" + + [deps.SpecialFunctions.extensions] + SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" + + [deps.SpecialFunctions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + +[[deps.StaticArraysCore]] +git-tree-sha1 = "6b7ba252635a5eff6a0b0664a41ee140a1c9e72a" +uuid = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" +version = "1.4.0" + +[[deps.Statistics]] +deps = ["LinearAlgebra", "SparseArrays"] +uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.9.0" + +[[deps.StatsAPI]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "45a7769a04a3cf80da1c1c7c60caf932e6f4c9f7" +uuid = "82ae8749-77ed-4fe6-ae5f-f523153014b0" +version = "1.6.0" + +[[deps.StatsBase]] +deps = ["DataAPI", "DataStructures", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] +git-tree-sha1 = "75ebe04c5bed70b91614d684259b661c9e6274a4" +uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" +version = "0.34.0" + +[[deps.StatsFuns]] +deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] +git-tree-sha1 = "f625d686d5a88bcd2b15cd81f18f98186fdc0c9a" +uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" +version = "1.3.0" + + [deps.StatsFuns.extensions] + StatsFunsChainRulesCoreExt = "ChainRulesCore" + StatsFunsInverseFunctionsExt = "InverseFunctions" + + [deps.StatsFuns.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + +[[deps.SuiteSparse]] +deps = ["Libdl", "LinearAlgebra", "Serialization", "SparseArrays"] +uuid = "4607b0f0-06f3-5cda-b6b1-a6196a1729e9" + +[[deps.SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "Pkg", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "5.10.1+6" + +[[deps.Suppressor]] +git-tree-sha1 = "c6ed566db2fe3931292865b966d6d140b7ef32a9" +uuid = "fd094767-a336-5f1f-9728-57cf17d0bbfb" +version = "0.2.1" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.Tar]] +deps = ["ArgTools", "SHA"] +uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e" +version = "1.10.0" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[[deps.TimerOutputs]] +deps = ["ExprTools", "Printf"] +git-tree-sha1 = "f548a9e9c490030e545f72074a41edfd0e5bcdd7" +uuid = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" +version = "0.5.23" + +[[deps.TranscodingStreams]] +deps = ["Random", "Test"] +git-tree-sha1 = "9a6ae7ed916312b41236fcef7e0af564ef934769" +uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" +version = "0.9.13" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +[[deps.VersionParsing]] +git-tree-sha1 = "58d6e80b4ee071f5efd07fda82cb9fbe17200868" +uuid = "81def892-9a0e-5fdd-b105-ffc91e053289" +version = "1.3.0" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.2.13+0" + +[[deps.libaec_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "eddd19a8dea6b139ea97bdc8a0e2667d4b661720" +uuid = "477f73a3-ac25-53e9-8cc3-50b2fa2566f0" +version = "1.0.6+1" + +[[deps.libblastrampoline_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.7.0+0" + +[[deps.nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.48.0+0" + +[[deps.p7zip_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.4.0+0" diff --git a/docs/tutorials/Project.toml b/docs/tutorials/Project.toml new file mode 100644 index 0000000..b1b5206 --- /dev/null +++ b/docs/tutorials/Project.toml @@ -0,0 +1,7 @@ +[deps] +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" +Gurobi = "2e9cd046-0924-5485-92f1-d5272153d98b" +JuMP = "4076af6c-e467-56ae-b986-b466b2749572" +MIPLearn = "2b1277c3-b477-4c49-a15e-7ba350325c68" +PyCall = "438e738f-606a-5dbb-bf0a-cddfbfd45ab0" +Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" diff --git a/docs/tutorials/getting-started-gurobipy.ipynb b/docs/tutorials/getting-started-gurobipy.ipynb new file mode 100644 index 0000000..47d51dd --- /dev/null +++ b/docs/tutorials/getting-started-gurobipy.ipynb @@ -0,0 +1,849 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6b8983b1", + "metadata": { + "tags": [] + }, + "source": [ + "# Getting started (Gurobipy)\n", + "\n", + "## Introduction\n", + "\n", + "**MIPLearn** is an open source framework that uses machine learning (ML) to accelerate the performance of mixed-integer programming solvers (e.g. Gurobi, CPLEX, XPRESS). In this tutorial, we will:\n", + "\n", + "1. Install the Python/Gurobipy version of MIPLearn\n", + "2. Model a simple optimization problem using Gurobipy\n", + "3. Generate training data and train the ML models\n", + "4. Use the ML models together Gurobi to solve new instances\n", + "\n", + "
\n", + "Note\n", + " \n", + "The Python/Gurobipy version of MIPLearn is only compatible with the Gurobi Optimizer. For broader solver compatibility, see the Python/Pyomo and Julia/JuMP versions of the package.\n", + "
\n", + "\n", + "
\n", + "Warning\n", + " \n", + "MIPLearn is still in early development stage. If run into any bugs or issues, please submit a bug report in our GitHub repository. Comments, suggestions and pull requests are also very welcome!\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "id": "02f0a927", + "metadata": {}, + "source": [ + "## Installation\n", + "\n", + "MIPLearn is available in two versions:\n", + "\n", + "- Python version, compatible with the Pyomo and Gurobipy modeling languages,\n", + "- Julia version, compatible with the JuMP modeling language.\n", + "\n", + "In this tutorial, we will demonstrate how to use and install the Python/Gurobipy version of the package. The first step is to install Python 3.8+ in your computer. See the [official Python website for more instructions](https://www.python.org/downloads/). After Python is installed, we proceed to install MIPLearn using `pip`:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cd8a69c1", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:18:02.381829278Z", + "start_time": "2023-06-06T20:18:02.381532300Z" + } + }, + "outputs": [], + "source": [ + "# !pip install MIPLearn==0.3.0" + ] + }, + { + "cell_type": "markdown", + "id": "e8274543", + "metadata": {}, + "source": [ + "In addition to MIPLearn itself, we will also install Gurobi 10.0, a state-of-the-art commercial MILP solver. This step also install a demo license for Gurobi, which should able to solve the small optimization problems in this tutorial. A license is required for solving larger-scale problems." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "dcc8756c", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:18:15.537811992Z", + "start_time": "2023-06-06T20:18:13.449177860Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: gurobipy<10.1,>=10 in /home/axavier/Software/anaconda3/envs/miplearn/lib/python3.8/site-packages (10.0.1)\n" + ] + } + ], + "source": [ + "!pip install 'gurobipy>=10,<10.1'" + ] + }, + { + "cell_type": "markdown", + "id": "a14e4550", + "metadata": {}, + "source": [ + "
\n", + " \n", + "Note\n", + " \n", + "In the code above, we install specific version of all packages to ensure that this tutorial keeps running in the future, even when newer (and possibly incompatible) versions of the packages are released. This is usually a recommended practice for all Python projects.\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "16b86823", + "metadata": {}, + "source": [ + "## Modeling a simple optimization problem\n", + "\n", + "To illustrate how can MIPLearn be used, we will model and solve a small optimization problem related to power systems optimization. The problem we discuss below is a simplification of the **unit commitment problem,** a practical optimization problem solved daily by electric grid operators around the world. \n", + "\n", + "Suppose that a utility company needs to decide which electrical generators should be online at each hour of the day, as well as how much power should each generator produce. More specifically, assume that the company owns $n$ generators, denoted by $g_1, \\ldots, g_n$. Each generator can either be online or offline. An online generator $g_i$ can produce between $p^\\text{min}_i$ to $p^\\text{max}_i$ megawatts of power, and it costs the company $c^\\text{fix}_i + c^\\text{var}_i y_i$, where $y_i$ is the amount of power produced. An offline generator produces nothing and costs nothing. The total amount of power to be produced needs to be exactly equal to the total demand $d$ (in megawatts).\n", + "\n", + "This simple problem can be modeled as a *mixed-integer linear optimization* problem as follows. For each generator $g_i$, let $x_i \\in \\{0,1\\}$ be a decision variable indicating whether $g_i$ is online, and let $y_i \\geq 0$ be a decision variable indicating how much power does $g_i$ produce. The problem is then given by:" + ] + }, + { + "cell_type": "markdown", + "id": "f12c3702", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align}\n", + "\\text{minimize } \\quad & \\sum_{i=1}^n \\left( c^\\text{fix}_i x_i + c^\\text{var}_i y_i \\right) \\\\\n", + "\\text{subject to } \\quad & y_i \\leq p^\\text{max}_i x_i & i=1,\\ldots,n \\\\\n", + "& y_i \\geq p^\\text{min}_i x_i & i=1,\\ldots,n \\\\\n", + "& \\sum_{i=1}^n y_i = d \\\\\n", + "& x_i \\in \\{0,1\\} & i=1,\\ldots,n \\\\\n", + "& y_i \\geq 0 & i=1,\\ldots,n\n", + "\\end{align}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "be3989ed", + "metadata": {}, + "source": [ + "
\n", + "\n", + "Note\n", + "\n", + "We use a simplified version of the unit commitment problem in this tutorial just to make it easier to follow. MIPLearn can also handle realistic, large-scale versions of this problem.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "a5fd33f6", + "metadata": {}, + "source": [ + "Next, let us convert this abstract mathematical formulation into a concrete optimization model, using Python and Pyomo. We start by defining a data class `UnitCommitmentData`, which holds all the input data." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "22a67170-10b4-43d3-8708-014d91141e73", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:18:25.442346786Z", + "start_time": "2023-06-06T20:18:25.329017476Z" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "from dataclasses import dataclass\n", + "from typing import List\n", + "\n", + "import numpy as np\n", + "\n", + "\n", + "@dataclass\n", + "class UnitCommitmentData:\n", + " demand: float\n", + " pmin: List[float]\n", + " pmax: List[float]\n", + " cfix: List[float]\n", + " cvar: List[float]" + ] + }, + { + "cell_type": "markdown", + "id": "29f55efa-0751-465a-9b0a-a821d46a3d40", + "metadata": {}, + "source": [ + "Next, we write a `build_uc_model` function, which converts the input data into a concrete Pyomo model. The function accepts `UnitCommitmentData`, the data structure we previously defined, or the path to a compressed pickle file containing this data." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "2f67032f-0d74-4317-b45c-19da0ec859e9", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:48:05.953902842Z", + "start_time": "2023-06-06T20:48:05.909747925Z" + } + }, + "outputs": [], + "source": [ + "import gurobipy as gp\n", + "from gurobipy import GRB, quicksum\n", + "from typing import Union\n", + "from miplearn.io import read_pkl_gz\n", + "from miplearn.solvers.gurobi import GurobiModel\n", + "\n", + "def build_uc_model(data: Union[str, UnitCommitmentData]) -> GurobiModel:\n", + " if isinstance(data, str):\n", + " data = read_pkl_gz(data)\n", + "\n", + " model = gp.Model()\n", + " n = len(data.pmin)\n", + " x = model._x = model.addVars(n, vtype=GRB.BINARY, name=\"x\")\n", + " y = model._y = model.addVars(n, name=\"y\")\n", + " model.setObjective(\n", + " quicksum(\n", + " data.cfix[i] * x[i] + data.cvar[i] * y[i] for i in range(n)\n", + " )\n", + " )\n", + " model.addConstrs(y[i] <= data.pmax[i] * x[i] for i in range(n))\n", + " model.addConstrs(y[i] >= data.pmin[i] * x[i] for i in range(n))\n", + " model.addConstr(quicksum(y[i] for i in range(n)) == data.demand)\n", + " return GurobiModel(model)" + ] + }, + { + "cell_type": "markdown", + "id": "c22714a3", + "metadata": {}, + "source": [ + "At this point, we can already use Pyomo and any mixed-integer linear programming solver to find optimal solutions to any instance of this problem. To illustrate this, let us solve a small instance with three generators:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2a896f47", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:14.266758244Z", + "start_time": "2023-06-06T20:49:14.223514806Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Restricted license - for non-production use only - expires 2024-10-28\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 7 rows, 6 columns and 15 nonzeros\n", + "Model fingerprint: 0x58dfdd53\n", + "Variable types: 3 continuous, 3 integer (3 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 7e+01]\n", + " Objective range [2e+00, 7e+02]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+02, 1e+02]\n", + "Presolve removed 2 rows and 1 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 5 rows, 5 columns, 13 nonzeros\n", + "Variable types: 0 continuous, 5 integer (3 binary)\n", + "Found heuristic solution: objective 1400.0000000\n", + "\n", + "Root relaxation: objective 1.035000e+03, 3 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 1035.00000 0 1 1400.00000 1035.00000 26.1% - 0s\n", + " 0 0 1105.71429 0 1 1400.00000 1105.71429 21.0% - 0s\n", + "* 0 0 0 1320.0000000 1320.00000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (5 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 2: 1320 1400 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 1.320000000000e+03, best bound 1.320000000000e+03, gap 0.0000%\n", + "obj = 1320.0\n", + "x = [-0.0, 1.0, 1.0]\n", + "y = [0.0, 60.0, 40.0]\n" + ] + } + ], + "source": [ + "model = build_uc_model(\n", + " UnitCommitmentData(\n", + " demand=100.0,\n", + " pmin=[10, 20, 30],\n", + " pmax=[50, 60, 70],\n", + " cfix=[700, 600, 500],\n", + " cvar=[1.5, 2.0, 2.5],\n", + " )\n", + ")\n", + "\n", + "model.optimize()\n", + "print(\"obj =\", model.inner.objVal)\n", + "print(\"x =\", [model.inner._x[i].x for i in range(3)])\n", + "print(\"y =\", [model.inner._y[i].x for i in range(3)])" + ] + }, + { + "cell_type": "markdown", + "id": "41b03bbc", + "metadata": {}, + "source": [ + "Running the code above, we found that the optimal solution for our small problem instance costs \\$1320. It is achieve by keeping generators 2 and 3 online and producing, respectively, 60 MW and 40 MW of power." + ] + }, + { + "cell_type": "markdown", + "id": "01f576e1-1790-425e-9e5c-9fa07b6f4c26", + "metadata": {}, + "source": [ + "
\n", + " \n", + "Note\n", + "\n", + "- In the example above, `GurobiModel` is just a thin wrapper around a standard Gurobi model. This wrapper allows MIPLearn to be solver- and modeling-language-agnostic. The wrapper provides only a few basic methods, such as `optimize`. For more control, and to query the solution, the original Gurobi model can be accessed through `model.inner`, as illustrated above.\n", + "- To ensure training data consistency, MIPLearn requires all decision variables to have names.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "cf60c1dd", + "metadata": {}, + "source": [ + "## Generating training data\n", + "\n", + "Although Gurobi could solve the small example above in a fraction of a second, it gets slower for larger and more complex versions of the problem. If this is a problem that needs to be solved frequently, as it is often the case in practice, it could make sense to spend some time upfront generating a **trained** solver, which can optimize new instances (similar to the ones it was trained on) faster.\n", + "\n", + "In the following, we will use MIPLearn to train machine learning models that is able to predict the optimal solution for instances that follow a given probability distribution, then it will provide this predicted solution to Gurobi as a warm start. Before we can train the model, we need to collect training data by solving a large number of instances. In real-world situations, we may construct these training instances based on historical data. In this tutorial, we will construct them using a random instance generator:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5eb09fab", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:22.758192368Z", + "start_time": "2023-06-06T20:49:22.724784572Z" + } + }, + "outputs": [], + "source": [ + "from scipy.stats import uniform\n", + "from typing import List\n", + "import random\n", + "\n", + "\n", + "def random_uc_data(samples: int, n: int, seed: int = 42) -> List[UnitCommitmentData]:\n", + " random.seed(seed)\n", + " np.random.seed(seed)\n", + " pmin = uniform(loc=100_000.0, scale=400_000.0).rvs(n)\n", + " pmax = pmin * uniform(loc=2.0, scale=2.5).rvs(n)\n", + " cfix = pmin * uniform(loc=100.0, scale=25.0).rvs(n)\n", + " cvar = uniform(loc=1.25, scale=0.25).rvs(n)\n", + " return [\n", + " UnitCommitmentData(\n", + " demand=pmax.sum() * uniform(loc=0.5, scale=0.25).rvs(),\n", + " pmin=pmin,\n", + " pmax=pmax,\n", + " cfix=cfix,\n", + " cvar=cvar,\n", + " )\n", + " for _ in range(samples)\n", + " ]" + ] + }, + { + "cell_type": "markdown", + "id": "3a03a7ac", + "metadata": {}, + "source": [ + "In this example, for simplicity, only the demands change from one instance to the next. We could also have randomized the costs, production limits or even the number of units. The more randomization we have in the training data, however, the more challenging it is for the machine learning models to learn solution patterns.\n", + "\n", + "Now we generate 500 instances of this problem, each one with 50 generators, and we use 450 of these instances for training. After generating the instances, we write them to individual files. MIPLearn uses files during the training process because, for large-scale optimization problems, it is often impractical to hold in memory the entire training data, as well as the concrete Pyomo models. Files also make it much easier to solve multiple instances simultaneously, potentially on multiple machines. The code below generates the files `uc/train/00000.pkl.gz`, `uc/train/00001.pkl.gz`, etc., which contain the input data in compressed (gzipped) pickle format." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6156752c", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:24.811192929Z", + "start_time": "2023-06-06T20:49:24.575639142Z" + } + }, + "outputs": [], + "source": [ + "from miplearn.io import write_pkl_gz\n", + "\n", + "data = random_uc_data(samples=500, n=500)\n", + "train_data = write_pkl_gz(data[0:450], \"uc/train\")\n", + "test_data = write_pkl_gz(data[450:500], \"uc/test\")" + ] + }, + { + "cell_type": "markdown", + "id": "b17af877", + "metadata": {}, + "source": [ + "Finally, we use `BasicCollector` to collect the optimal solutions and other useful training data for all training instances. The data is stored in HDF5 files `uc/train/00000.h5`, `uc/train/00001.h5`, etc. The optimization models are also exported to compressed MPS files `uc/train/00000.mps.gz`, `uc/train/00001.mps.gz`, etc." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "7623f002", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:34.936729253Z", + "start_time": "2023-06-06T20:49:25.936126612Z" + } + }, + "outputs": [], + "source": [ + "from miplearn.collectors.basic import BasicCollector\n", + "\n", + "bc = BasicCollector()\n", + "bc.collect(train_data, build_uc_model, n_jobs=4)" + ] + }, + { + "cell_type": "markdown", + "id": "c42b1be1-9723-4827-82d8-974afa51ef9f", + "metadata": {}, + "source": [ + "## Training and solving test instances" + ] + }, + { + "cell_type": "markdown", + "id": "a33c6aa4-f0b8-4ccb-9935-01f7d7de2a1c", + "metadata": {}, + "source": [ + "With training data in hand, we can now design and train a machine learning model to accelerate solver performance. In this tutorial, for illustration purposes, we will use ML to generate a good warm start using $k$-nearest neighbors. More specifically, the strategy is to:\n", + "\n", + "1. Memorize the optimal solutions of all training instances;\n", + "2. Given a test instance, find the 25 most similar training instances, based on constraint right-hand sides;\n", + "3. Merge their optimal solutions into a single partial solution; specifically, only assign values to the binary variables that agree unanimously.\n", + "4. Provide this partial solution to the solver as a warm start.\n", + "\n", + "This simple strategy can be implemented as shown below, using `MemorizingPrimalComponent`. For more advanced strategies, and for the usage of more advanced classifiers, see the user guide." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "435f7bf8-4b09-4889-b1ec-b7b56e7d8ed2", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:38.997939600Z", + "start_time": "2023-06-06T20:49:38.968261432Z" + } + }, + "outputs": [], + "source": [ + "from sklearn.neighbors import KNeighborsClassifier\n", + "from miplearn.components.primal.actions import SetWarmStart\n", + "from miplearn.components.primal.mem import (\n", + " MemorizingPrimalComponent,\n", + " MergeTopSolutions,\n", + ")\n", + "from miplearn.extractors.fields import H5FieldsExtractor\n", + "\n", + "comp = MemorizingPrimalComponent(\n", + " clf=KNeighborsClassifier(n_neighbors=25),\n", + " extractor=H5FieldsExtractor(\n", + " instance_fields=[\"static_constr_rhs\"],\n", + " ),\n", + " constructor=MergeTopSolutions(25, [0.0, 1.0]),\n", + " action=SetWarmStart(),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9536e7e4-0b0d-49b0-bebd-4a848f839e94", + "metadata": {}, + "source": [ + "Having defined the ML strategy, we next construct `LearningSolver`, train the ML component and optimize one of the test instances." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "9d13dd50-3dcf-4673-a757-6f44dcc0dedf", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:42.072345411Z", + "start_time": "2023-06-06T20:49:41.294040974Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0xa8b70287\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve removed 1000 rows and 500 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 1 rows, 500 columns, 500 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.6166537e+09 5.648803e+04 0.000000e+00 0s\n", + " 1 8.2906219e+09 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", + "Optimal objective 8.290621916e+09\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x4ccd7ae3\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "\n", + "User MIP start produced solution with objective 8.30129e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.29184e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.29146e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.29146e+09 (0.01s)\n", + "Loaded user MIP start with objective 8.29146e+09\n", + "\n", + "Presolve time: 0.00s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "\n", + "Root relaxation: objective 8.290622e+09, 512 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 8.2906e+09 0 1 8.2915e+09 8.2906e+09 0.01% - 0s\n", + "\n", + "Cutting planes:\n", + " Cover: 1\n", + " Flow cover: 2\n", + "\n", + "Explored 1 nodes (512 simplex iterations) in 0.07 seconds (0.01 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 3: 8.29146e+09 8.29184e+09 8.30129e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 8.291459497797e+09, best bound 8.290645029670e+09, gap 0.0098%\n" + ] + } + ], + "source": [ + "from miplearn.solvers.learning import LearningSolver\n", + "\n", + "solver_ml = LearningSolver(components=[comp])\n", + "solver_ml.fit(train_data)\n", + "solver_ml.optimize(test_data[0], build_uc_model);" + ] + }, + { + "cell_type": "markdown", + "id": "61da6dad-7f56-4edb-aa26-c00eb5f946c0", + "metadata": {}, + "source": [ + "By examining the solve log above, specifically the line `Loaded user MIP start with objective...`, we can see that MIPLearn was able to construct an initial solution which turned out to be very close to the optimal solution to the problem. Now let us repeat the code above, but a solver which does not apply any ML strategies. Note that our previously-defined component is not provided." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "2ff391ed-e855-4228-aa09-a7641d8c2893", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:49:44.012782276Z", + "start_time": "2023-06-06T20:49:43.813974362Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0xa8b70287\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve removed 1000 rows and 500 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 1 rows, 500 columns, 500 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.6166537e+09 5.648803e+04 0.000000e+00 0s\n", + " 1 8.2906219e+09 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", + "Optimal objective 8.290621916e+09\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x4cbbf7c7\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve time: 0.00s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Found heuristic solution: objective 9.757128e+09\n", + "\n", + "Root relaxation: objective 8.290622e+09, 512 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 8.2906e+09 0 1 9.7571e+09 8.2906e+09 15.0% - 0s\n", + "H 0 0 8.298273e+09 8.2906e+09 0.09% - 0s\n", + " 0 0 8.2907e+09 0 4 8.2983e+09 8.2907e+09 0.09% - 0s\n", + " 0 0 8.2907e+09 0 1 8.2983e+09 8.2907e+09 0.09% - 0s\n", + " 0 0 8.2907e+09 0 4 8.2983e+09 8.2907e+09 0.09% - 0s\n", + "H 0 0 8.293980e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2907e+09 0 5 8.2940e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2907e+09 0 1 8.2940e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2907e+09 0 2 8.2940e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2908e+09 0 1 8.2940e+09 8.2908e+09 0.04% - 0s\n", + " 0 0 8.2908e+09 0 4 8.2940e+09 8.2908e+09 0.04% - 0s\n", + " 0 0 8.2908e+09 0 4 8.2940e+09 8.2908e+09 0.04% - 0s\n", + "H 0 0 8.291465e+09 8.2908e+09 0.01% - 0s\n", + "\n", + "Cutting planes:\n", + " Gomory: 2\n", + " MIR: 1\n", + "\n", + "Explored 1 nodes (1031 simplex iterations) in 0.07 seconds (0.03 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 4: 8.29147e+09 8.29398e+09 8.29827e+09 9.75713e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 8.291465302389e+09, best bound 8.290781665333e+09, gap 0.0082%\n" + ] + } + ], + "source": [ + "solver_baseline = LearningSolver(components=[])\n", + "solver_baseline.fit(train_data)\n", + "solver_baseline.optimize(test_data[0], build_uc_model);" + ] + }, + { + "cell_type": "markdown", + "id": "b6d37b88-9fcc-43ee-ac1e-2a7b1e51a266", + "metadata": {}, + "source": [ + "In the log above, the `MIP start` line is missing, and Gurobi had to start with a significantly inferior initial solution. The solver was still able to find the optimal solution at the end, but it required using its own internal heuristic procedures. In this example, because we solve very small optimization problems, there was almost no difference in terms of running time, but the difference can be significant for larger problems." + ] + }, + { + "cell_type": "markdown", + "id": "eec97f06", + "metadata": { + "tags": [] + }, + "source": [ + "## Accessing the solution\n", + "\n", + "In the example above, we used `LearningSolver.solve` together with data files to solve both the training and the test instances. The optimal solutions were saved to HDF5 files in the train/test folders, and could be retrieved by reading theses files, but that is not very convenient. In the following example, we show how to build and solve a Pyomo model entirely in-memory, using our trained solver." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "67a6cd18", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:50:12.869892930Z", + "start_time": "2023-06-06T20:50:12.509410473Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x19042f12\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve removed 1000 rows and 500 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 1 rows, 500 columns, 500 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.5917580e+09 5.627453e+04 0.000000e+00 0s\n", + " 1 8.2535968e+09 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", + "Optimal objective 8.253596777e+09\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x8ee64638\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "\n", + "User MIP start produced solution with objective 8.25814e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.25512e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.25459e+09 (0.04s)\n", + "User MIP start produced solution with objective 8.25459e+09 (0.04s)\n", + "Loaded user MIP start with objective 8.25459e+09\n", + "\n", + "Presolve time: 0.01s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "\n", + "Root relaxation: objective 8.253597e+09, 512 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 8.2536e+09 0 1 8.2546e+09 8.2536e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 3 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 1 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 4 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 4 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2538e+09 0 4 8.2546e+09 8.2538e+09 0.01% - 0s\n", + " 0 0 8.2538e+09 0 5 8.2546e+09 8.2538e+09 0.01% - 0s\n", + " 0 0 8.2538e+09 0 6 8.2546e+09 8.2538e+09 0.01% - 0s\n", + "\n", + "Cutting planes:\n", + " Cover: 1\n", + " MIR: 2\n", + " StrongCG: 1\n", + " Flow cover: 1\n", + "\n", + "Explored 1 nodes (575 simplex iterations) in 0.12 seconds (0.01 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 3: 8.25459e+09 8.25512e+09 8.25814e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 8.254590409970e+09, best bound 8.253768093811e+09, gap 0.0100%\n", + "obj = 8254590409.969726\n", + "x = [1.0, 1.0, 0.0]\n", + "y = [935662.0949263407, 1604270.0218116897, 0.0]\n" + ] + } + ], + "source": [ + "data = random_uc_data(samples=1, n=500)[0]\n", + "model = build_uc_model(data)\n", + "solver_ml.optimize(model)\n", + "print(\"obj =\", model.inner.objVal)\n", + "print(\"x =\", [model.inner._x[i].x for i in range(3)])\n", + "print(\"y =\", [model.inner._y[i].x for i in range(3)])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5593d23a-83bd-4e16-8253-6300f5e3f63b", + "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.9.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/tutorials/getting-started-jump.ipynb b/docs/tutorials/getting-started-jump.ipynb new file mode 100644 index 0000000..ec76ac2 --- /dev/null +++ b/docs/tutorials/getting-started-jump.ipynb @@ -0,0 +1,680 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6b8983b1", + "metadata": { + "tags": [] + }, + "source": [ + "# Getting started (JuMP)\n", + "\n", + "## Introduction\n", + "\n", + "**MIPLearn** is an open source framework that uses machine learning (ML) to accelerate the performance of mixed-integer programming solvers (e.g. Gurobi, CPLEX, XPRESS). In this tutorial, we will:\n", + "\n", + "1. Install the Julia/JuMP version of MIPLearn\n", + "2. Model a simple optimization problem using JuMP\n", + "3. Generate training data and train the ML models\n", + "4. Use the ML models together Gurobi to solve new instances\n", + "\n", + "
\n", + "Warning\n", + " \n", + "MIPLearn is still in early development stage. If run into any bugs or issues, please submit a bug report in our GitHub repository. Comments, suggestions and pull requests are also very welcome!\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "id": "02f0a927", + "metadata": {}, + "source": [ + "## Installation\n", + "\n", + "MIPLearn is available in two versions:\n", + "\n", + "- Python version, compatible with the Pyomo and Gurobipy modeling languages,\n", + "- Julia version, compatible with the JuMP modeling language.\n", + "\n", + "In this tutorial, we will demonstrate how to use and install the Python/Pyomo version of the package. The first step is to install Julia in your machine. See the [official Julia website for more instructions](https://julialang.org/downloads/). After Julia is installed, launch the Julia REPL, type `]` to enter package mode, then install MIPLearn:\n", + "\n", + "```\n", + "pkg> add MIPLearn@0.3\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "e8274543", + "metadata": {}, + "source": [ + "In addition to MIPLearn itself, we will also install:\n", + "\n", + "- the JuMP modeling language\n", + "- Gurobi, a state-of-the-art commercial MILP solver\n", + "- Distributions, to generate random data\n", + "- PyCall, to access ML model from Scikit-Learn\n", + "- Suppressor, to make the output cleaner\n", + "\n", + "```\n", + "pkg> add JuMP@1, Gurobi@1, Distributions@0.25, PyCall@1, Suppressor@0.2\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "a14e4550", + "metadata": {}, + "source": [ + "
\n", + " \n", + "Note\n", + "\n", + "- If you do not have a Gurobi license available, you can also follow the tutorial by installing an open-source solver, such as `HiGHS`, and replacing `Gurobi.Optimizer` by `HiGHS.Optimizer` in all the code examples.\n", + "- In the code above, we install specific version of all packages to ensure that this tutorial keeps running in the future, even when newer (and possibly incompatible) versions of the packages are released. This is usually a recommended practice for all Julia projects.\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "16b86823", + "metadata": {}, + "source": [ + "## Modeling a simple optimization problem\n", + "\n", + "To illustrate how can MIPLearn be used, we will model and solve a small optimization problem related to power systems optimization. The problem we discuss below is a simplification of the **unit commitment problem,** a practical optimization problem solved daily by electric grid operators around the world. \n", + "\n", + "Suppose that a utility company needs to decide which electrical generators should be online at each hour of the day, as well as how much power should each generator produce. More specifically, assume that the company owns $n$ generators, denoted by $g_1, \\ldots, g_n$. Each generator can either be online or offline. An online generator $g_i$ can produce between $p^\\text{min}_i$ to $p^\\text{max}_i$ megawatts of power, and it costs the company $c^\\text{fix}_i + c^\\text{var}_i y_i$, where $y_i$ is the amount of power produced. An offline generator produces nothing and costs nothing. The total amount of power to be produced needs to be exactly equal to the total demand $d$ (in megawatts).\n", + "\n", + "This simple problem can be modeled as a *mixed-integer linear optimization* problem as follows. For each generator $g_i$, let $x_i \\in \\{0,1\\}$ be a decision variable indicating whether $g_i$ is online, and let $y_i \\geq 0$ be a decision variable indicating how much power does $g_i$ produce. The problem is then given by:" + ] + }, + { + "cell_type": "markdown", + "id": "f12c3702", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align}\n", + "\\text{minimize } \\quad & \\sum_{i=1}^n \\left( c^\\text{fix}_i x_i + c^\\text{var}_i y_i \\right) \\\\\n", + "\\text{subject to } \\quad & y_i \\leq p^\\text{max}_i x_i & i=1,\\ldots,n \\\\\n", + "& y_i \\geq p^\\text{min}_i x_i & i=1,\\ldots,n \\\\\n", + "& \\sum_{i=1}^n y_i = d \\\\\n", + "& x_i \\in \\{0,1\\} & i=1,\\ldots,n \\\\\n", + "& y_i \\geq 0 & i=1,\\ldots,n\n", + "\\end{align}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "be3989ed", + "metadata": {}, + "source": [ + "
\n", + "\n", + "Note\n", + "\n", + "We use a simplified version of the unit commitment problem in this tutorial just to make it easier to follow. MIPLearn can also handle realistic, large-scale versions of this problem.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "a5fd33f6", + "metadata": {}, + "source": [ + "Next, let us convert this abstract mathematical formulation into a concrete optimization model, using Julia and JuMP. We start by defining a data class `UnitCommitmentData`, which holds all the input data." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c62ebff1-db40-45a1-9997-d121837f067b", + "metadata": {}, + "outputs": [], + "source": [ + "struct UnitCommitmentData\n", + " demand::Float64\n", + " pmin::Vector{Float64}\n", + " pmax::Vector{Float64}\n", + " cfix::Vector{Float64}\n", + " cvar::Vector{Float64}\n", + "end;" + ] + }, + { + "cell_type": "markdown", + "id": "29f55efa-0751-465a-9b0a-a821d46a3d40", + "metadata": {}, + "source": [ + "Next, we write a `build_uc_model` function, which converts the input data into a concrete JuMP model. The function accepts `UnitCommitmentData`, the data structure we previously defined, or the path to a JLD2 file containing this data." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "79ef7775-18ca-4dfa-b438-49860f762ad0", + "metadata": {}, + "outputs": [], + "source": [ + "using MIPLearn\n", + "using JuMP\n", + "using Gurobi\n", + "\n", + "function build_uc_model(data)\n", + " if data isa String\n", + " data = read_jld2(data)\n", + " end\n", + " model = Model(Gurobi.Optimizer)\n", + " G = 1:length(data.pmin)\n", + " @variable(model, x[G], Bin)\n", + " @variable(model, y[G] >= 0)\n", + " @objective(model, Min, sum(data.cfix[g] * x[g] + data.cvar[g] * y[g] for g in G))\n", + " @constraint(model, eq_max_power[g in G], y[g] <= data.pmax[g] * x[g])\n", + " @constraint(model, eq_min_power[g in G], y[g] >= data.pmin[g] * x[g])\n", + " @constraint(model, eq_demand, sum(y[g] for g in G) == data.demand)\n", + " return JumpModel(model)\n", + "end;" + ] + }, + { + "cell_type": "markdown", + "id": "c22714a3", + "metadata": {}, + "source": [ + "At this point, we can already use Gurobi to find optimal solutions to any instance of this problem. To illustrate this, let us solve a small instance with three generators:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "dd828d68-fd43-4d2a-a058-3e2628d99d9e", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:01:10.993801745Z", + "start_time": "2023-06-06T20:01:10.887580927Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 7 rows, 6 columns and 15 nonzeros\n", + "Model fingerprint: 0x55e33a07\n", + "Variable types: 3 continuous, 3 integer (3 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 7e+01]\n", + " Objective range [2e+00, 7e+02]\n", + " Bounds range [0e+00, 0e+00]\n", + " RHS range [1e+02, 1e+02]\n", + "Presolve removed 2 rows and 1 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 5 rows, 5 columns, 13 nonzeros\n", + "Variable types: 0 continuous, 5 integer (3 binary)\n", + "Found heuristic solution: objective 1400.0000000\n", + "\n", + "Root relaxation: objective 1.035000e+03, 3 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 1035.00000 0 1 1400.00000 1035.00000 26.1% - 0s\n", + " 0 0 1105.71429 0 1 1400.00000 1105.71429 21.0% - 0s\n", + "* 0 0 0 1320.0000000 1320.00000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (5 simplex iterations) in 0.00 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 2: 1320 1400 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 1.320000000000e+03, best bound 1.320000000000e+03, gap 0.0000%\n", + "\n", + "User-callback calls 371, time in user-callback 0.00 sec\n", + "objective_value(model.inner) = 1320.0\n", + "Vector(value.(model.inner[:x])) = [-0.0, 1.0, 1.0]\n", + "Vector(value.(model.inner[:y])) = [0.0, 60.0, 40.0]\n" + ] + } + ], + "source": [ + "model = build_uc_model(\n", + " UnitCommitmentData(\n", + " 100.0, # demand\n", + " [10, 20, 30], # pmin\n", + " [50, 60, 70], # pmax\n", + " [700, 600, 500], # cfix\n", + " [1.5, 2.0, 2.5], # cvar\n", + " )\n", + ")\n", + "model.optimize()\n", + "@show objective_value(model.inner)\n", + "@show Vector(value.(model.inner[:x]))\n", + "@show Vector(value.(model.inner[:y]));" + ] + }, + { + "cell_type": "markdown", + "id": "41b03bbc", + "metadata": {}, + "source": [ + "Running the code above, we found that the optimal solution for our small problem instance costs \\$1320. It is achieve by keeping generators 2 and 3 online and producing, respectively, 60 MW and 40 MW of power." + ] + }, + { + "cell_type": "markdown", + "id": "01f576e1-1790-425e-9e5c-9fa07b6f4c26", + "metadata": {}, + "source": [ + "
\n", + " \n", + "Notes\n", + " \n", + "- In the example above, `JumpModel` is just a thin wrapper around a standard JuMP model. This wrapper allows MIPLearn to be solver- and modeling-language-agnostic. The wrapper provides only a few basic methods, such as `optimize`. For more control, and to query the solution, the original JuMP model can be accessed through `model.inner`, as illustrated above.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "cf60c1dd", + "metadata": {}, + "source": [ + "## Generating training data\n", + "\n", + "Although Gurobi could solve the small example above in a fraction of a second, it gets slower for larger and more complex versions of the problem. If this is a problem that needs to be solved frequently, as it is often the case in practice, it could make sense to spend some time upfront generating a **trained** solver, which can optimize new instances (similar to the ones it was trained on) faster.\n", + "\n", + "In the following, we will use MIPLearn to train machine learning models that is able to predict the optimal solution for instances that follow a given probability distribution, then it will provide this predicted solution to Gurobi as a warm start. Before we can train the model, we need to collect training data by solving a large number of instances. In real-world situations, we may construct these training instances based on historical data. In this tutorial, we will construct them using a random instance generator:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1326efd7-3869-4137-ab6b-df9cb609a7e0", + "metadata": {}, + "outputs": [], + "source": [ + "using Distributions\n", + "using Random\n", + "\n", + "function random_uc_data(; samples::Int, n::Int, seed::Int=42)::Vector\n", + " Random.seed!(seed)\n", + " pmin = rand(Uniform(100_000, 500_000), n)\n", + " pmax = pmin .* rand(Uniform(2, 2.5), n)\n", + " cfix = pmin .* rand(Uniform(100, 125), n)\n", + " cvar = rand(Uniform(1.25, 1.50), n)\n", + " return [\n", + " UnitCommitmentData(\n", + " sum(pmax) * rand(Uniform(0.5, 0.75)),\n", + " pmin,\n", + " pmax,\n", + " cfix,\n", + " cvar,\n", + " )\n", + " for _ in 1:samples\n", + " ]\n", + "end;" + ] + }, + { + "cell_type": "markdown", + "id": "3a03a7ac", + "metadata": {}, + "source": [ + "In this example, for simplicity, only the demands change from one instance to the next. We could also have randomized the costs, production limits or even the number of units. The more randomization we have in the training data, however, the more challenging it is for the machine learning models to learn solution patterns.\n", + "\n", + "Now we generate 500 instances of this problem, each one with 50 generators, and we use 450 of these instances for training. After generating the instances, we write them to individual files. MIPLearn uses files during the training process because, for large-scale optimization problems, it is often impractical to hold in memory the entire training data, as well as the concrete Pyomo models. Files also make it much easier to solve multiple instances simultaneously, potentially on multiple machines. The code below generates the files `uc/train/00001.jld2`, `uc/train/00002.jld2`, etc., which contain the input data in JLD2 format." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6156752c", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:03:04.782830561Z", + "start_time": "2023-06-06T20:03:04.530421396Z" + } + }, + "outputs": [], + "source": [ + "data = random_uc_data(samples=500, n=500)\n", + "train_data = write_jld2(data[1:450], \"uc/train\")\n", + "test_data = write_jld2(data[451:500], \"uc/test\");" + ] + }, + { + "cell_type": "markdown", + "id": "b17af877", + "metadata": {}, + "source": [ + "Finally, we use `BasicCollector` to collect the optimal solutions and other useful training data for all training instances. The data is stored in HDF5 files `uc/train/00001.h5`, `uc/train/00002.h5`, etc. The optimization models are also exported to compressed MPS files `uc/train/00001.mps.gz`, `uc/train/00002.mps.gz`, etc." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7623f002", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:03:35.571497019Z", + "start_time": "2023-06-06T20:03:25.804104036Z" + } + }, + "outputs": [], + "source": [ + "using Suppressor\n", + "@suppress_out begin\n", + " bc = BasicCollector()\n", + " bc.collect(train_data, build_uc_model)\n", + "end" + ] + }, + { + "cell_type": "markdown", + "id": "c42b1be1-9723-4827-82d8-974afa51ef9f", + "metadata": {}, + "source": [ + "## Training and solving test instances" + ] + }, + { + "cell_type": "markdown", + "id": "a33c6aa4-f0b8-4ccb-9935-01f7d7de2a1c", + "metadata": {}, + "source": [ + "With training data in hand, we can now design and train a machine learning model to accelerate solver performance. In this tutorial, for illustration purposes, we will use ML to generate a good warm start using $k$-nearest neighbors. More specifically, the strategy is to:\n", + "\n", + "1. Memorize the optimal solutions of all training instances;\n", + "2. Given a test instance, find the 25 most similar training instances, based on constraint right-hand sides;\n", + "3. Merge their optimal solutions into a single partial solution; specifically, only assign values to the binary variables that agree unanimously.\n", + "4. Provide this partial solution to the solver as a warm start.\n", + "\n", + "This simple strategy can be implemented as shown below, using `MemorizingPrimalComponent`. For more advanced strategies, and for the usage of more advanced classifiers, see the user guide." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "435f7bf8-4b09-4889-b1ec-b7b56e7d8ed2", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:05:20.497772794Z", + "start_time": "2023-06-06T20:05:20.484821405Z" + } + }, + "outputs": [], + "source": [ + "# Load kNN classifier from Scikit-Learn\n", + "using PyCall\n", + "KNeighborsClassifier = pyimport(\"sklearn.neighbors\").KNeighborsClassifier\n", + "\n", + "# Build the MIPLearn component\n", + "comp = MemorizingPrimalComponent(\n", + " clf=KNeighborsClassifier(n_neighbors=25),\n", + " extractor=H5FieldsExtractor(\n", + " instance_fields=[\"static_constr_rhs\"],\n", + " ),\n", + " constructor=MergeTopSolutions(25, [0.0, 1.0]),\n", + " action=SetWarmStart(),\n", + ");" + ] + }, + { + "cell_type": "markdown", + "id": "9536e7e4-0b0d-49b0-bebd-4a848f839e94", + "metadata": {}, + "source": [ + "Having defined the ML strategy, we next construct `LearningSolver`, train the ML component and optimize one of the test instances." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9d13dd50-3dcf-4673-a757-6f44dcc0dedf", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:05:22.672002339Z", + "start_time": "2023-06-06T20:05:21.447466634Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0xd2378195\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [0e+00, 0e+00]\n", + " RHS range [2e+08, 2e+08]\n", + "\n", + "User MIP start produced solution with objective 1.02165e+10 (0.00s)\n", + "Loaded user MIP start with objective 1.02165e+10\n", + "\n", + "Presolve time: 0.00s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "\n", + "Root relaxation: objective 1.021568e+10, 510 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 1.0216e+10 0 1 1.0217e+10 1.0216e+10 0.01% - 0s\n", + "\n", + "Explored 1 nodes (510 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 1: 1.02165e+10 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 1.021651058978e+10, best bound 1.021567971257e+10, gap 0.0081%\n", + "\n", + "User-callback calls 169, time in user-callback 0.00 sec\n" + ] + } + ], + "source": [ + "solver_ml = LearningSolver(components=[comp])\n", + "solver_ml.fit(train_data)\n", + "solver_ml.optimize(test_data[1], build_uc_model);" + ] + }, + { + "cell_type": "markdown", + "id": "61da6dad-7f56-4edb-aa26-c00eb5f946c0", + "metadata": {}, + "source": [ + "By examining the solve log above, specifically the line `Loaded user MIP start with objective...`, we can see that MIPLearn was able to construct an initial solution which turned out to be very close to the optimal solution to the problem. Now let us repeat the code above, but a solver which does not apply any ML strategies. Note that our previously-defined component is not provided." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2ff391ed-e855-4228-aa09-a7641d8c2893", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:05:46.969575966Z", + "start_time": "2023-06-06T20:05:46.420803286Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0xb45c0594\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [0e+00, 0e+00]\n", + " RHS range [2e+08, 2e+08]\n", + "Presolve time: 0.00s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Found heuristic solution: objective 1.071463e+10\n", + "\n", + "Root relaxation: objective 1.021568e+10, 510 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 1.0216e+10 0 1 1.0715e+10 1.0216e+10 4.66% - 0s\n", + "H 0 0 1.025162e+10 1.0216e+10 0.35% - 0s\n", + " 0 0 1.0216e+10 0 1 1.0252e+10 1.0216e+10 0.35% - 0s\n", + "H 0 0 1.023090e+10 1.0216e+10 0.15% - 0s\n", + "H 0 0 1.022335e+10 1.0216e+10 0.07% - 0s\n", + "H 0 0 1.022281e+10 1.0216e+10 0.07% - 0s\n", + "H 0 0 1.021753e+10 1.0216e+10 0.02% - 0s\n", + "H 0 0 1.021752e+10 1.0216e+10 0.02% - 0s\n", + " 0 0 1.0216e+10 0 3 1.0218e+10 1.0216e+10 0.02% - 0s\n", + " 0 0 1.0216e+10 0 1 1.0218e+10 1.0216e+10 0.02% - 0s\n", + "H 0 0 1.021651e+10 1.0216e+10 0.01% - 0s\n", + "\n", + "Explored 1 nodes (764 simplex iterations) in 0.03 seconds (0.02 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 7: 1.02165e+10 1.02175e+10 1.02228e+10 ... 1.07146e+10\n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 1.021651058978e+10, best bound 1.021573363741e+10, gap 0.0076%\n", + "\n", + "User-callback calls 204, time in user-callback 0.00 sec\n" + ] + } + ], + "source": [ + "solver_baseline = LearningSolver(components=[])\n", + "solver_baseline.fit(train_data)\n", + "solver_baseline.optimize(test_data[1], build_uc_model);" + ] + }, + { + "cell_type": "markdown", + "id": "b6d37b88-9fcc-43ee-ac1e-2a7b1e51a266", + "metadata": {}, + "source": [ + "In the log above, the `MIP start` line is missing, and Gurobi had to start with a significantly inferior initial solution. The solver was still able to find the optimal solution at the end, but it required using its own internal heuristic procedures. In this example, because we solve very small optimization problems, there was almost no difference in terms of running time, but the difference can be significant for larger problems." + ] + }, + { + "cell_type": "markdown", + "id": "eec97f06", + "metadata": { + "tags": [] + }, + "source": [ + "## Accessing the solution\n", + "\n", + "In the example above, we used `LearningSolver.solve` together with data files to solve both the training and the test instances. The optimal solutions were saved to HDF5 files in the train/test folders, and could be retrieved by reading theses files, but that is not very convenient. In the following example, we show how to build and solve a JuMP model entirely in-memory, using our trained solver." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "67a6cd18", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:06:26.913448568Z", + "start_time": "2023-06-06T20:06:26.169047914Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: AMD Ryzen 9 7950X 16-Core Processor, instruction set [SSE2|AVX|AVX2|AVX512]\n", + "Thread count: 16 physical cores, 32 logical processors, using up to 32 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x974a7fba\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [0e+00, 0e+00]\n", + " RHS range [2e+08, 2e+08]\n", + "\n", + "User MIP start produced solution with objective 9.86729e+09 (0.00s)\n", + "User MIP start produced solution with objective 9.86675e+09 (0.00s)\n", + "User MIP start produced solution with objective 9.86654e+09 (0.01s)\n", + "User MIP start produced solution with objective 9.8661e+09 (0.01s)\n", + "Loaded user MIP start with objective 9.8661e+09\n", + "\n", + "Presolve time: 0.00s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "\n", + "Root relaxation: objective 9.865344e+09, 510 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 9.8653e+09 0 1 9.8661e+09 9.8653e+09 0.01% - 0s\n", + "\n", + "Explored 1 nodes (510 simplex iterations) in 0.02 seconds (0.01 work units)\n", + "Thread count was 32 (of 32 available processors)\n", + "\n", + "Solution count 4: 9.8661e+09 9.86654e+09 9.86675e+09 9.86729e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 9.866096485614e+09, best bound 9.865343669936e+09, gap 0.0076%\n", + "\n", + "User-callback calls 182, time in user-callback 0.00 sec\n", + "objective_value(model.inner) = 9.866096485613789e9\n" + ] + } + ], + "source": [ + "data = random_uc_data(samples=1, n=500)[1]\n", + "model = build_uc_model(data)\n", + "solver_ml.optimize(model)\n", + "@show objective_value(model.inner);" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Julia 1.9.0", + "language": "julia", + "name": "julia-1.9" + }, + "language_info": { + "file_extension": ".jl", + "mimetype": "application/julia", + "name": "julia", + "version": "1.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/tutorials/getting-started-pyomo.ipynb b/docs/tutorials/getting-started-pyomo.ipynb new file mode 100644 index 0000000..c3b46a7 --- /dev/null +++ b/docs/tutorials/getting-started-pyomo.ipynb @@ -0,0 +1,869 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6b8983b1", + "metadata": { + "tags": [] + }, + "source": [ + "# Getting started (Pyomo)\n", + "\n", + "## Introduction\n", + "\n", + "**MIPLearn** is an open source framework that uses machine learning (ML) to accelerate the performance of mixed-integer programming solvers (e.g. Gurobi, CPLEX, XPRESS). In this tutorial, we will:\n", + "\n", + "1. Install the Python/Pyomo version of MIPLearn\n", + "2. Model a simple optimization problem using Pyomo\n", + "3. Generate training data and train the ML models\n", + "4. Use the ML models together Gurobi to solve new instances\n", + "\n", + "
\n", + "Note\n", + " \n", + "The Python/Pyomo version of MIPLearn is currently only compatible with Pyomo persistent solvers (Gurobi, CPLEX and XPRESS). For broader solver compatibility, see the Julia/JuMP version of the package.\n", + "
\n", + "\n", + "
\n", + "Warning\n", + " \n", + "MIPLearn is still in early development stage. If run into any bugs or issues, please submit a bug report in our GitHub repository. Comments, suggestions and pull requests are also very welcome!\n", + " \n", + "
\n" + ] + }, + { + "cell_type": "markdown", + "id": "02f0a927", + "metadata": {}, + "source": [ + "## Installation\n", + "\n", + "MIPLearn is available in two versions:\n", + "\n", + "- Python version, compatible with the Pyomo and Gurobipy modeling languages,\n", + "- Julia version, compatible with the JuMP modeling language.\n", + "\n", + "In this tutorial, we will demonstrate how to use and install the Python/Pyomo version of the package. The first step is to install Python 3.8+ in your computer. See the [official Python website for more instructions](https://www.python.org/downloads/). After Python is installed, we proceed to install MIPLearn using `pip`:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cd8a69c1", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T19:57:33.202580815Z", + "start_time": "2023-06-06T19:57:33.198341886Z" + } + }, + "outputs": [], + "source": [ + "# !pip install MIPLearn==0.3.0" + ] + }, + { + "cell_type": "markdown", + "id": "e8274543", + "metadata": {}, + "source": [ + "In addition to MIPLearn itself, we will also install Gurobi 10.0, a state-of-the-art commercial MILP solver. This step also install a demo license for Gurobi, which should able to solve the small optimization problems in this tutorial. A license is required for solving larger-scale problems." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "dcc8756c", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T19:57:35.756831801Z", + "start_time": "2023-06-06T19:57:33.201767088Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: gurobipy<10.1,>=10 in /home/axavier/Software/anaconda3/envs/miplearn/lib/python3.8/site-packages (10.0.1)\n" + ] + } + ], + "source": [ + "!pip install 'gurobipy>=10,<10.1'" + ] + }, + { + "cell_type": "markdown", + "id": "a14e4550", + "metadata": {}, + "source": [ + "
\n", + " \n", + "Note\n", + " \n", + "In the code above, we install specific version of all packages to ensure that this tutorial keeps running in the future, even when newer (and possibly incompatible) versions of the packages are released. This is usually a recommended practice for all Python projects.\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "16b86823", + "metadata": {}, + "source": [ + "## Modeling a simple optimization problem\n", + "\n", + "To illustrate how can MIPLearn be used, we will model and solve a small optimization problem related to power systems optimization. The problem we discuss below is a simplification of the **unit commitment problem,** a practical optimization problem solved daily by electric grid operators around the world. \n", + "\n", + "Suppose that a utility company needs to decide which electrical generators should be online at each hour of the day, as well as how much power should each generator produce. More specifically, assume that the company owns $n$ generators, denoted by $g_1, \\ldots, g_n$. Each generator can either be online or offline. An online generator $g_i$ can produce between $p^\\text{min}_i$ to $p^\\text{max}_i$ megawatts of power, and it costs the company $c^\\text{fix}_i + c^\\text{var}_i y_i$, where $y_i$ is the amount of power produced. An offline generator produces nothing and costs nothing. The total amount of power to be produced needs to be exactly equal to the total demand $d$ (in megawatts).\n", + "\n", + "This simple problem can be modeled as a *mixed-integer linear optimization* problem as follows. For each generator $g_i$, let $x_i \\in \\{0,1\\}$ be a decision variable indicating whether $g_i$ is online, and let $y_i \\geq 0$ be a decision variable indicating how much power does $g_i$ produce. The problem is then given by:" + ] + }, + { + "cell_type": "markdown", + "id": "f12c3702", + "metadata": {}, + "source": [ + "$$\n", + "\\begin{align}\n", + "\\text{minimize } \\quad & \\sum_{i=1}^n \\left( c^\\text{fix}_i x_i + c^\\text{var}_i y_i \\right) \\\\\n", + "\\text{subject to } \\quad & y_i \\leq p^\\text{max}_i x_i & i=1,\\ldots,n \\\\\n", + "& y_i \\geq p^\\text{min}_i x_i & i=1,\\ldots,n \\\\\n", + "& \\sum_{i=1}^n y_i = d \\\\\n", + "& x_i \\in \\{0,1\\} & i=1,\\ldots,n \\\\\n", + "& y_i \\geq 0 & i=1,\\ldots,n\n", + "\\end{align}\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "be3989ed", + "metadata": {}, + "source": [ + "
\n", + "\n", + "Note\n", + "\n", + "We use a simplified version of the unit commitment problem in this tutorial just to make it easier to follow. MIPLearn can also handle realistic, large-scale versions of this problem.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "a5fd33f6", + "metadata": {}, + "source": [ + "Next, let us convert this abstract mathematical formulation into a concrete optimization model, using Python and Pyomo. We start by defining a data class `UnitCommitmentData`, which holds all the input data." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "22a67170-10b4-43d3-8708-014d91141e73", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:00:03.278853343Z", + "start_time": "2023-06-06T20:00:03.123324067Z" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "from dataclasses import dataclass\n", + "from typing import List\n", + "\n", + "import numpy as np\n", + "\n", + "\n", + "@dataclass\n", + "class UnitCommitmentData:\n", + " demand: float\n", + " pmin: List[float]\n", + " pmax: List[float]\n", + " cfix: List[float]\n", + " cvar: List[float]" + ] + }, + { + "cell_type": "markdown", + "id": "29f55efa-0751-465a-9b0a-a821d46a3d40", + "metadata": {}, + "source": [ + "Next, we write a `build_uc_model` function, which converts the input data into a concrete Pyomo model. The function accepts `UnitCommitmentData`, the data structure we previously defined, or the path to a compressed pickle file containing this data." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "2f67032f-0d74-4317-b45c-19da0ec859e9", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:00:45.890126754Z", + "start_time": "2023-06-06T20:00:45.637044282Z" + } + }, + "outputs": [], + "source": [ + "import pyomo.environ as pe\n", + "from typing import Union\n", + "from miplearn.io import read_pkl_gz\n", + "from miplearn.solvers.pyomo import PyomoModel\n", + "\n", + "\n", + "def build_uc_model(data: Union[str, UnitCommitmentData]) -> PyomoModel:\n", + " if isinstance(data, str):\n", + " data = read_pkl_gz(data)\n", + "\n", + " model = pe.ConcreteModel()\n", + " n = len(data.pmin)\n", + " model.x = pe.Var(range(n), domain=pe.Binary)\n", + " model.y = pe.Var(range(n), domain=pe.NonNegativeReals)\n", + " model.obj = pe.Objective(\n", + " expr=sum(\n", + " data.cfix[i] * model.x[i] + data.cvar[i] * model.y[i] for i in range(n)\n", + " )\n", + " )\n", + " model.eq_max_power = pe.ConstraintList()\n", + " model.eq_min_power = pe.ConstraintList()\n", + " for i in range(n):\n", + " model.eq_max_power.add(model.y[i] <= data.pmax[i] * model.x[i])\n", + " model.eq_min_power.add(model.y[i] >= data.pmin[i] * model.x[i])\n", + " model.eq_demand = pe.Constraint(\n", + " expr=sum(model.y[i] for i in range(n)) == data.demand,\n", + " )\n", + " return PyomoModel(model, \"gurobi_persistent\")" + ] + }, + { + "cell_type": "markdown", + "id": "c22714a3", + "metadata": {}, + "source": [ + "At this point, we can already use Pyomo and any mixed-integer linear programming solver to find optimal solutions to any instance of this problem. To illustrate this, let us solve a small instance with three generators:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2a896f47", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:01:10.993801745Z", + "start_time": "2023-06-06T20:01:10.887580927Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Restricted license - for non-production use only - expires 2024-10-28\n", + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 7 rows, 6 columns and 15 nonzeros\n", + "Model fingerprint: 0x15c7a953\n", + "Variable types: 3 continuous, 3 integer (3 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 7e+01]\n", + " Objective range [2e+00, 7e+02]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [1e+02, 1e+02]\n", + "Presolve removed 2 rows and 1 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 5 rows, 5 columns, 13 nonzeros\n", + "Variable types: 0 continuous, 5 integer (3 binary)\n", + "Found heuristic solution: objective 1400.0000000\n", + "\n", + "Root relaxation: objective 1.035000e+03, 3 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 1035.00000 0 1 1400.00000 1035.00000 26.1% - 0s\n", + " 0 0 1105.71429 0 1 1400.00000 1105.71429 21.0% - 0s\n", + "* 0 0 0 1320.0000000 1320.00000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (5 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 2: 1320 1400 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 1.320000000000e+03, best bound 1.320000000000e+03, gap 0.0000%\n", + "WARNING: Cannot get reduced costs for MIP.\n", + "WARNING: Cannot get duals for MIP.\n", + "obj = 1320.0\n", + "x = [-0.0, 1.0, 1.0]\n", + "y = [0.0, 60.0, 40.0]\n" + ] + } + ], + "source": [ + "model = build_uc_model(\n", + " UnitCommitmentData(\n", + " demand=100.0,\n", + " pmin=[10, 20, 30],\n", + " pmax=[50, 60, 70],\n", + " cfix=[700, 600, 500],\n", + " cvar=[1.5, 2.0, 2.5],\n", + " )\n", + ")\n", + "\n", + "model.optimize()\n", + "print(\"obj =\", model.inner.obj())\n", + "print(\"x =\", [model.inner.x[i].value for i in range(3)])\n", + "print(\"y =\", [model.inner.y[i].value for i in range(3)])" + ] + }, + { + "cell_type": "markdown", + "id": "41b03bbc", + "metadata": {}, + "source": [ + "Running the code above, we found that the optimal solution for our small problem instance costs \\$1320. It is achieve by keeping generators 2 and 3 online and producing, respectively, 60 MW and 40 MW of power." + ] + }, + { + "cell_type": "markdown", + "id": "01f576e1-1790-425e-9e5c-9fa07b6f4c26", + "metadata": {}, + "source": [ + "
\n", + " \n", + "Notes\n", + " \n", + "- In the example above, `PyomoModel` is just a thin wrapper around a standard Pyomo model. This wrapper allows MIPLearn to be solver- and modeling-language-agnostic. The wrapper provides only a few basic methods, such as `optimize`. For more control, and to query the solution, the original Pyomo model can be accessed through `model.inner`, as illustrated above. \n", + "- To use CPLEX or XPRESS, instead of Gurobi, replace `gurobi_persistent` by `cplex_persistent` or `xpress_persistent` in the `build_uc_model`. Note that only persistent Pyomo solvers are currently supported. Pull requests adding support for other types of solver are very welcome.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "cf60c1dd", + "metadata": {}, + "source": [ + "## Generating training data\n", + "\n", + "Although Gurobi could solve the small example above in a fraction of a second, it gets slower for larger and more complex versions of the problem. If this is a problem that needs to be solved frequently, as it is often the case in practice, it could make sense to spend some time upfront generating a **trained** solver, which can optimize new instances (similar to the ones it was trained on) faster.\n", + "\n", + "In the following, we will use MIPLearn to train machine learning models that is able to predict the optimal solution for instances that follow a given probability distribution, then it will provide this predicted solution to Gurobi as a warm start. Before we can train the model, we need to collect training data by solving a large number of instances. In real-world situations, we may construct these training instances based on historical data. In this tutorial, we will construct them using a random instance generator:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5eb09fab", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:02:27.324208900Z", + "start_time": "2023-06-06T20:02:26.990044230Z" + } + }, + "outputs": [], + "source": [ + "from scipy.stats import uniform\n", + "from typing import List\n", + "import random\n", + "\n", + "\n", + "def random_uc_data(samples: int, n: int, seed: int = 42) -> List[UnitCommitmentData]:\n", + " random.seed(seed)\n", + " np.random.seed(seed)\n", + " pmin = uniform(loc=100_000.0, scale=400_000.0).rvs(n)\n", + " pmax = pmin * uniform(loc=2.0, scale=2.5).rvs(n)\n", + " cfix = pmin * uniform(loc=100.0, scale=25.0).rvs(n)\n", + " cvar = uniform(loc=1.25, scale=0.25).rvs(n)\n", + " return [\n", + " UnitCommitmentData(\n", + " demand=pmax.sum() * uniform(loc=0.5, scale=0.25).rvs(),\n", + " pmin=pmin,\n", + " pmax=pmax,\n", + " cfix=cfix,\n", + " cvar=cvar,\n", + " )\n", + " for _ in range(samples)\n", + " ]" + ] + }, + { + "cell_type": "markdown", + "id": "3a03a7ac", + "metadata": {}, + "source": [ + "In this example, for simplicity, only the demands change from one instance to the next. We could also have randomized the costs, production limits or even the number of units. The more randomization we have in the training data, however, the more challenging it is for the machine learning models to learn solution patterns.\n", + "\n", + "Now we generate 500 instances of this problem, each one with 50 generators, and we use 450 of these instances for training. After generating the instances, we write them to individual files. MIPLearn uses files during the training process because, for large-scale optimization problems, it is often impractical to hold in memory the entire training data, as well as the concrete Pyomo models. Files also make it much easier to solve multiple instances simultaneously, potentially on multiple machines. The code below generates the files `uc/train/00000.pkl.gz`, `uc/train/00001.pkl.gz`, etc., which contain the input data in compressed (gzipped) pickle format." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6156752c", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:03:04.782830561Z", + "start_time": "2023-06-06T20:03:04.530421396Z" + } + }, + "outputs": [], + "source": [ + "from miplearn.io import write_pkl_gz\n", + "\n", + "data = random_uc_data(samples=500, n=500)\n", + "train_data = write_pkl_gz(data[0:450], \"uc/train\")\n", + "test_data = write_pkl_gz(data[450:500], \"uc/test\")" + ] + }, + { + "cell_type": "markdown", + "id": "b17af877", + "metadata": {}, + "source": [ + "Finally, we use `BasicCollector` to collect the optimal solutions and other useful training data for all training instances. The data is stored in HDF5 files `uc/train/00000.h5`, `uc/train/00001.h5`, etc. The optimization models are also exported to compressed MPS files `uc/train/00000.mps.gz`, `uc/train/00001.mps.gz`, etc." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "7623f002", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:03:35.571497019Z", + "start_time": "2023-06-06T20:03:25.804104036Z" + } + }, + "outputs": [], + "source": [ + "from miplearn.collectors.basic import BasicCollector\n", + "\n", + "bc = BasicCollector()\n", + "bc.collect(train_data, build_uc_model, n_jobs=4)" + ] + }, + { + "cell_type": "markdown", + "id": "c42b1be1-9723-4827-82d8-974afa51ef9f", + "metadata": {}, + "source": [ + "## Training and solving test instances" + ] + }, + { + "cell_type": "markdown", + "id": "a33c6aa4-f0b8-4ccb-9935-01f7d7de2a1c", + "metadata": {}, + "source": [ + "With training data in hand, we can now design and train a machine learning model to accelerate solver performance. In this tutorial, for illustration purposes, we will use ML to generate a good warm start using $k$-nearest neighbors. More specifically, the strategy is to:\n", + "\n", + "1. Memorize the optimal solutions of all training instances;\n", + "2. Given a test instance, find the 25 most similar training instances, based on constraint right-hand sides;\n", + "3. Merge their optimal solutions into a single partial solution; specifically, only assign values to the binary variables that agree unanimously.\n", + "4. Provide this partial solution to the solver as a warm start.\n", + "\n", + "This simple strategy can be implemented as shown below, using `MemorizingPrimalComponent`. For more advanced strategies, and for the usage of more advanced classifiers, see the user guide." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "435f7bf8-4b09-4889-b1ec-b7b56e7d8ed2", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:05:20.497772794Z", + "start_time": "2023-06-06T20:05:20.484821405Z" + } + }, + "outputs": [], + "source": [ + "from sklearn.neighbors import KNeighborsClassifier\n", + "from miplearn.components.primal.actions import SetWarmStart\n", + "from miplearn.components.primal.mem import (\n", + " MemorizingPrimalComponent,\n", + " MergeTopSolutions,\n", + ")\n", + "from miplearn.extractors.fields import H5FieldsExtractor\n", + "\n", + "comp = MemorizingPrimalComponent(\n", + " clf=KNeighborsClassifier(n_neighbors=25),\n", + " extractor=H5FieldsExtractor(\n", + " instance_fields=[\"static_constr_rhs\"],\n", + " ),\n", + " constructor=MergeTopSolutions(25, [0.0, 1.0]),\n", + " action=SetWarmStart(),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9536e7e4-0b0d-49b0-bebd-4a848f839e94", + "metadata": {}, + "source": [ + "Having defined the ML strategy, we next construct `LearningSolver`, train the ML component and optimize one of the test instances." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "9d13dd50-3dcf-4673-a757-6f44dcc0dedf", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:05:22.672002339Z", + "start_time": "2023-06-06T20:05:21.447466634Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x5e67c6ee\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve removed 1000 rows and 500 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 1 rows, 500 columns, 500 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.6166537e+09 5.648803e+04 0.000000e+00 0s\n", + " 1 8.2906219e+09 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", + "Optimal objective 8.290621916e+09\n", + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0xa4a7961e\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "\n", + "User MIP start produced solution with objective 8.30129e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.29184e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.29146e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.29146e+09 (0.02s)\n", + "Loaded user MIP start with objective 8.29146e+09\n", + "\n", + "Presolve time: 0.01s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "\n", + "Root relaxation: objective 8.290622e+09, 512 iterations, 0.01 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 8.2906e+09 0 1 8.2915e+09 8.2906e+09 0.01% - 0s\n", + "\n", + "Cutting planes:\n", + " Cover: 1\n", + " Flow cover: 2\n", + "\n", + "Explored 1 nodes (512 simplex iterations) in 0.09 seconds (0.01 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 3: 8.29146e+09 8.29184e+09 8.30129e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 8.291459497797e+09, best bound 8.290645029670e+09, gap 0.0098%\n", + "WARNING: Cannot get reduced costs for MIP.\n", + "WARNING: Cannot get duals for MIP.\n" + ] + } + ], + "source": [ + "from miplearn.solvers.learning import LearningSolver\n", + "\n", + "solver_ml = LearningSolver(components=[comp])\n", + "solver_ml.fit(train_data)\n", + "solver_ml.optimize(test_data[0], build_uc_model);" + ] + }, + { + "cell_type": "markdown", + "id": "61da6dad-7f56-4edb-aa26-c00eb5f946c0", + "metadata": {}, + "source": [ + "By examining the solve log above, specifically the line `Loaded user MIP start with objective...`, we can see that MIPLearn was able to construct an initial solution which turned out to be very close to the optimal solution to the problem. Now let us repeat the code above, but a solver which does not apply any ML strategies. Note that our previously-defined component is not provided." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "2ff391ed-e855-4228-aa09-a7641d8c2893", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:05:46.969575966Z", + "start_time": "2023-06-06T20:05:46.420803286Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x5e67c6ee\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve removed 1000 rows and 500 columns\n", + "Presolve time: 0.01s\n", + "Presolved: 1 rows, 500 columns, 500 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.6166537e+09 5.648803e+04 0.000000e+00 0s\n", + " 1 8.2906219e+09 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", + "Optimal objective 8.290621916e+09\n", + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x8a0f9587\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve time: 0.00s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Found heuristic solution: objective 9.757128e+09\n", + "\n", + "Root relaxation: objective 8.290622e+09, 512 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 8.2906e+09 0 1 9.7571e+09 8.2906e+09 15.0% - 0s\n", + "H 0 0 8.298273e+09 8.2906e+09 0.09% - 0s\n", + " 0 0 8.2907e+09 0 4 8.2983e+09 8.2907e+09 0.09% - 0s\n", + " 0 0 8.2907e+09 0 1 8.2983e+09 8.2907e+09 0.09% - 0s\n", + " 0 0 8.2907e+09 0 4 8.2983e+09 8.2907e+09 0.09% - 0s\n", + "H 0 0 8.293980e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2907e+09 0 5 8.2940e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2907e+09 0 1 8.2940e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2907e+09 0 2 8.2940e+09 8.2907e+09 0.04% - 0s\n", + " 0 0 8.2908e+09 0 1 8.2940e+09 8.2908e+09 0.04% - 0s\n", + " 0 0 8.2908e+09 0 4 8.2940e+09 8.2908e+09 0.04% - 0s\n", + " 0 0 8.2908e+09 0 4 8.2940e+09 8.2908e+09 0.04% - 0s\n", + "H 0 0 8.291465e+09 8.2908e+09 0.01% - 0s\n", + "\n", + "Cutting planes:\n", + " Gomory: 2\n", + " MIR: 1\n", + "\n", + "Explored 1 nodes (1025 simplex iterations) in 0.08 seconds (0.03 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 4: 8.29147e+09 8.29398e+09 8.29827e+09 9.75713e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 8.291465302389e+09, best bound 8.290781665333e+09, gap 0.0082%\n", + "WARNING: Cannot get reduced costs for MIP.\n", + "WARNING: Cannot get duals for MIP.\n" + ] + } + ], + "source": [ + "solver_baseline = LearningSolver(components=[])\n", + "solver_baseline.fit(train_data)\n", + "solver_baseline.optimize(test_data[0], build_uc_model);" + ] + }, + { + "cell_type": "markdown", + "id": "b6d37b88-9fcc-43ee-ac1e-2a7b1e51a266", + "metadata": {}, + "source": [ + "In the log above, the `MIP start` line is missing, and Gurobi had to start with a significantly inferior initial solution. The solver was still able to find the optimal solution at the end, but it required using its own internal heuristic procedures. In this example, because we solve very small optimization problems, there was almost no difference in terms of running time, but the difference can be significant for larger problems." + ] + }, + { + "cell_type": "markdown", + "id": "eec97f06", + "metadata": { + "tags": [] + }, + "source": [ + "## Accessing the solution\n", + "\n", + "In the example above, we used `LearningSolver.solve` together with data files to solve both the training and the test instances. The optimal solutions were saved to HDF5 files in the train/test folders, and could be retrieved by reading theses files, but that is not very convenient. In the following example, we show how to build and solve a Pyomo model entirely in-memory, using our trained solver." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "67a6cd18", + "metadata": { + "ExecuteTime": { + "end_time": "2023-06-06T20:06:26.913448568Z", + "start_time": "2023-06-06T20:06:26.169047914Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x2dfe4e1c\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "Presolve removed 1000 rows and 500 columns\n", + "Presolve time: 0.01s\n", + "Presolved: 1 rows, 500 columns, 500 nonzeros\n", + "\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 6.5917580e+09 5.627453e+04 0.000000e+00 0s\n", + " 1 8.2535968e+09 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 1 iterations and 0.01 seconds (0.00 work units)\n", + "Optimal objective 8.253596777e+09\n", + "Set parameter QCPDual to value 1\n", + "Gurobi Optimizer version 10.0.1 build v10.0.1rc0 (linux64)\n", + "\n", + "CPU model: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, instruction set [SSE2|AVX|AVX2]\n", + "Thread count: 6 physical cores, 12 logical processors, using up to 12 threads\n", + "\n", + "Optimize a model with 1001 rows, 1000 columns and 2500 nonzeros\n", + "Model fingerprint: 0x20637200\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+06]\n", + " Objective range [1e+00, 6e+07]\n", + " Bounds range [1e+00, 1e+00]\n", + " RHS range [3e+08, 3e+08]\n", + "\n", + "User MIP start produced solution with objective 8.25814e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.25512e+09 (0.01s)\n", + "User MIP start produced solution with objective 8.25459e+09 (0.04s)\n", + "User MIP start produced solution with objective 8.25459e+09 (0.04s)\n", + "Loaded user MIP start with objective 8.25459e+09\n", + "\n", + "Presolve time: 0.01s\n", + "Presolved: 1001 rows, 1000 columns, 2500 nonzeros\n", + "Variable types: 500 continuous, 500 integer (500 binary)\n", + "\n", + "Root relaxation: objective 8.253597e+09, 512 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 8.2536e+09 0 1 8.2546e+09 8.2536e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 3 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 1 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 4 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2537e+09 0 4 8.2546e+09 8.2537e+09 0.01% - 0s\n", + " 0 0 8.2538e+09 0 4 8.2546e+09 8.2538e+09 0.01% - 0s\n", + " 0 0 8.2538e+09 0 5 8.2546e+09 8.2538e+09 0.01% - 0s\n", + " 0 0 8.2538e+09 0 6 8.2546e+09 8.2538e+09 0.01% - 0s\n", + "\n", + "Cutting planes:\n", + " Cover: 1\n", + " MIR: 2\n", + " StrongCG: 1\n", + " Flow cover: 1\n", + "\n", + "Explored 1 nodes (575 simplex iterations) in 0.11 seconds (0.01 work units)\n", + "Thread count was 12 (of 12 available processors)\n", + "\n", + "Solution count 3: 8.25459e+09 8.25512e+09 8.25814e+09 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 8.254590409970e+09, best bound 8.253768093811e+09, gap 0.0100%\n", + "WARNING: Cannot get reduced costs for MIP.\n", + "WARNING: Cannot get duals for MIP.\n", + "obj = 8254590409.96973\n", + " x = [1.0, 1.0, 0.0, 1.0, 1.0]\n", + " y = [935662.0949263407, 1604270.0218116897, 0.0, 1369560.835229226, 602828.5321028307]\n" + ] + } + ], + "source": [ + "data = random_uc_data(samples=1, n=500)[0]\n", + "model = build_uc_model(data)\n", + "solver_ml.optimize(model)\n", + "print(\"obj =\", model.inner.obj())\n", + "print(\" x =\", [model.inner.x[i].value for i in range(5)])\n", + "print(\" y =\", [model.inner.y[i].value for i in range(5)])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5593d23a-83bd-4e16-8253-6300f5e3f63b", + "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.8.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/miplearn/__init__.py b/miplearn/__init__.py index 60d6ca5..4932a43 100644 --- a/miplearn/__init__.py +++ b/miplearn/__init__.py @@ -1,31 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - -from .benchmark import BenchmarkRunner -from .classifiers import Classifier, Regressor -from .classifiers.adaptive import AdaptiveClassifier -from .classifiers.sklearn import ScikitLearnRegressor, ScikitLearnClassifier -from .classifiers.threshold import MinPrecisionThreshold -from .components.component import Component -from .components.dynamic_lazy import DynamicLazyConstraintsComponent -from .components.dynamic_user_cuts import UserCutsComponent -from .components.objective import ObjectiveValueComponent -from .components.primal import PrimalSolutionComponent -from .components.static_lazy import StaticLazyConstraintsComponent -from .instance.base import Instance -from .instance.picklegz import ( - PickleGzInstance, - write_pickle_gz, - read_pickle_gz, - write_pickle_gz_multiple, - save, - load, -) -from .log import setup_logger -from .solvers.gurobi import GurobiSolver -from .solvers.internal import InternalSolver -from .solvers.learning import LearningSolver -from .solvers.pyomo.base import BasePyomoSolver -from .solvers.pyomo.cplex import CplexPyomoSolver -from .solvers.pyomo.gurobi import GurobiPyomoSolver diff --git a/miplearn/benchmark.py b/miplearn/benchmark.py deleted file mode 100644 index aa39f0d..0000000 --- a/miplearn/benchmark.py +++ /dev/null @@ -1,264 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -import os -from typing import Dict, List, Any, Optional, Callable - -import pandas as pd - -from miplearn.components.component import Component -from miplearn.instance.base import Instance -from miplearn.solvers.learning import LearningSolver, FileInstanceWrapper -from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver -from sklearn.utils._testing import ignore_warnings -from sklearn.exceptions import ConvergenceWarning - - -logger = logging.getLogger(__name__) - - -class BenchmarkRunner: - """ - Utility class that simplifies the task of comparing the performance of different - solvers. - - Parameters - ---------- - solvers: Dict[str, LearningSolver] - Dictionary containing the solvers to compare. Solvers may have different - arguments and components. The key should be the name of the solver. It - appears in the exported tables of results. - """ - - def __init__(self, solvers: Dict[str, LearningSolver]) -> None: - self.solvers: Dict[str, LearningSolver] = solvers - self.results = pd.DataFrame( - columns=[ - "Solver", - "Instance", - ] - ) - - def parallel_solve( - self, - filenames: List[str], - build_model: Callable, - n_jobs: int = 1, - n_trials: int = 1, - progress: bool = False, - ) -> None: - self._silence_miplearn_logger() - trials = filenames * n_trials - for (solver_name, solver) in self.solvers.items(): - results = solver.parallel_solve( - trials, - build_model, - n_jobs=n_jobs, - label="benchmark (%s)" % solver_name, - progress=progress, - ) - for i in range(len(trials)): - idx = i % len(filenames) - results[i]["Solver"] = solver_name - results[i]["Instance"] = idx - self.results = self.results.append(pd.DataFrame([results[i]])) - self._restore_miplearn_logger() - - def write_csv(self, filename: str) -> None: - """ - Writes the collected results to a CSV file. - - Parameters - ---------- - filename: str - The name of the file. - """ - os.makedirs(os.path.dirname(filename), exist_ok=True) - self.results.to_csv(filename) - - def fit( - self, - filenames: List[str], - build_model: Callable, - progress: bool = False, - n_jobs: int = 1, - ) -> None: - components = [] - instances: List[Instance] = [ - FileInstanceWrapper(f, build_model, mode="r") for f in filenames - ] - for (solver_name, solver) in self.solvers.items(): - if solver_name == "baseline": - continue - components += solver.components.values() - Component.fit_multiple( - components, - instances, - n_jobs=n_jobs, - progress=progress, - ) - - def _silence_miplearn_logger(self) -> None: - miplearn_logger = logging.getLogger("miplearn") - self.prev_log_level = miplearn_logger.getEffectiveLevel() - miplearn_logger.setLevel(logging.WARNING) - - def _restore_miplearn_logger(self) -> None: - miplearn_logger = logging.getLogger("miplearn") - miplearn_logger.setLevel(self.prev_log_level) - - def write_svg( - self, - output: Optional[str] = None, - ) -> None: - import matplotlib.pyplot as plt - import pandas as pd - import seaborn as sns - - sns.set_style("whitegrid") - sns.set_palette("Blues_r") - groups = self.results.groupby("Instance") - best_lower_bound = groups["mip_lower_bound"].transform("max") - best_upper_bound = groups["mip_upper_bound"].transform("min") - self.results["Relative lower bound"] = self.results["mip_lower_bound"] / best_lower_bound - self.results["Relative upper bound"] = self.results["mip_upper_bound"] / best_upper_bound - - if (self.results["mip_sense"] == "min").any(): - primal_column = "Relative upper bound" - obj_column = "mip_upper_bound" - predicted_obj_column = "Objective: Predicted upper bound" - else: - primal_column = "Relative lower bound" - obj_column = "mip_lower_bound" - predicted_obj_column = "Objective: Predicted lower bound" - - palette = { - "baseline": "#9b59b6", - "ml-exact": "#3498db", - "ml-heuristic": "#95a5a6", - } - fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots( - nrows=2, - ncols=2, - figsize=(8, 8), - ) - - # Wallclock time - sns.stripplot( - x="Solver", - y="mip_wallclock_time", - data=self.results, - ax=ax1, - jitter=0.25, - palette=palette, - size=2.0, - ) - sns.barplot( - x="Solver", - y="mip_wallclock_time", - data=self.results, - ax=ax1, - errwidth=0.0, - alpha=0.4, - palette=palette, - ) - ax1.set(ylabel="Wallclock time (s)") - - # Gap - sns.stripplot( - x="Solver", - y="Gap", - jitter=0.25, - data=self.results[self.results["Solver"] != "ml-heuristic"], - ax=ax2, - palette=palette, - size=2.0, - ) - ax2.set(ylabel="Relative MIP gap") - - # Relative primal bound - sns.stripplot( - x="Solver", - y=primal_column, - jitter=0.25, - data=self.results[self.results["Solver"] == "ml-heuristic"], - ax=ax3, - palette=palette, - size=2.0, - ) - sns.scatterplot( - x=obj_column, - y=predicted_obj_column, - hue="Solver", - data=self.results[self.results["Solver"] == "ml-exact"], - ax=ax4, - palette=palette, - size=2.0, - ) - - # Predicted vs actual primal bound - xlim, ylim = ax4.get_xlim(), ax4.get_ylim() - ax4.plot( - [-1e10, 1e10], - [-1e10, 1e10], - ls="-", - color="#cccccc", - ) - ax4.set_xlim(xlim) - ax4.set_ylim(xlim) - ax4.get_legend().remove() - ax4.set( - ylabel="Predicted optimal value", - xlabel="Actual optimal value", - ) - - fig.tight_layout() - plt.savefig(output) - - -@ignore_warnings(category=ConvergenceWarning) -def run_benchmarks( - train_instances: List[Instance], - test_instances: List[Instance], - n_jobs: int = 4, - n_trials: int = 1, - progress: bool = False, - solver: Any = None, -) -> None: - if solver is None: - solver = GurobiPyomoSolver() - benchmark = BenchmarkRunner( - solvers={ - "baseline": LearningSolver( - solver=solver.clone(), - ), - "ml-exact": LearningSolver( - solver=solver.clone(), - ), - "ml-heuristic": LearningSolver( - solver=solver.clone(), - mode="heuristic", - ), - } - ) - benchmark.solvers["baseline"].parallel_solve( - train_instances, - n_jobs=n_jobs, - progress=progress, - ) - benchmark.fit( - train_instances, - n_jobs=n_jobs, - progress=progress, - ) - benchmark.parallel_solve( - test_instances, - n_jobs=n_jobs, - n_trials=n_trials, - progress=progress, - ) - plot(benchmark.results) - - diff --git a/miplearn/classifiers/__init__.py b/miplearn/classifiers/__init__.py index 6625c05..4932a43 100644 --- a/miplearn/classifiers/__init__.py +++ b/miplearn/classifiers/__init__.py @@ -1,163 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - -from abc import ABC, abstractmethod -from typing import Optional - -import numpy as np - - -class Classifier(ABC): - """ - A Classifier decides which class each sample belongs to, based on historical - data. - """ - - def __init__(self) -> None: - self.n_features: Optional[int] = None - self.n_classes: Optional[int] = None - - @abstractmethod - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - """ - Trains the classifier. - - Parameters - ---------- - x_train: np.ndarray - An array of features with shape (`n_samples`, `n_features`). Each entry - must be a float. - y_train: np.ndarray - An array of labels with shape (`n_samples`, `n_classes`). Each entry must be - a bool, and there must be exactly one True element in each row. - """ - assert isinstance(x_train, np.ndarray) - assert isinstance(y_train, np.ndarray) - assert x_train.dtype in [ - np.float16, - np.float32, - np.float64, - ], f"x_train.dtype should be float. Found {x_train.dtype} instead." - assert y_train.dtype == np.bool8 - assert len(x_train.shape) == 2 - assert len(y_train.shape) == 2 - (n_samples_x, n_features) = x_train.shape - (n_samples_y, n_classes) = y_train.shape - assert n_samples_y == n_samples_x - self.n_features = n_features - self.n_classes = n_classes - - @abstractmethod - def predict_proba(self, x_test: np.ndarray) -> np.ndarray: - """ - Predicts the probability of each sample belonging to each class. Must be called - after fit. - - Parameters - ---------- - x_test: np.ndarray - An array of features with shape (`n_samples`, `n_features`). The number of - features in `x_test` must match the number of features in `x_train` provided - to `fit`. - - Returns - ------- - np.ndarray - An array of predicted probabilities with shape (`n_samples`, `n_classes`), - where `n_classes` is the number of columns in `y_train` provided to `fit`. - """ - assert self.n_features is not None - assert isinstance(x_test, np.ndarray) - assert len(x_test.shape) == 2 - (n_samples, n_features_x) = x_test.shape - assert n_features_x == self.n_features, ( - f"Test and training data have different number of " - f"features: {n_features_x} != {self.n_features}" - ) - return np.ndarray([]) - - @abstractmethod - def clone(self) -> "Classifier": - """ - Returns an unfitted copy of this classifier with the same hyperparameters. - """ - pass - - -class Regressor(ABC): - """ - A Regressor tries to predict the values of some continous variables, given the - values of other variables. - """ - - def __init__(self) -> None: - self.n_inputs: Optional[int] = None - - @abstractmethod - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - """ - Trains the regressor. - - Parameters - ---------- - x_train: np.ndarray - An array of inputs with shape (`n_samples`, `n_inputs`). Each entry must be - a float. - y_train: np.ndarray - An array of outputs with shape (`n_samples`, `n_outputs`). Each entry must - be a float. - """ - assert isinstance(x_train, np.ndarray) - assert isinstance(y_train, np.ndarray) - assert x_train.dtype in [np.float16, np.float32, np.float64] - assert y_train.dtype in [np.float16, np.float32, np.float64] - assert len(x_train.shape) == 2, ( - f"Parameter x_train should be a square matrix. " - f"Found {x_train.shape} ndarray instead." - ) - assert len(y_train.shape) == 2, ( - f"Parameter y_train should be a square matrix. " - f"Found {y_train.shape} ndarray instead." - ) - (n_samples_x, n_inputs) = x_train.shape - (n_samples_y, n_outputs) = y_train.shape - assert n_samples_y == n_samples_x - self.n_inputs = n_inputs - - @abstractmethod - def predict(self, x_test: np.ndarray) -> np.ndarray: - """ - Predicts the values of the output variables. Must be called after fit. - - Parameters - ---------- - x_test: np.ndarray - An array of inputs with shape (`n_samples`, `n_inputs`), where `n_inputs` - must match the number of columns in `x_train` provided to `fit`. - - Returns - ------- - np.ndarray - An array of outputs with shape (`n_samples`, `n_outputs`), where - `n_outputs` is the number of columns in `y_train` provided to `fit`. - """ - assert self.n_inputs is not None - assert isinstance(x_test, np.ndarray), ( - f"Parameter x_train must be np.ndarray. " - f"Found {x_test.__class__.__name__} instead." - ) - assert len(x_test.shape) == 2 - (n_samples, n_inputs_x) = x_test.shape - assert n_inputs_x == self.n_inputs, ( - f"Test and training data have different number of " - f"inputs: {n_inputs_x} != {self.n_inputs}" - ) - return np.ndarray([]) - - @abstractmethod - def clone(self) -> "Regressor": - """ - Returns an unfitted copy of this regressor with the same hyperparameters. - """ - pass diff --git a/miplearn/classifiers/adaptive.py b/miplearn/classifiers/adaptive.py deleted file mode 100644 index c5437f0..0000000 --- a/miplearn/classifiers/adaptive.py +++ /dev/null @@ -1,135 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import Dict, Optional - -import numpy as np -from sklearn.ensemble import RandomForestClassifier -from sklearn.linear_model import LogisticRegression -from sklearn.metrics import roc_auc_score -from sklearn.model_selection import cross_val_predict -from sklearn.neighbors import KNeighborsClassifier -from sklearn.pipeline import make_pipeline -from sklearn.preprocessing import StandardScaler - -from miplearn.classifiers import Classifier -from miplearn.classifiers.counting import CountingClassifier -from miplearn.classifiers.sklearn import ScikitLearnClassifier - -logger = logging.getLogger(__name__) - - -class CandidateClassifierSpecs: - """ - Specifications describing how to construct a certain classifier, and under - which circumstances it can be used. - - Parameters - ---------- - min_samples: int - Minimum number of samples for this classifier to be considered. - classifier: Callable[[], Classifier] - Callable that constructs the classifier. - """ - - def __init__( - self, - classifier: Classifier, - min_samples: int = 0, - ) -> None: - self.min_samples = min_samples - self.classifier = classifier - - -class AdaptiveClassifier(Classifier): - """ - A meta-classifier which dynamically selects what actual classifier to use - based on its cross-validation score on a particular training data set. - - Parameters - ---------- - candidates: Dict[str, CandidateClassifierSpecs] - A dictionary of candidate classifiers to consider, mapping the name of the - candidate to its specs, which describes how to construct it and under what - scenarios. If no candidates are provided, uses a fixed set of defaults, - which includes `CountingClassifier`, `KNeighborsClassifier` and - `LogisticRegression`. - """ - - def __init__( - self, - candidates: Optional[Dict[str, CandidateClassifierSpecs]] = None, - ) -> None: - super().__init__() - if candidates is None: - candidates = { - "forest(5,10)": CandidateClassifierSpecs( - classifier=ScikitLearnClassifier( - RandomForestClassifier( - n_estimators=5, - min_samples_split=10, - ), - ), - min_samples=100, - ), - "knn(100)": CandidateClassifierSpecs( - classifier=ScikitLearnClassifier( - KNeighborsClassifier(n_neighbors=100) - ), - min_samples=100, - ), - "logistic": CandidateClassifierSpecs( - classifier=ScikitLearnClassifier( - make_pipeline( - StandardScaler(), - LogisticRegression(), - ) - ), - min_samples=30, - ), - "counting": CandidateClassifierSpecs( - classifier=CountingClassifier(), - ), - } - self.candidates = candidates - self.classifier: Optional[Classifier] = None - - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - super().fit(x_train, y_train) - n_samples = x_train.shape[0] - assert y_train.shape == (n_samples, 2) - - # If almost all samples belong to the same class, return a fixed prediction and - # skip all the other steps. - if y_train[:, 0].mean() > 0.99 or y_train[:, 1].mean() > 0.99: - self.classifier = CountingClassifier() - self.classifier.fit(x_train, y_train) - return - - best_name, best_clf, best_score = None, None, -float("inf") - for (name, specs) in self.candidates.items(): - if n_samples < specs.min_samples: - continue - clf = specs.classifier.clone() - if isinstance(clf, ScikitLearnClassifier): - proba = cross_val_predict(clf.inner_clf, x_train, y_train[:, 1]) - else: - clf.fit(x_train, y_train) - proba = clf.predict_proba(x_train)[:, 1] - score = roc_auc_score(y_train[:, 1], proba) - if score > best_score: - best_name, best_clf, best_score = name, clf, score - logger.debug("Best classifier: %s (score=%.3f)" % (best_name, best_score)) - if isinstance(best_clf, ScikitLearnClassifier): - best_clf.fit(x_train, y_train) - self.classifier = best_clf - - def predict_proba(self, x_test: np.ndarray) -> np.ndarray: - super().predict_proba(x_test) - assert self.classifier is not None - return self.classifier.predict_proba(x_test) - - def clone(self) -> "AdaptiveClassifier": - return AdaptiveClassifier(self.candidates) diff --git a/miplearn/classifiers/counting.py b/miplearn/classifiers/counting.py deleted file mode 100644 index e7ec33c..0000000 --- a/miplearn/classifiers/counting.py +++ /dev/null @@ -1,45 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from typing import Optional, cast - -import numpy as np - -from miplearn.classifiers import Classifier - - -class CountingClassifier(Classifier): - """ - - A classifier that generates constant predictions, based only on the frequency of - the training labels. For example, suppose `y_train` is given by: - ```python - y_train = np.array([ - [True, False], - [False, True], - [False, True], - ]) - ``` - Then `predict_proba` always returns `[0.33 0.66]` for every sample, regardless of - `x_train`. It essentially counts how many times each label appeared, hence the name. - - """ - - def __init__(self) -> None: - super().__init__() - self.mean: Optional[np.ndarray] = None - - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - super().fit(x_train, y_train) - self.mean = cast(np.ndarray, np.mean(y_train, axis=0)) - - def predict_proba(self, x_test: np.ndarray) -> np.ndarray: - super().predict_proba(x_test) - n_samples = x_test.shape[0] - return np.array([self.mean for _ in range(n_samples)]) - - def __repr__(self) -> str: - return "CountingClassifier(mean=%s)" % self.mean - - def clone(self) -> "CountingClassifier": - return CountingClassifier() diff --git a/miplearn/classifiers/cv.py b/miplearn/classifiers/cv.py deleted file mode 100644 index 9c5d210..0000000 --- a/miplearn/classifiers/cv.py +++ /dev/null @@ -1,132 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import Optional, List - -import numpy as np -from sklearn.dummy import DummyClassifier -from sklearn.linear_model import LogisticRegression -from sklearn.model_selection import cross_val_score - -from miplearn.classifiers import Classifier -from miplearn.classifiers.sklearn import ScikitLearnClassifier - -logger = logging.getLogger(__name__) - - -class CrossValidatedClassifier(Classifier): - """ - A meta-classifier that, upon training, evaluates the performance of another - candidate classifier on the training data set, using k-fold cross validation, - then either adopts it, if its cv-score is high enough, or returns constant - predictions for every x_test, otherwise. - - Parameters - ---------- - classifier: Callable[[], ScikitLearnClassifier] - A callable that constructs the candidate classifier. - threshold: float - Number from zero to one indicating how well must the candidate classifier - perform to be adopted. The threshold is specified in comparison to a dummy - classifier trained on the same dataset. For example, a threshold of 0.0 - indicates that any classifier as good as the dummy predictor is acceptable. A - threshold of 1.0 indicates that only classifiers with perfect - cross-validation scores are acceptable. Other numbers are a linear - interpolation of these two extremes. - constant: Optional[List[bool]] - If the candidate classifier fails to meet the threshold, use a dummy classifier - which always returns this prediction instead. The list should have exactly as - many elements as the number of columns of `x_train` provided to `fit`. - cv: int - Number of folds. - scoring: str - Scoring function. - """ - - def __init__( - self, - classifier: ScikitLearnClassifier = ScikitLearnClassifier(LogisticRegression()), - threshold: float = 0.75, - constant: Optional[List[bool]] = None, - cv: int = 5, - scoring: str = "accuracy", - ): - super().__init__() - if constant is None: - constant = [True, False] - self.n_classes = len(constant) - self.classifier: Optional[ScikitLearnClassifier] = None - self.classifier_prototype = classifier - self.constant: List[bool] = constant - self.threshold = threshold - self.cv = cv - self.scoring = scoring - - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - super().fit(x_train, y_train) - (n_samples, n_classes) = x_train.shape - assert n_classes == self.n_classes - - # Calculate dummy score and absolute score threshold - y_train_avg = np.average(y_train) - dummy_score = max(y_train_avg, 1 - y_train_avg) - absolute_threshold = 1.0 * self.threshold + dummy_score * (1 - self.threshold) - - # Calculate cross validation score and decide which classifier to use - clf = self.classifier_prototype.clone() - assert clf is not None - assert isinstance(clf, ScikitLearnClassifier), ( - f"The provided classifier callable must return a ScikitLearnClassifier. " - f"Found {clf.__class__.__name__} instead. If this is a scikit-learn " - f"classifier, you must wrap it with ScikitLearnClassifier." - ) - - cv_score = float( - np.mean( - cross_val_score( - clf.inner_clf, - x_train, - y_train[:, 1], - cv=self.cv, - scoring=self.scoring, - ) - ) - ) - if cv_score >= absolute_threshold: - logger.debug( - "cv_score is above threshold (%.2f >= %.2f); keeping" - % (cv_score, absolute_threshold) - ) - self.classifier = clf - else: - logger.debug( - "cv_score is below threshold (%.2f < %.2f); discarding" - % (cv_score, absolute_threshold) - ) - self.classifier = ScikitLearnClassifier( - DummyClassifier( - strategy="constant", - constant=self.constant[1], - ) - ) - - # Train chosen classifier - assert self.classifier is not None - assert isinstance(self.classifier, ScikitLearnClassifier) - self.classifier.fit(x_train, y_train) - - def predict_proba(self, x_test: np.ndarray) -> np.ndarray: - super().predict_proba(x_test) - assert self.classifier is not None - return self.classifier.predict_proba(x_test) - - def clone(self) -> "CrossValidatedClassifier": - return CrossValidatedClassifier( - classifier=self.classifier_prototype, - threshold=self.threshold, - constant=self.constant, - cv=self.cv, - scoring=self.scoring, - ) diff --git a/miplearn/classifiers/minprob.py b/miplearn/classifiers/minprob.py new file mode 100644 index 0000000..330d925 --- /dev/null +++ b/miplearn/classifiers/minprob.py @@ -0,0 +1,61 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import List, Any, Callable, Optional + +import numpy as np +import sklearn +from sklearn.base import BaseEstimator +from sklearn.utils.multiclass import unique_labels + + +class MinProbabilityClassifier(BaseEstimator): + """ + Meta-classifier that returns NaN for predictions made by a base classifier that + have probability below a given threshold. More specifically, this meta-classifier + calls base_clf.predict_proba and compares the result against the provided + thresholds. If the probability for one of the classes is above its threshold, + the meta-classifier returns that prediction. Otherwise, it returns NaN. + """ + + def __init__( + self, + base_clf: Any, + thresholds: List[float], + clone_fn: Callable[[Any], Any] = sklearn.base.clone, + ) -> None: + assert len(thresholds) == 2 + self.base_clf = base_clf + self.thresholds = thresholds + self.clone_fn = clone_fn + self.clf_: Optional[Any] = None + self.classes_: Optional[List[Any]] = None + + def fit(self, x: np.ndarray, y: np.ndarray) -> None: + assert len(y.shape) == 1 + assert len(x.shape) == 2 + classes = unique_labels(y) + assert len(classes) == len(self.thresholds) + + self.clf_ = self.clone_fn(self.base_clf) + self.clf_.fit(x, y) + self.classes_ = self.clf_.classes_ + + def predict(self, x: np.ndarray) -> np.ndarray: + assert self.clf_ is not None + assert self.classes_ is not None + + y_proba = self.clf_.predict_proba(x) + assert len(y_proba.shape) == 2 + assert y_proba.shape[0] == x.shape[0] + assert y_proba.shape[1] == 2 + n_samples = x.shape[0] + + y_pred = [] + for sample_idx in range(n_samples): + yi = float("nan") + for (class_idx, class_val) in enumerate(self.classes_): + if y_proba[sample_idx, class_idx] >= self.thresholds[class_idx]: + yi = class_val + y_pred.append(yi) + return np.array(y_pred) diff --git a/miplearn/classifiers/singleclass.py b/miplearn/classifiers/singleclass.py new file mode 100644 index 0000000..2477254 --- /dev/null +++ b/miplearn/classifiers/singleclass.py @@ -0,0 +1,51 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import Callable, Optional + +import numpy as np +import sklearn.base +from sklearn.base import BaseEstimator +from sklearn.utils.multiclass import unique_labels + + +class SingleClassFix(BaseEstimator): + """ + Some sklearn classifiers, such as logistic regression, have issues with datasets + that contain a single class. This meta-classifier fixes the issue. If the + training data contains a single class, this meta-classifier always returns that + class as a prediction. Otherwise, it fits the provided base classifier, + and returns its predictions instead. + """ + + def __init__( + self, + base_clf: BaseEstimator, + clone_fn: Callable = sklearn.base.clone, + ): + self.base_clf = base_clf + self.clf_: Optional[BaseEstimator] = None + self.constant_ = None + self.classes_ = None + self.clone_fn = clone_fn + + def fit(self, x: np.ndarray, y: np.ndarray) -> None: + classes = unique_labels(y) + if len(classes) == 1: + assert classes[0] is not None + self.clf_ = None + self.constant_ = classes[0] + self.classes_ = classes + else: + self.clf_ = self.clone_fn(self.base_clf) + assert self.clf_ is not None + self.clf_.fit(x, y) + self.constant_ = None + self.classes_ = self.clf_.classes_ + + def predict(self, x: np.ndarray) -> np.ndarray: + if self.constant_ is not None: + return np.full(x.shape[0], self.constant_) + else: + assert self.clf_ is not None + return self.clf_.predict(x) diff --git a/miplearn/classifiers/sklearn.py b/miplearn/classifiers/sklearn.py deleted file mode 100644 index 2ab93e9..0000000 --- a/miplearn/classifiers/sklearn.py +++ /dev/null @@ -1,93 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from typing import Optional, Any, cast - -import numpy as np -import sklearn - -from miplearn.classifiers import Classifier, Regressor - - -class ScikitLearnClassifier(Classifier): - """ - Wrapper for ScikitLearn classifiers, which makes sure inputs and outputs have the - correct dimensions and types. - """ - - def __init__(self, clf: Any) -> None: - super().__init__() - self.inner_clf = clf - self.constant: Optional[np.ndarray] = None - - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - super().fit(x_train, y_train) - (n_samples, n_classes) = y_train.shape - assert n_classes == 2, ( - f"Scikit-learn classifiers must have exactly two classes. " - f"{n_classes} classes were provided instead." - ) - - # When all samples belong to the same class, sklearn's predict_proba returns - # an array with a single column. The following check avoid this strange - # behavior. - mean = cast(np.ndarray, y_train.astype(float).mean(axis=0)) - if mean.max() == 1.0: - self.constant = mean - return - - self.inner_clf.fit(x_train, y_train[:, 1]) - - def predict_proba(self, x_test: np.ndarray) -> np.ndarray: - super().predict_proba(x_test) - n_samples = x_test.shape[0] - if self.constant is not None: - return np.array([self.constant for n in range(n_samples)]) - sklearn_proba = self.inner_clf.predict_proba(x_test) - if isinstance(sklearn_proba, list): - assert len(sklearn_proba) == self.n_classes - for pb in sklearn_proba: - assert isinstance(pb, np.ndarray) - assert pb.dtype in [np.float16, np.float32, np.float64] - assert pb.shape == (n_samples, 2) - proba = np.hstack([pb[:, [1]] for pb in sklearn_proba]) - assert proba.shape == (n_samples, self.n_classes) - return proba - else: - assert isinstance(sklearn_proba, np.ndarray) - assert sklearn_proba.shape == (n_samples, 2) - return sklearn_proba - - def clone(self) -> "ScikitLearnClassifier": - return ScikitLearnClassifier( - clf=sklearn.base.clone(self.inner_clf), - ) - - -class ScikitLearnRegressor(Regressor): - """ - Wrapper for ScikitLearn regressors, which makes sure inputs and outputs have the - correct dimensions and types. - """ - - def __init__(self, reg: Any) -> None: - super().__init__() - self.inner_reg = reg - - def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: - super().fit(x_train, y_train) - self.inner_reg.fit(x_train, y_train) - - def predict(self, x_test: np.ndarray) -> np.ndarray: - super().predict(x_test) - n_samples = x_test.shape[0] - sklearn_pred = self.inner_reg.predict(x_test) - assert isinstance(sklearn_pred, np.ndarray) - assert sklearn_pred.shape[0] == n_samples - return sklearn_pred - - def clone(self) -> "ScikitLearnRegressor": - return ScikitLearnRegressor( - reg=sklearn.base.clone(self.inner_reg), - ) diff --git a/miplearn/classifiers/threshold.py b/miplearn/classifiers/threshold.py deleted file mode 100644 index 0853947..0000000 --- a/miplearn/classifiers/threshold.py +++ /dev/null @@ -1,143 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from abc import abstractmethod, ABC -from typing import Optional, List - -import numpy as np -from sklearn.metrics._ranking import _binary_clf_curve -from sklearn.model_selection import cross_val_predict - -from miplearn.classifiers.sklearn import ScikitLearnClassifier -from miplearn.classifiers.adaptive import AdaptiveClassifier -from miplearn.classifiers import Classifier - - -class Threshold(ABC): - """ - Solver components ask the machine learning models how confident are they on each - prediction they make, then automatically discard all predictions that have low - confidence. A Threshold specifies how confident should the ML models be for a - prediction to be considered trustworthy. - - To model dynamic thresholds, which automatically adjust themselves during - training to reach some desired target (such as minimum precision, or minimum - recall), thresholds behave somewhat similar to ML models themselves, with `fit` - and `predict` methods. - """ - - @abstractmethod - def fit( - self, - clf: Classifier, - x_train: np.ndarray, - y_train: np.ndarray, - ) -> None: - """ - Given a trained binary classifier `clf`, calibrates itself based on the - classifier's performance on the given training data set. - """ - assert isinstance(clf, Classifier) - assert isinstance(x_train, np.ndarray) - assert isinstance(y_train, np.ndarray) - n_samples = x_train.shape[0] - assert y_train.shape[0] == n_samples - - @abstractmethod - def predict(self, x_test: np.ndarray) -> List[float]: - """ - Returns the minimum probability for a machine learning prediction to be - considered trustworthy. There is one value for each label. - """ - pass - - @abstractmethod - def clone(self) -> "Threshold": - """ - Returns an unfitted copy of this threshold with the same hyperparameters. - """ - pass - - -class MinProbabilityThreshold(Threshold): - """ - A threshold which considers predictions trustworthy if their probability of being - correct, as computed by the machine learning models, are above a fixed value. - """ - - def __init__(self, min_probability: List[float]): - self.min_probability = min_probability - - def fit(self, clf: Classifier, x_train: np.ndarray, y_train: np.ndarray) -> None: - pass - - def predict(self, x_test: np.ndarray) -> List[float]: - return self.min_probability - - def clone(self) -> "MinProbabilityThreshold": - return MinProbabilityThreshold(self.min_probability) - - -class MinPrecisionThreshold(Threshold): - """ - A dynamic threshold which automatically adjusts itself during training to ensure - that the component achieves at least a given precision `p` on the training data - set. Note that increasing a component's minimum precision may reduce its recall. - """ - - def __init__(self, min_precision: List[float]) -> None: - self.min_precision = min_precision - self._computed_threshold: Optional[List[float]] = None - - def fit( - self, - clf: Classifier, - x_train: np.ndarray, - y_train: np.ndarray, - ) -> None: - super().fit(clf, x_train, y_train) - (n_samples, n_classes) = y_train.shape - if isinstance(clf, AdaptiveClassifier) and isinstance( - clf.classifier, ScikitLearnClassifier - ): - proba = cross_val_predict( - clf.classifier.inner_clf, - x_train, - y_train[:, 1], - method="predict_proba", - ) - else: - proba = clf.predict_proba(x_train) - self._computed_threshold = [ - self._compute( - y_train[:, i], - proba[:, i], - self.min_precision[i], - ) - for i in range(n_classes) - ] - - def predict(self, x_test: np.ndarray) -> List[float]: - assert self._computed_threshold is not None - return self._computed_threshold - - @staticmethod - def _compute( - y_actual: np.ndarray, - y_prob: np.ndarray, - min_precision: float, - min_recall: float = 0.1, - ) -> float: - fps, tps, thresholds = _binary_clf_curve(y_actual, y_prob) - precision = tps / (tps + fps) - recall = tps / tps[-1] - for k in reversed(range(len(precision))): - if precision[k] >= min_precision and recall[k] >= min_recall: - return thresholds[k] - return float("inf") - - def clone(self) -> "MinPrecisionThreshold": - return MinPrecisionThreshold( - min_precision=self.min_precision, - ) diff --git a/tests/features/__init__.py b/miplearn/collectors/__init__.py similarity index 100% rename from tests/features/__init__.py rename to miplearn/collectors/__init__.py diff --git a/miplearn/collectors/basic.py b/miplearn/collectors/basic.py new file mode 100644 index 0000000..599b716 --- /dev/null +++ b/miplearn/collectors/basic.py @@ -0,0 +1,86 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import json +import os +from io import StringIO +from os.path import exists +from typing import Callable, List + +from ..h5 import H5File +from ..io import _RedirectOutput, gzip, _to_h5_filename +from ..parallel import p_umap + + +class BasicCollector: + def collect( + self, + filenames: List[str], + build_model: Callable, + n_jobs: int = 1, + progress: bool = False, + ) -> None: + def _collect(data_filename): + h5_filename = _to_h5_filename(data_filename) + mps_filename = h5_filename.replace(".h5", ".mps") + + if exists(h5_filename): + # Try to read optimal solution + mip_var_values = None + try: + with H5File(h5_filename, "r") as h5: + mip_var_values = h5.get_array("mip_var_values") + except: + pass + + if mip_var_values is None: + print(f"Removing empty/corrupted h5 file: {h5_filename}") + os.remove(h5_filename) + else: + return + + with H5File(h5_filename, "w") as h5: + streams = [StringIO()] + with _RedirectOutput(streams): + # Load and extract static features + model = build_model(data_filename) + model.extract_after_load(h5) + + # Solve LP relaxation + relaxed = model.relax() + relaxed.optimize() + relaxed.extract_after_lp(h5) + + # Solve MIP + model.optimize() + model.extract_after_mip(h5) + + # Add lazy constraints to model + if ( + hasattr(model, "fix_violations") + and model.fix_violations is not None + ): + model.fix_violations(model, model.violations_, "aot") + h5.put_scalar( + "mip_constr_violations", json.dumps(model.violations_) + ) + + # Save MPS file + model.write(mps_filename) + gzip(mps_filename) + + h5.put_scalar("mip_log", streams[0].getvalue()) + + if n_jobs > 1: + p_umap( + _collect, + filenames, + num_cpus=n_jobs, + desc="collect", + smoothing=0, + disable=not progress, + ) + else: + for filename in filenames: + _collect(filename) diff --git a/miplearn/collectors/lazy.py b/miplearn/collectors/lazy.py new file mode 100644 index 0000000..4222f4c --- /dev/null +++ b/miplearn/collectors/lazy.py @@ -0,0 +1,117 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from io import StringIO +from typing import Callable + +import gurobipy as gp +import numpy as np +from gurobipy import GRB, LinExpr + +from ..h5 import H5File +from ..io import _RedirectOutput + + +class LazyCollector: + def __init__( + self, + min_constrs: int = 100_000, + time_limit: float = 900, + ) -> None: + self.min_constrs = min_constrs + self.time_limit = time_limit + + def collect( + self, data_filename: str, build_model: Callable, tol: float = 1e-6 + ) -> None: + h5_filename = f"{data_filename}.h5" + with H5File(h5_filename, "r+") as h5: + streams = [StringIO()] + lazy = None + with _RedirectOutput(streams): + slacks = h5.get_array("mip_constr_slacks") + assert slacks is not None + + # Check minimum problem size + if len(slacks) < self.min_constrs: + print("Problem is too small. Skipping.") + h5.put_array("mip_constr_lazy", np.zeros(len(slacks))) + return + + # Load model + print("Loading model...") + model = build_model(data_filename) + model.params.LazyConstraints = True + model.params.timeLimit = self.time_limit + gp_constrs = np.array(model.getConstrs()) + gp_vars = np.array(model.getVars()) + + # Load constraints + lhs = h5.get_sparse("static_constr_lhs") + rhs = h5.get_array("static_constr_rhs") + sense = h5.get_array("static_constr_sense") + assert lhs is not None + assert rhs is not None + assert sense is not None + lhs_csr = lhs.tocsr() + lhs_csc = lhs.tocsc() + constr_idx = np.array(range(len(rhs))) + lazy = np.zeros(len(rhs)) + + # Drop loose constraints + selected = (slacks > 0) & ((sense == b"<") | (sense == b">")) + loose_constrs = gp_constrs[selected] + print( + f"Removing {len(loose_constrs):,d} constraints (out of {len(rhs):,d})..." + ) + model.remove(list(loose_constrs)) + + # Filter to constraints that were dropped + lhs_csr = lhs_csr[selected, :] + lhs_csc = lhs_csc[selected, :] + rhs = rhs[selected] + sense = sense[selected] + constr_idx = constr_idx[selected] + lazy[selected] = 1 + + # Load warm start + var_names = h5.get_array("static_var_names") + var_values = h5.get_array("mip_var_values") + assert var_values is not None + assert var_names is not None + for (var_idx, var_name) in enumerate(var_names): + var = model.getVarByName(var_name.decode()) + var.start = var_values[var_idx] + + print("Solving MIP with lazy constraints callback...") + + def callback(model: gp.Model, where: int) -> None: + assert rhs is not None + assert lazy is not None + assert sense is not None + + if where == GRB.Callback.MIPSOL: + x_val = np.array(model.cbGetSolution(model.getVars())) + slack = lhs_csc * x_val - rhs + slack[sense == b">"] *= -1 + is_violated = slack > tol + + for (j, rhs_j) in enumerate(rhs): + if is_violated[j]: + lazy[constr_idx[j]] = 0 + expr = LinExpr( + lhs_csr[j, :].data, gp_vars[lhs_csr[j, :].indices] + ) + if sense[j] == b"<": + model.cbLazy(expr <= rhs_j) + elif sense[j] == b">": + model.cbLazy(expr >= rhs_j) + else: + raise RuntimeError(f"Unknown sense: {sense[j]}") + + model.optimize(callback) + print(f"Marking {lazy.sum():,.0f} constraints as lazy...") + + h5.put_array("mip_constr_lazy", lazy) + h5.put_scalar("mip_constr_lazy_log", streams[0].getvalue()) diff --git a/miplearn/collectors/priority.py b/miplearn/collectors/priority.py new file mode 100644 index 0000000..8217d65 --- /dev/null +++ b/miplearn/collectors/priority.py @@ -0,0 +1,49 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import os +import subprocess +from typing import Callable + +from ..h5 import H5File + + +class BranchPriorityCollector: + def __init__( + self, + time_limit: float = 900.0, + print_interval: int = 1, + node_limit: int = 500, + ) -> None: + self.time_limit = time_limit + self.print_interval = print_interval + self.node_limit = node_limit + + def collect(self, data_filename: str, _: Callable) -> None: + basename = data_filename.replace(".pkl.gz", "") + env = os.environ.copy() + env["JULIA_NUM_THREADS"] = "1" + ret = subprocess.run( + [ + "julia", + "--project=.", + "-e", + ( + f"using CPLEX, JuMP, MIPLearn.BB; " + f"BB.solve!(" + f' optimizer_with_attributes(CPLEX.Optimizer, "CPXPARAM_Threads" => 1),' + f' "{basename}",' + f" print_interval={self.print_interval}," + f" time_limit={self.time_limit:.2f}," + f" node_limit={self.node_limit}," + f")" + ), + ], + check=True, + capture_output=True, + env=env, + ) + h5_filename = f"{basename}.h5" + with H5File(h5_filename, "r+") as h5: + h5.put_scalar("bb_log", ret.stdout) diff --git a/miplearn/components/__init__.py b/miplearn/components/__init__.py index 2ebe21e..4932a43 100644 --- a/miplearn/components/__init__.py +++ b/miplearn/components/__init__.py @@ -1,47 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from typing import Dict - - -def classifier_evaluation_dict( - tp: int, - tn: int, - fp: int, - fn: int, -) -> Dict[str, float]: - p = tp + fn - n = fp + tn - d: Dict = { - "Predicted positive": fp + tp, - "Predicted negative": fn + tn, - "Condition positive": p, - "Condition negative": n, - "True positive": tp, - "True negative": tn, - "False positive": fp, - "False negative": fn, - "Accuracy": (tp + tn) / (p + n), - "F1 score": (2 * tp) / (2 * tp + fp + fn), - } - - if p > 0: - d["Recall"] = tp / p - else: - d["Recall"] = 1.0 - - if tp + fp > 0: - d["Precision"] = tp / (tp + fp) - else: - d["Precision"] = 1.0 - - t = (p + n) / 100.0 - d["Predicted positive (%)"] = d["Predicted positive"] / t - d["Predicted negative (%)"] = d["Predicted negative"] / t - d["Condition positive (%)"] = d["Condition positive"] / t - d["Condition negative (%)"] = d["Condition negative"] / t - d["True positive (%)"] = d["True positive"] / t - d["True negative (%)"] = d["True negative"] / t - d["False positive (%)"] = d["False positive"] / t - d["False negative (%)"] = d["False negative"] / t - return d diff --git a/miplearn/components/component.py b/miplearn/components/component.py deleted file mode 100644 index e4a138a..0000000 --- a/miplearn/components/component.py +++ /dev/null @@ -1,269 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from typing import Any, List, TYPE_CHECKING, Tuple, Dict, Optional - -import numpy as np -from tqdm.auto import tqdm -from p_tqdm import p_umap - -from miplearn.features.sample import Sample -from miplearn.instance.base import Instance -from miplearn.types import LearningSolveStats, Category - -if TYPE_CHECKING: - from miplearn.solvers.learning import LearningSolver - - -# noinspection PyMethodMayBeStatic -class Component: - """ - A Component is an object which adds functionality to a LearningSolver. - - For better code maintainability, LearningSolver simply delegates most of its - functionality to Components. Each Component is responsible for exactly one ML - strategy. - """ - - def after_solve_lp( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - """ - Method called by LearningSolver after the root LP relaxation is solved. - See before_solve_lp for a description of the parameters. - """ - return - - def after_solve_mip( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - """ - Method called by LearningSolver after the MIP is solved. - See before_solve_lp for a description of the parameters. - """ - return - - def before_solve_lp( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - """ - Method called by LearningSolver before the root LP relaxation is solved. - - Parameters - ---------- - solver: LearningSolver - The solver calling this method. - instance: Instance - The instance being solved. - model - The concrete optimization model being solved. - stats: LearningSolveStats - A dictionary containing statistics about the solution process, such as - number of nodes explored and running time. Components are free to add - their own statistics here. For example, PrimalSolutionComponent adds - statistics regarding the number of predicted variables. All statistics in - this dictionary are exported to the benchmark CSV file. - sample: miplearn.features.Sample - An object containing data that may be useful for training machine - learning models and accelerating the solution process. Components are - free to add their own training data here. - """ - return - - def before_solve_mip( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - """ - Method called by LearningSolver before the MIP is solved. - See before_solve_lp for a description of the parameters. - """ - return - - def fit_xy( - self, - x: Dict[Category, np.ndarray], - y: Dict[Category, np.ndarray], - ) -> None: - """ - Given two dictionaries x and y, mapping the name of the category to matrices - of features and targets, this function does two things. First, for each - category, it creates a clone of the prototype regressor/classifier. Second, - it passes (x[category], y[category]) to the clone's fit method. - """ - return - - def iteration_cb( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - ) -> bool: - """ - Method called by LearningSolver at the end of each iteration. - - After solving the MIP, LearningSolver calls `iteration_cb` of each component, - giving them a chance to modify the problem and resolve it before the solution - process ends. For example, the lazy constraint component uses `iteration_cb` - to check that all lazy constraints are satisfied. - - If `iteration_cb` returns False for all components, the solution process - ends. If it retunrs True for any component, the MIP is solved again. - - Parameters - ---------- - solver: LearningSolver - The solver calling this method. - instance: Instance - The instance being solved. - model: Any - The concrete optimization model being solved. - """ - return False - - def lazy_cb( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - ) -> None: - return - - def sample_evaluate( - self, - instance: Optional[Instance], - sample: Sample, - ) -> Dict[str, Dict[str, float]]: - return {} - - def sample_xy( - self, - instance: Optional[Instance], - sample: Sample, - ) -> Tuple[Dict, Dict]: - """ - Returns a pair of x and y dictionaries containing, respectively, the matrices - of ML features and the labels for the sample. If the training sample does not - include label information, returns (x, {}). - """ - pass - - def pre_fit(self, pre: List[Any]) -> None: - pass - - def user_cut_cb( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - ) -> None: - return - - def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any: - pass - - @staticmethod - def fit_multiple( - components: List["Component"], - instances: List[Instance], - n_jobs: int = 1, - progress: bool = False, - ) -> None: - - # Part I: Pre-fit - def _pre_sample_xy(instance: Instance) -> Dict: - pre_instance: Dict = {} - for (cidx, comp) in enumerate(components): - pre_instance[cidx] = [] - instance.load() - for sample in instance.get_samples(): - for (cidx, comp) in enumerate(components): - pre_instance[cidx].append(comp.pre_sample_xy(instance, sample)) - instance.free() - return pre_instance - - if n_jobs == 1: - pre = [_pre_sample_xy(instance) for instance in instances] - else: - pre = p_umap( - _pre_sample_xy, - instances, - num_cpus=n_jobs, - desc="pre-sample-xy", - disable=not progress, - ) - pre_combined: Dict = {} - for (cidx, comp) in enumerate(components): - pre_combined[cidx] = [] - for p in pre: - pre_combined[cidx].extend(p[cidx]) - for (cidx, comp) in enumerate(components): - comp.pre_fit(pre_combined[cidx]) - - # Part II: Fit - def _sample_xy(instance: Instance) -> Tuple[Dict, Dict]: - x_instance: Dict = {} - y_instance: Dict = {} - for (cidx, comp) in enumerate(components): - x_instance[cidx] = {} - y_instance[cidx] = {} - instance.load() - for sample in instance.get_samples(): - for (cidx, comp) in enumerate(components): - x = x_instance[cidx] - y = y_instance[cidx] - x_sample, y_sample = comp.sample_xy(instance, sample) - for cat in x_sample.keys(): - if cat not in x: - x[cat] = [] - y[cat] = [] - x[cat] += x_sample[cat] - y[cat] += y_sample[cat] - instance.free() - return x_instance, y_instance - - if n_jobs == 1: - xy = [_sample_xy(instance) for instance in instances] - else: - xy = p_umap(_sample_xy, instances, desc="sample-xy", disable=not progress) - - for (cidx, comp) in enumerate( - tqdm( - components, - desc="fit", - disable=not progress, - ) - ): - x_comp: Dict = {} - y_comp: Dict = {} - for (x, y) in xy: - for cat in x[cidx].keys(): - if cat not in x_comp: - x_comp[cat] = [] - y_comp[cat] = [] - x_comp[cat].extend(x[cidx][cat]) - y_comp[cat].extend(y[cidx][cat]) - for cat in x_comp.keys(): - x_comp[cat] = np.array(x_comp[cat], dtype=np.float32) - y_comp[cat] = np.array(y_comp[cat]) - comp.fit_xy(x_comp, y_comp) diff --git a/miplearn/components/dynamic_common.py b/miplearn/components/dynamic_common.py deleted file mode 100644 index 11f341a..0000000 --- a/miplearn/components/dynamic_common.py +++ /dev/null @@ -1,184 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -import json -import logging -from typing import Dict, List, Tuple, Optional, Any, Set - -import numpy as np -from overrides import overrides - -from miplearn.features.extractor import FeaturesExtractor -from miplearn.classifiers import Classifier -from miplearn.classifiers.threshold import Threshold -from miplearn.components import classifier_evaluation_dict -from miplearn.components.component import Component -from miplearn.features.sample import Sample -from miplearn.instance.base import Instance -from miplearn.types import ConstraintCategory, ConstraintName - -logger = logging.getLogger(__name__) - - -class DynamicConstraintsComponent(Component): - """ - Base component used by both DynamicLazyConstraintsComponent and UserCutsComponent. - """ - - def __init__( - self, - attr: str, - classifier: Classifier, - threshold: Threshold, - ): - assert isinstance(classifier, Classifier) - self.threshold_prototype: Threshold = threshold - self.classifier_prototype: Classifier = classifier - self.classifiers: Dict[ConstraintCategory, Classifier] = {} - self.thresholds: Dict[ConstraintCategory, Threshold] = {} - self.known_violations: Dict[ConstraintName, Any] = {} - self.attr = attr - - def sample_xy_with_cids( - self, - instance: Optional[Instance], - sample: Sample, - ) -> Tuple[ - Dict[ConstraintCategory, List[List[float]]], - Dict[ConstraintCategory, List[List[bool]]], - Dict[ConstraintCategory, List[ConstraintName]], - ]: - if len(self.known_violations) == 0: - return {}, {}, {} - assert instance is not None - x: Dict[ConstraintCategory, List[List[float]]] = {} - y: Dict[ConstraintCategory, List[List[bool]]] = {} - cids: Dict[ConstraintCategory, List[ConstraintName]] = {} - known_cids = np.array(sorted(list(self.known_violations.keys())), dtype="S") - - enforced_cids = None - enforced_encoded = sample.get_scalar(self.attr) - if enforced_encoded is not None: - enforced = self.decode(enforced_encoded) - enforced_cids = list(enforced.keys()) - - # Get user-provided constraint features - ( - constr_features, - constr_categories, - constr_lazy, - ) = FeaturesExtractor._extract_user_features_constrs(instance, known_cids) - - # Augment with instance features - instance_features = sample.get_array("static_instance_features") - assert instance_features is not None - constr_features = np.hstack( - [ - instance_features.reshape(1, -1).repeat(len(known_cids), axis=0), - constr_features, - ] - ) - - categories = np.unique(constr_categories) - for c in categories: - x[c] = constr_features[constr_categories == c].tolist() - cids[c] = known_cids[constr_categories == c].tolist() - if enforced_cids is not None: - tmp = np.isin(cids[c], enforced_cids).reshape(-1, 1) - y[c] = np.hstack([~tmp, tmp]).tolist() # type: ignore - - return x, y, cids - - @overrides - def sample_xy( - self, - instance: Optional[Instance], - sample: Sample, - ) -> Tuple[Dict, Dict]: - x, y, _ = self.sample_xy_with_cids(instance, sample) - return x, y - - @overrides - def pre_fit(self, pre: List[Any]) -> None: - assert pre is not None - self.known_violations.clear() - for violations in pre: - for (vname, vdata) in violations.items(): - self.known_violations[vname] = vdata - - def sample_predict( - self, - instance: Instance, - sample: Sample, - ) -> List[ConstraintName]: - pred: List[ConstraintName] = [] - if len(self.known_violations) == 0: - logger.info("Classifiers not fitted. Skipping.") - return pred - x, _, cids = self.sample_xy_with_cids(instance, sample) - for category in x.keys(): - assert category in self.classifiers - assert category in self.thresholds - clf = self.classifiers[category] - thr = self.thresholds[category] - nx = np.array(x[category]) - proba = clf.predict_proba(nx) - t = thr.predict(nx) - for i in range(proba.shape[0]): - if proba[i][1] > t[1]: - pred += [cids[category][i]] - return pred - - @overrides - def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any: - attr_encoded = sample.get_scalar(self.attr) - assert attr_encoded is not None - return self.decode(attr_encoded) - - @overrides - def fit_xy( - self, - x: Dict[ConstraintCategory, np.ndarray], - y: Dict[ConstraintCategory, np.ndarray], - ) -> None: - for category in x.keys(): - self.classifiers[category] = self.classifier_prototype.clone() - self.thresholds[category] = self.threshold_prototype.clone() - npx = np.array(x[category]) - npy = np.array(y[category]) - self.classifiers[category].fit(npx, npy) - self.thresholds[category].fit(self.classifiers[category], npx, npy) - - @overrides - def sample_evaluate( - self, - instance: Instance, - sample: Sample, - ) -> Dict[str, float]: - attr_encoded = sample.get_scalar(self.attr) - assert attr_encoded is not None - actual_violations = DynamicConstraintsComponent.decode(attr_encoded) - actual = set(actual_violations.keys()) - pred = set(self.sample_predict(instance, sample)) - tp, tn, fp, fn = 0, 0, 0, 0 - for cid in self.known_violations.keys(): - if cid in pred: - if cid in actual: - tp += 1 - else: - fp += 1 - else: - if cid in actual: - fn += 1 - else: - tn += 1 - return classifier_evaluation_dict(tp=tp, tn=tn, fp=fp, fn=fn) - - @staticmethod - def encode(violations: Dict[ConstraintName, Any]) -> str: - return json.dumps({k.decode(): v for (k, v) in violations.items()}) - - @staticmethod - def decode(violations_encoded: str) -> Dict[ConstraintName, Any]: - violations = json.loads(violations_encoded) - return {k.encode(): v for (k, v) in violations.items()} diff --git a/miplearn/components/dynamic_lazy.py b/miplearn/components/dynamic_lazy.py deleted file mode 100644 index 019d922..0000000 --- a/miplearn/components/dynamic_lazy.py +++ /dev/null @@ -1,223 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -import json -import logging -from typing import Dict, List, TYPE_CHECKING, Tuple, Any, Optional - -import numpy as np -from overrides import overrides -from tqdm.auto import tqdm - -from miplearn.classifiers import Classifier -from miplearn.classifiers.counting import CountingClassifier -from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold -from miplearn.components.component import Component -from miplearn.components.dynamic_common import DynamicConstraintsComponent -from miplearn.features.sample import Sample, Hdf5Sample -from miplearn.instance.base import Instance -from miplearn.types import LearningSolveStats, ConstraintName, ConstraintCategory -from p_tqdm import p_map - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from miplearn.solvers.learning import LearningSolver - - -class DynamicLazyConstraintsComponent(Component): - """ - A component that predicts which lazy constraints to enforce. - """ - - def __init__( - self, - classifier: Classifier = CountingClassifier(), - threshold: Threshold = MinProbabilityThreshold([0, 0.05]), - ): - self.dynamic: DynamicConstraintsComponent = DynamicConstraintsComponent( - classifier=classifier, - threshold=threshold, - attr="mip_constr_lazy", - ) - self.classifiers = self.dynamic.classifiers - self.thresholds = self.dynamic.thresholds - self.known_violations = self.dynamic.known_violations - self.lazy_enforced: Dict[ConstraintName, Any] = {} - self.n_iterations: int = 0 - - @staticmethod - def enforce( - violations: Dict[ConstraintName, Any], - instance: Instance, - model: Any, - solver: "LearningSolver", - ) -> None: - assert solver.internal_solver is not None - for (vname, vdata) in violations.items(): - instance.enforce_lazy_constraint(solver.internal_solver, model, vdata) - - @overrides - def before_solve_mip( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - self.lazy_enforced.clear() - logger.info("Predicting violated (dynamic) lazy constraints...") - vnames = self.dynamic.sample_predict(instance, sample) - violations = {c: self.dynamic.known_violations[c] for c in vnames} - logger.info("Enforcing %d lazy constraints..." % len(vnames)) - self.enforce(violations, instance, model, solver) - self.n_iterations = 0 - - @overrides - def after_solve_mip( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - sample.put_scalar("mip_constr_lazy", self.dynamic.encode(self.lazy_enforced)) - stats["LazyDynamic: Added in callback"] = len(self.lazy_enforced) - stats["LazyDynamic: Iterations"] = self.n_iterations - - @overrides - def iteration_cb( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - ) -> bool: - assert solver.internal_solver is not None - logger.debug("Finding violated lazy constraints...") - violations = instance.find_violated_lazy_constraints( - solver.internal_solver, - model, - ) - if len(violations) == 0: - logger.debug("No violations found") - return False - else: - self.n_iterations += 1 - for v in violations: - self.lazy_enforced[v] = violations[v] - logger.debug(" %d violations found" % len(violations)) - self.enforce(violations, instance, model, solver) - return True - - # Delegate ML methods to self.dynamic - # ------------------------------------------------------------------- - @overrides - def sample_xy( - self, - instance: Optional[Instance], - sample: Sample, - ) -> Tuple[Dict, Dict]: - return self.dynamic.sample_xy(instance, sample) - - @overrides - def pre_fit(self, pre: List[Any]) -> None: - self.dynamic.pre_fit(pre) - - def sample_predict( - self, - instance: Instance, - sample: Sample, - ) -> List[ConstraintName]: - return self.dynamic.sample_predict(instance, sample) - - @overrides - def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any: - return self.dynamic.pre_sample_xy(instance, sample) - - @overrides - def fit_xy( - self, - x: Dict[ConstraintCategory, np.ndarray], - y: Dict[ConstraintCategory, np.ndarray], - ) -> None: - self.dynamic.fit_xy(x, y) - - @overrides - def sample_evaluate( - self, - instance: Instance, - sample: Sample, - ) -> Dict[ConstraintCategory, Dict[str, float]]: - return self.dynamic.sample_evaluate(instance, sample) - - # ------------------------------------------------------------------------------------------------------------------ - # NEW API - # ------------------------------------------------------------------------------------------------------------------ - @staticmethod - def extract(filenames, progress=True, known_cids=None): - enforced_cids, features = [], [] - freeze_known_cids = True - if known_cids is None: - known_cids = set() - freeze_known_cids = False - for filename in tqdm( - filenames, - desc="extract (1/2)", - disable=not progress, - ): - with Hdf5Sample(filename, mode="r") as sample: - features.append(sample.get_array("lp_var_values")) - cids = frozenset( - DynamicConstraintsComponent.decode( - sample.get_scalar("mip_constr_lazy") - ).keys() - ) - enforced_cids.append(cids) - if not freeze_known_cids: - known_cids.update(cids) - - x, y, cat, cdata = [], [], [], {} - for (j, cid) in enumerate(known_cids): - cdata[cid] = json.loads(cid.decode()) - for i in range(len(features)): - cat.append(cid) - x.append(features[i]) - if cid in enforced_cids[i]: - y.append([0, 1]) - else: - y.append([1, 0]) - x = np.vstack(x) - y = np.vstack(y) - cat = np.array(cat) - x_dict, y_dict = DynamicLazyConstraintsComponent._split( - x, - y, - cat, - progress=progress, - ) - return x_dict, y_dict, cdata - - @staticmethod - def _split(x, y, cat, progress=False): - # Sort data by categories - pi = np.argsort(cat, kind="stable") - x = x[pi] - y = y[pi] - cat = cat[pi] - - # Split categories - x_dict = {} - y_dict = {} - start = 0 - for end in tqdm( - range(len(cat) + 1), - desc="extract (2/2)", - disable=not progress, - ): - if (end >= len(cat)) or (cat[start] != cat[end]): - x_dict[cat[start]] = x[start:end, :] - y_dict[cat[start]] = y[start:end, :] - start = end - return x_dict, y_dict \ No newline at end of file diff --git a/miplearn/components/dynamic_user_cuts.py b/miplearn/components/dynamic_user_cuts.py deleted file mode 100644 index d939ff5..0000000 --- a/miplearn/components/dynamic_user_cuts.py +++ /dev/null @@ -1,133 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -import logging -from typing import Any, TYPE_CHECKING, Tuple, Dict, List - -import numpy as np -from overrides import overrides - -from miplearn.classifiers import Classifier -from miplearn.classifiers.counting import CountingClassifier -from miplearn.classifiers.threshold import Threshold, MinProbabilityThreshold -from miplearn.components.component import Component -from miplearn.components.dynamic_common import DynamicConstraintsComponent -from miplearn.features.sample import Sample -from miplearn.instance.base import Instance -from miplearn.types import LearningSolveStats, ConstraintName, ConstraintCategory - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from miplearn.solvers.learning import LearningSolver - - -class UserCutsComponent(Component): - def __init__( - self, - classifier: Classifier = CountingClassifier(), - threshold: Threshold = MinProbabilityThreshold([0.50, 0.50]), - ) -> None: - self.dynamic = DynamicConstraintsComponent( - classifier=classifier, - threshold=threshold, - attr="mip_user_cuts", - ) - self.enforced: Dict[ConstraintName, Any] = {} - self.n_added_in_callback = 0 - - @overrides - def before_solve_mip( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - assert solver.internal_solver is not None - self.enforced.clear() - self.n_added_in_callback = 0 - logger.info("Predicting violated user cuts...") - vnames = self.dynamic.sample_predict(instance, sample) - logger.info("Enforcing %d user cuts ahead-of-time..." % len(vnames)) - for vname in vnames: - vdata = self.dynamic.known_violations[vname] - instance.enforce_user_cut(solver.internal_solver, model, vdata) - stats["UserCuts: Added ahead-of-time"] = len(vnames) - - @overrides - def user_cut_cb( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - ) -> None: - assert solver.internal_solver is not None - logger.debug("Finding violated user cuts...") - violations = instance.find_violated_user_cuts(model) - logger.debug(f"Found {len(violations)} violated user cuts") - logger.debug("Building violated user cuts...") - for (vname, vdata) in violations.items(): - if vname in self.enforced: - continue - instance.enforce_user_cut(solver.internal_solver, model, vdata) - self.enforced[vname] = vdata - self.n_added_in_callback += 1 - if len(violations) > 0: - logger.debug(f"Added {len(violations)} violated user cuts") - - @overrides - def after_solve_mip( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - sample.put_scalar("mip_user_cuts", self.dynamic.encode(self.enforced)) - stats["UserCuts: Added in callback"] = self.n_added_in_callback - if self.n_added_in_callback > 0: - logger.info(f"{self.n_added_in_callback} user cuts added in callback") - - # Delegate ML methods to self.dynamic - # ------------------------------------------------------------------- - @overrides - def sample_xy( - self, - instance: "Instance", - sample: Sample, - ) -> Tuple[Dict, Dict]: - return self.dynamic.sample_xy(instance, sample) - - @overrides - def pre_fit(self, pre: List[Any]) -> None: - self.dynamic.pre_fit(pre) - - def sample_predict( - self, - instance: "Instance", - sample: Sample, - ) -> List[ConstraintName]: - return self.dynamic.sample_predict(instance, sample) - - @overrides - def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any: - return self.dynamic.pre_sample_xy(instance, sample) - - @overrides - def fit_xy( - self, - x: Dict[ConstraintCategory, np.ndarray], - y: Dict[ConstraintCategory, np.ndarray], - ) -> None: - self.dynamic.fit_xy(x, y) - - @overrides - def sample_evaluate( - self, - instance: "Instance", - sample: Sample, - ) -> Dict[ConstraintCategory, Dict[ConstraintName, float]]: - return self.dynamic.sample_evaluate(instance, sample) diff --git a/miplearn/components/lazy.py b/miplearn/components/lazy.py new file mode 100644 index 0000000..2838117 --- /dev/null +++ b/miplearn/components/lazy.py @@ -0,0 +1,43 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import json +from typing import Any, Dict, List + +import gurobipy as gp + +from ..h5 import H5File + + +class ExpertLazyComponent: + def __init__(self) -> None: + pass + + def fit(self, train_h5: List[str]) -> None: + pass + + def before_mip(self, test_h5: str, model: gp.Model, stats: Dict[str, Any]) -> None: + with H5File(test_h5, "r") as h5: + constr_names = h5.get_array("static_constr_names") + constr_lazy = h5.get_array("mip_constr_lazy") + constr_violations = h5.get_scalar("mip_constr_violations") + + assert constr_names is not None + assert constr_violations is not None + + # Static lazy constraints + n_static_lazy = 0 + if constr_lazy is not None: + for (constr_idx, constr_name) in enumerate(constr_names): + if constr_lazy[constr_idx]: + constr = model.getConstrByName(constr_name.decode()) + constr.lazy = 3 + n_static_lazy += 1 + stats.update({"Static lazy constraints": n_static_lazy}) + + # Dynamic lazy constraints + if hasattr(model, "_fix_violations"): + violations = json.loads(constr_violations) + model._fix_violations(model, violations, "aot") + stats.update({"Dynamic lazy constraints": len(violations)}) diff --git a/miplearn/components/objective.py b/miplearn/components/objective.py deleted file mode 100644 index af1316f..0000000 --- a/miplearn/components/objective.py +++ /dev/null @@ -1,126 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import List, Dict, Any, TYPE_CHECKING, Tuple, Optional, cast - -import numpy as np -from overrides import overrides -from sklearn.linear_model import LinearRegression - -from miplearn.classifiers import Regressor -from miplearn.classifiers.sklearn import ScikitLearnRegressor -from miplearn.components.component import Component -from miplearn.features.sample import Sample -from miplearn.instance.base import Instance -from miplearn.types import LearningSolveStats - -if TYPE_CHECKING: - from miplearn.solvers.learning import LearningSolver - -logger = logging.getLogger(__name__) - - -class ObjectiveValueComponent(Component): - """ - A Component which predicts the optimal objective value of the problem. - """ - - def __init__( - self, - regressor: Regressor = ScikitLearnRegressor(LinearRegression()), - ) -> None: - assert isinstance(regressor, Regressor) - self.regressors: Dict[str, Regressor] = {} - self.regressor_prototype = regressor - - @overrides - def before_solve_mip( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - logger.info("Predicting optimal value...") - pred = self.sample_predict(sample) - for (c, v) in pred.items(): - logger.info(f"Predicted {c.lower()}: %.6e" % v) - stats[f"Objective: Predicted {c.lower()}"] = v # type: ignore - - @overrides - def fit_xy( - self, - x: Dict[str, np.ndarray], - y: Dict[str, np.ndarray], - ) -> None: - for c in ["Upper bound", "Lower bound"]: - if c in y: - self.regressors[c] = self.regressor_prototype.clone() - self.regressors[c].fit(x[c], y[c]) - - def sample_predict(self, sample: Sample) -> Dict[str, float]: - pred: Dict[str, float] = {} - x, _ = self.sample_xy(None, sample) - for c in ["Upper bound", "Lower bound"]: - if c in self.regressors is not None: - pred[c] = self.regressors[c].predict(np.array(x[c]))[0, 0] - else: - logger.info(f"{c} regressor not fitted. Skipping.") - return pred - - @overrides - def sample_xy( - self, - _: Optional[Instance], - sample: Sample, - ) -> Tuple[Dict[str, List[List[float]]], Dict[str, List[List[float]]]]: - lp_instance_features_np = sample.get_array("lp_instance_features") - if lp_instance_features_np is None: - lp_instance_features_np = sample.get_array("static_instance_features") - assert lp_instance_features_np is not None - lp_instance_features = cast(List[float], lp_instance_features_np.tolist()) - - # Features - x: Dict[str, List[List[float]]] = { - "Upper bound": [lp_instance_features], - "Lower bound": [lp_instance_features], - } - - # Labels - y: Dict[str, List[List[float]]] = {} - mip_lower_bound = sample.get_scalar("mip_lower_bound") - mip_upper_bound = sample.get_scalar("mip_upper_bound") - if mip_lower_bound is not None: - y["Lower bound"] = [[mip_lower_bound]] - if mip_upper_bound is not None: - y["Upper bound"] = [[mip_upper_bound]] - - return x, y - - @overrides - def sample_evaluate( - self, - instance: Instance, - sample: Sample, - ) -> Dict[str, Dict[str, float]]: - def compare(y_pred: float, y_actual: float) -> Dict[str, float]: - err = np.round(abs(y_pred - y_actual), 8) - return { - "Actual value": y_actual, - "Predicted value": y_pred, - "Absolute error": err, - "Relative error": err / y_actual, - } - - result: Dict[str, Dict[str, float]] = {} - pred = self.sample_predict(sample) - actual_ub = sample.get_scalar("mip_upper_bound") - actual_lb = sample.get_scalar("mip_lower_bound") - if actual_ub is not None: - result["Upper bound"] = compare(pred["Upper bound"], actual_ub) - if actual_lb is not None: - result["Lower bound"] = compare(pred["Lower bound"], actual_lb) - return result diff --git a/miplearn/components/primal.py b/miplearn/components/primal.py deleted file mode 100644 index 3e88502..0000000 --- a/miplearn/components/primal.py +++ /dev/null @@ -1,341 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import Dict, List, Any, TYPE_CHECKING, Tuple, Optional - -import numpy as np -from overrides import overrides - -from miplearn.classifiers import Classifier -from miplearn.classifiers.adaptive import AdaptiveClassifier -from miplearn.classifiers.threshold import MinPrecisionThreshold, Threshold -from miplearn.components import classifier_evaluation_dict -from miplearn.components.component import Component -from miplearn.features.sample import Sample -from miplearn.instance.base import Instance -from miplearn.types import ( - LearningSolveStats, - Category, - Solution, -) -from miplearn.features.sample import Hdf5Sample -from p_tqdm import p_map -from tqdm.auto import tqdm - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from miplearn.solvers.learning import LearningSolver - - -class PrimalSolutionComponent(Component): - """ - A component that predicts the optimal primal values for the binary decision - variables. - - In exact mode, predicted primal solutions are provided to the solver as MIP - starts. In heuristic mode, this component fixes the decision variables to their - predicted values. - """ - - def __init__( - self, - classifier: Classifier = AdaptiveClassifier(), - mode: str = "exact", - threshold: Threshold = MinPrecisionThreshold([0.99, 0.99]), - ) -> None: - assert isinstance(classifier, Classifier) - assert isinstance(threshold, Threshold) - assert mode in ["exact", "heuristic"] - self.mode = mode - self.classifiers: Dict[Category, Classifier] = {} - self.thresholds: Dict[Category, Threshold] = {} - self.threshold_prototype = threshold - self.classifier_prototype = classifier - - @overrides - def before_solve_mip( - self, - solver: "LearningSolver", - instance: Instance, - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - logger.info("Predicting primal solution...") - - # Do nothing if models are not trained - if len(self.classifiers) == 0: - logger.info("Classifiers not fitted. Skipping.") - return - - # Predict solution and provide it to the solver - solution = self.sample_predict(sample) - assert solver.internal_solver is not None - if self.mode == "heuristic": - solver.internal_solver.fix(solution) - else: - solver.internal_solver.set_warm_start(solution) - - # Update statistics - stats["Primal: Free"] = 0 - stats["Primal: Zero"] = 0 - stats["Primal: One"] = 0 - for (var_name, value) in solution.items(): - if value is None: - stats["Primal: Free"] += 1 - else: - if value < 0.5: - stats["Primal: Zero"] += 1 - else: - stats["Primal: One"] += 1 - logger.info( - f"Predicted: free: {stats['Primal: Free']}, " - f"zero: {stats['Primal: Zero']}, " - f"one: {stats['Primal: One']}" - ) - - def sample_predict(self, sample: Sample) -> Solution: - var_names = sample.get_array("static_var_names") - var_categories = sample.get_array("static_var_categories") - var_types = sample.get_array("static_var_types") - assert var_names is not None - assert var_categories is not None - assert var_types is not None - - # Compute y_pred - x, _ = self.sample_xy(None, sample) - y_pred = {} - for category in x.keys(): - assert category in self.classifiers, ( - f"Classifier for category {category} has not been trained. " - f"Please call component.fit before component.predict." - ) - xc = np.array(x[category]) - proba = self.classifiers[category].predict_proba(xc) - thr = self.thresholds[category].predict(xc) - y_pred[category] = np.vstack( - [ - proba[:, 0] >= thr[0], - proba[:, 1] >= thr[1], - ] - ).T - - # Convert y_pred into solution - solution: Solution = {v: None for v in var_names} - category_offset: Dict[Category, int] = {cat: 0 for cat in x.keys()} - for (i, var_name) in enumerate(var_names): - if var_types[i] != b"B": - continue - category = var_categories[i] - if category not in category_offset: - continue - offset = category_offset[category] - category_offset[category] += 1 - if y_pred[category][offset, 0]: - solution[var_name] = 0.0 - if y_pred[category][offset, 1]: - solution[var_name] = 1.0 - - return solution - - @overrides - def sample_xy( - self, - _: Optional[Instance], - sample: Sample, - ) -> Tuple[Dict[Category, List[List[float]]], Dict[Category, List[List[float]]]]: - x: Dict = {} - y: Dict = {} - instance_features = sample.get_array("static_instance_features") - mip_var_values = sample.get_array("mip_var_values") - lp_var_values = sample.get_array("lp_var_values") - var_features = sample.get_array("lp_var_features") - var_names = sample.get_array("static_var_names") - var_types = sample.get_array("static_var_types") - var_categories = sample.get_array("static_var_categories") - if var_features is None: - var_features = sample.get_array("static_var_features") - assert instance_features is not None - assert var_features is not None - assert var_names is not None - assert var_types is not None - assert var_categories is not None - - for (i, var_name) in enumerate(var_names): - # Skip non-binary variables - if var_types[i] != b"B": - continue - - # Initialize categories - category = var_categories[i] - if len(category) == 0: - continue - if category not in x.keys(): - x[category] = [] - y[category] = [] - - # Features - features = list(instance_features) - features.extend(var_features[i]) - if lp_var_values is not None: - features.extend(lp_var_values) - x[category].append(features) - - # Labels - if mip_var_values is not None: - opt_value = mip_var_values[i] - assert opt_value is not None - y[category].append([opt_value < 0.5, opt_value >= 0.5]) - return x, y - - @overrides - def sample_evaluate( - self, - _: Optional[Instance], - sample: Sample, - ) -> Dict[str, Dict[str, float]]: - mip_var_values = sample.get_array("mip_var_values") - var_names = sample.get_array("static_var_names") - assert mip_var_values is not None - assert var_names is not None - - solution_actual = { - var_name: mip_var_values[i] for (i, var_name) in enumerate(var_names) - } - solution_pred = self.sample_predict(sample) - vars_all, vars_one, vars_zero = set(), set(), set() - pred_one_positive, pred_zero_positive = set(), set() - for (var_name, value_actual) in solution_actual.items(): - vars_all.add(var_name) - if value_actual > 0.5: - vars_one.add(var_name) - else: - vars_zero.add(var_name) - value_pred = solution_pred[var_name] - if value_pred is not None: - if value_pred > 0.5: - pred_one_positive.add(var_name) - else: - pred_zero_positive.add(var_name) - pred_one_negative = vars_all - pred_one_positive - pred_zero_negative = vars_all - pred_zero_positive - return { - "0": classifier_evaluation_dict( - tp=len(pred_zero_positive & vars_zero), - tn=len(pred_zero_negative & vars_one), - fp=len(pred_zero_positive & vars_one), - fn=len(pred_zero_negative & vars_zero), - ), - "1": classifier_evaluation_dict( - tp=len(pred_one_positive & vars_one), - tn=len(pred_one_negative & vars_zero), - fp=len(pred_one_positive & vars_zero), - fn=len(pred_one_negative & vars_one), - ), - } - - @overrides - def fit_xy( - self, - x: Dict[Category, np.ndarray], - y: Dict[Category, np.ndarray], - progress: bool = False, - ) -> None: - for category in tqdm(x.keys(), desc="fit", disable=not progress): - clf = self.classifier_prototype.clone() - thr = self.threshold_prototype.clone() - clf.fit(x[category], y[category]) - thr.fit(clf, x[category], y[category]) - self.classifiers[category] = clf - self.thresholds[category] = thr - - # ------------------------------------------------------------------------------------------------------------------ - # NEW API - # ------------------------------------------------------------------------------------------------------------------ - - def fit( - self, - x: Dict[Category, np.ndarray], - y: Dict[Category, np.ndarray], - progress: bool = False, - ) -> None: - for category in tqdm(x.keys(), desc="fit", disable=not progress): - clf = self.classifier_prototype.clone() - thr = self.threshold_prototype.clone() - clf.fit(x[category], y[category]) - thr.fit(clf, x[category], y[category]) - self.classifiers[category] = clf - self.thresholds[category] = thr - - def predict(self, x): - y_pred = {} - for category in x.keys(): - assert category in self.classifiers, ( - f"Classifier for category {category} has not been trained. " - f"Please call component.fit before component.predict." - ) - xc = np.array(x[category]) - proba = self.classifiers[category].predict_proba(xc) - thr = self.thresholds[category].predict(xc) - y_pred[category] = np.vstack( - [ - proba[:, 0] >= thr[0], - proba[:, 1] >= thr[1], - ] - ).T - return y_pred - - @staticmethod - def extract( - filenames: List[str], - progress: bool = False, - ): - x, y, cat = [], [], [] - - # Read data - for filename in tqdm( - filenames, - desc="extract (1/2)", - disable=not progress, - ): - with Hdf5Sample(filename, mode="r") as sample: - mip_var_values = sample.get_array("mip_var_values") - var_features = sample.get_array("lp_var_features") - var_types = sample.get_array("static_var_types") - var_categories = sample.get_array("static_var_categories") - assert var_features is not None - assert var_types is not None - assert var_categories is not None - x.append(var_features) - y.append([mip_var_values < 0.5, mip_var_values > 0.5]) - cat.extend(var_categories) - - # Convert to numpy arrays - x = np.vstack(x) - y = np.hstack(y).T - cat = np.array(cat) - - # Sort data by categories - pi = np.argsort(cat, kind="stable") - x = x[pi] - y = y[pi] - cat = cat[pi] - - # Split categories - x_dict = {} - y_dict = {} - start = 0 - for end in tqdm( - range(len(cat) + 1), - desc="extract (2/2)", - disable=not progress, - ): - if (end >= len(cat)) or (cat[start] != cat[end]): - x_dict[cat[start]] = x[start:end, :] - y_dict[cat[start]] = y[start:end, :] - start = end - - return x_dict, y_dict diff --git a/miplearn/components/primal/__init__.py b/miplearn/components/primal/__init__.py new file mode 100644 index 0000000..4c8a414 --- /dev/null +++ b/miplearn/components/primal/__init__.py @@ -0,0 +1,29 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import Tuple + +import numpy as np + +from miplearn.h5 import H5File + + +def _extract_bin_var_names_values( + h5: H5File, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + bin_var_names, bin_var_indices = _extract_bin_var_names(h5) + var_values = h5.get_array("mip_var_values") + assert var_values is not None + bin_var_values = var_values[bin_var_indices].astype(int) + return bin_var_names, bin_var_values, bin_var_indices + + +def _extract_bin_var_names(h5: H5File) -> Tuple[np.ndarray, np.ndarray]: + var_types = h5.get_array("static_var_types") + var_names = h5.get_array("static_var_names") + assert var_types is not None + assert var_names is not None + bin_var_indices = np.where(var_types == b"B")[0] + bin_var_names = var_names[bin_var_indices] + assert len(bin_var_names.shape) == 1 + return bin_var_names, bin_var_indices diff --git a/miplearn/components/primal/actions.py b/miplearn/components/primal/actions.py new file mode 100644 index 0000000..0a1a1d9 --- /dev/null +++ b/miplearn/components/primal/actions.py @@ -0,0 +1,93 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +import logging +from abc import ABC, abstractmethod +from typing import Optional, Dict + +import numpy as np + +from miplearn.solvers.abstract import AbstractModel + +logger = logging.getLogger() + + +class PrimalComponentAction(ABC): + @abstractmethod + def perform( + self, + model: AbstractModel, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict], + ) -> None: + pass + + +class SetWarmStart(PrimalComponentAction): + def perform( + self, + model: AbstractModel, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict], + ) -> None: + logger.info("Setting warm starts...") + model.set_warm_starts(var_names, var_values, stats) + + +class FixVariables(PrimalComponentAction): + def perform( + self, + model: AbstractModel, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict], + ) -> None: + logger.info("Fixing variables...") + assert len(var_values.shape) == 2 + assert var_values.shape[0] == 1 + var_values = var_values.reshape(-1) + model.fix_variables(var_names, var_values, stats) + if stats is not None: + stats["Heuristic"] = True + + +class EnforceProximity(PrimalComponentAction): + def __init__(self, tol: float) -> None: + self.tol = tol + + def perform( + self, + model: AbstractModel, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict], + ) -> None: + assert len(var_values.shape) == 2 + assert var_values.shape[0] == 1 + var_values = var_values.reshape(-1) + + constr_lhs = [] + constr_vars = [] + constr_rhs = 0.0 + for (i, var_name) in enumerate(var_names): + if np.isnan(var_values[i]): + continue + constr_lhs.append(1.0 if var_values[i] < 0.5 else -1.0) + constr_rhs -= var_values[i] + constr_vars.append(var_name) + + constr_rhs += len(constr_vars) * self.tol + logger.info( + f"Adding proximity constraint (tol={self.tol}, nz={len(constr_vars)})..." + ) + + model.add_constrs( + np.array(constr_vars), + np.array([constr_lhs]), + np.array(["<"], dtype="S"), + np.array([constr_rhs]), + ) + if stats is not None: + stats["Heuristic"] = True diff --git a/miplearn/components/primal/expert.py b/miplearn/components/primal/expert.py new file mode 100644 index 0000000..8bf4319 --- /dev/null +++ b/miplearn/components/primal/expert.py @@ -0,0 +1,32 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import logging +from typing import Any, Dict, List + +from . import _extract_bin_var_names_values +from .actions import PrimalComponentAction +from ...solvers.abstract import AbstractModel +from ...h5 import H5File + +logger = logging.getLogger(__name__) + + +class ExpertPrimalComponent: + def __init__(self, action: PrimalComponentAction): + self.action = action + + """ + Component that predicts warm starts by peeking at the optimal solution. + """ + + def fit(self, train_h5: List[str]) -> None: + pass + + def before_mip( + self, test_h5: str, model: AbstractModel, stats: Dict[str, Any] + ) -> None: + with H5File(test_h5, "r") as h5: + names, values, _ = _extract_bin_var_names_values(h5) + self.action.perform(model, names, values.reshape(1, -1), stats) diff --git a/miplearn/components/primal/indep.py b/miplearn/components/primal/indep.py new file mode 100644 index 0000000..45c509d --- /dev/null +++ b/miplearn/components/primal/indep.py @@ -0,0 +1,129 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +import logging +from typing import Any, Dict, List, Callable, Optional + +import numpy as np +import sklearn + +from miplearn.components.primal import ( + _extract_bin_var_names_values, + _extract_bin_var_names, +) +from miplearn.components.primal.actions import PrimalComponentAction +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.solvers.abstract import AbstractModel +from miplearn.h5 import H5File + +logger = logging.getLogger(__name__) + + +class IndependentVarsPrimalComponent: + def __init__( + self, + base_clf: Any, + extractor: FeaturesExtractor, + action: PrimalComponentAction, + clone_fn: Callable[[Any], Any] = sklearn.clone, + ): + self.base_clf = base_clf + self.extractor = extractor + self.clf_: Dict[bytes, Any] = {} + self.bin_var_names_: Optional[np.ndarray] = None + self.n_features_: Optional[int] = None + self.clone_fn = clone_fn + self.action = action + + def fit(self, train_h5: List[str]) -> None: + logger.info("Reading training data...") + self.bin_var_names_ = None + n_bin_vars: Optional[int] = None + n_vars: Optional[int] = None + x, y = [], [] + for h5_filename in train_h5: + with H5File(h5_filename, "r") as h5: + # Get number of variables + var_types = h5.get_array("static_var_types") + assert var_types is not None + n_vars = len(var_types) + + # Extract features + ( + bin_var_names, + bin_var_values, + bin_var_indices, + ) = _extract_bin_var_names_values(h5) + + # Store/check variable names + if self.bin_var_names_ is None: + self.bin_var_names_ = bin_var_names + n_bin_vars = len(self.bin_var_names_) + else: + assert np.all(bin_var_names == self.bin_var_names_) + + # Build x and y vectors + x_sample = self.extractor.get_var_features(h5) + assert len(x_sample.shape) == 2 + assert x_sample.shape[0] == n_vars + x_sample = x_sample[bin_var_indices] + if self.n_features_ is None: + self.n_features_ = x_sample.shape[1] + else: + assert x_sample.shape[1] == self.n_features_ + x.append(x_sample) + y.append(bin_var_values) + + assert n_bin_vars is not None + assert self.bin_var_names_ is not None + + logger.info("Constructing matrices...") + x_np = np.vstack(x) + y_np = np.hstack(y) + n_samples = len(train_h5) * n_bin_vars + assert x_np.shape == (n_samples, self.n_features_) + assert y_np.shape == (n_samples,) + logger.info( + f"Dataset has {n_bin_vars} binary variables, " + f"{len(train_h5):,d} samples per variable, " + f"{self.n_features_:,d} features, 1 target and 2 classes" + ) + + logger.info(f"Training {n_bin_vars} classifiers...") + self.clf_ = {} + for (var_idx, var_name) in enumerate(self.bin_var_names_): + self.clf_[var_name] = self.clone_fn(self.base_clf) + self.clf_[var_name].fit( + x_np[var_idx::n_bin_vars, :], y_np[var_idx::n_bin_vars] + ) + + logger.info("Done fitting.") + + def before_mip( + self, test_h5: str, model: AbstractModel, stats: Dict[str, Any] + ) -> None: + assert self.bin_var_names_ is not None + assert self.n_features_ is not None + + # Read features + with H5File(test_h5, "r") as h5: + x_sample = self.extractor.get_var_features(h5) + bin_var_names, bin_var_indices = _extract_bin_var_names(h5) + assert np.all(bin_var_names == self.bin_var_names_) + x_sample = x_sample[bin_var_indices] + + assert x_sample.shape == (len(self.bin_var_names_), self.n_features_) + + # Predict optimal solution + logger.info("Predicting warm starts...") + y_pred = [] + for (var_idx, var_name) in enumerate(self.bin_var_names_): + x_var = x_sample[var_idx, :].reshape(1, -1) + y_var = self.clf_[var_name].predict(x_var) + assert y_var.shape == (1,) + y_pred.append(y_var[0]) + + # Construct warm starts, based on prediction + y_pred_np = np.array(y_pred).reshape(1, -1) + assert y_pred_np.shape == (1, len(self.bin_var_names_)) + self.action.perform(model, self.bin_var_names_, y_pred_np, stats) diff --git a/miplearn/components/primal/joint.py b/miplearn/components/primal/joint.py new file mode 100644 index 0000000..e03b4f4 --- /dev/null +++ b/miplearn/components/primal/joint.py @@ -0,0 +1,88 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +import logging +from typing import List, Dict, Any, Optional + +import numpy as np + +from miplearn.components.primal import _extract_bin_var_names_values +from miplearn.components.primal.actions import PrimalComponentAction +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.solvers.abstract import AbstractModel +from miplearn.h5 import H5File + +logger = logging.getLogger(__name__) + + +class JointVarsPrimalComponent: + def __init__( + self, clf: Any, extractor: FeaturesExtractor, action: PrimalComponentAction + ): + self.clf = clf + self.extractor = extractor + self.bin_var_names_: Optional[np.ndarray] = None + self.action = action + + def fit(self, train_h5: List[str]) -> None: + logger.info("Reading training data...") + self.bin_var_names_ = None + x, y, n_samples, n_features = [], [], len(train_h5), None + for h5_filename in train_h5: + with H5File(h5_filename, "r") as h5: + bin_var_names, bin_var_values, _ = _extract_bin_var_names_values(h5) + + # Store/check variable names + if self.bin_var_names_ is None: + self.bin_var_names_ = bin_var_names + else: + assert np.all(bin_var_names == self.bin_var_names_) + + # Build x and y vectors + x_sample = self.extractor.get_instance_features(h5) + assert len(x_sample.shape) == 1 + if n_features is None: + n_features = len(x_sample) + else: + assert len(x_sample) == n_features + x.append(x_sample) + y.append(bin_var_values) + assert self.bin_var_names_ is not None + + logger.info("Constructing matrices...") + x_np = np.vstack(x) + y_np = np.array(y) + assert len(x_np.shape) == 2 + assert x_np.shape[0] == n_samples + assert x_np.shape[1] == n_features + assert y_np.shape == (n_samples, len(self.bin_var_names_)) + logger.info( + f"Dataset has {n_samples:,d} samples, " + f"{n_features:,d} features and {y_np.shape[1]:,d} targets" + ) + + logger.info("Training classifier...") + self.clf.fit(x_np, y_np) + + logger.info("Done fitting.") + + def before_mip( + self, test_h5: str, model: AbstractModel, stats: Dict[str, Any] + ) -> None: + assert self.bin_var_names_ is not None + + # Read features + with H5File(test_h5, "r") as h5: + x_sample = self.extractor.get_instance_features(h5) + assert len(x_sample.shape) == 1 + x_sample = x_sample.reshape(1, -1) + + # Predict optimal solution + logger.info("Predicting warm starts...") + y_pred = self.clf.predict(x_sample) + assert len(y_pred.shape) == 2 + assert y_pred.shape[0] == 1 + assert y_pred.shape[1] == len(self.bin_var_names_) + + # Construct warm starts, based on prediction + self.action.perform(model, self.bin_var_names_, y_pred, stats) diff --git a/miplearn/components/primal/mem.py b/miplearn/components/primal/mem.py new file mode 100644 index 0000000..67db87f --- /dev/null +++ b/miplearn/components/primal/mem.py @@ -0,0 +1,167 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import logging +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional, Tuple + +import numpy as np + +from . import _extract_bin_var_names_values +from .actions import PrimalComponentAction +from ...extractors.abstract import FeaturesExtractor +from ...solvers.abstract import AbstractModel +from ...h5 import H5File + +logger = logging.getLogger() + + +class SolutionConstructor(ABC): + @abstractmethod + def construct(self, y_proba: np.ndarray, solutions: np.ndarray) -> np.ndarray: + pass + + +class MemorizingPrimalComponent: + """ + Component that memorizes all solutions seen during training, then fits a + single classifier to predict which of the memorized solutions should be + provided to the solver. Optionally combines multiple memorized solutions + into a single, partial one. + """ + + def __init__( + self, + clf: Any, + extractor: FeaturesExtractor, + constructor: SolutionConstructor, + action: PrimalComponentAction, + ) -> None: + assert clf is not None + self.clf = clf + self.extractor = extractor + self.constructor = constructor + self.solutions_: Optional[np.ndarray] = None + self.bin_var_names_: Optional[np.ndarray] = None + self.action = action + + def fit(self, train_h5: List[str]) -> None: + logger.info("Reading training data...") + n_samples = len(train_h5) + solutions_ = [] + self.bin_var_names_ = None + x, y, n_features = [], [], None + solution_to_idx: Dict[Tuple, int] = {} + for h5_filename in train_h5: + with H5File(h5_filename, "r") as h5: + bin_var_names, bin_var_values, _ = _extract_bin_var_names_values(h5) + + # Store/check variable names + if self.bin_var_names_ is None: + self.bin_var_names_ = bin_var_names + else: + assert np.all(bin_var_names == self.bin_var_names_) + + # Store solution + sol = tuple(np.where(bin_var_values)[0]) + if sol not in solution_to_idx: + solutions_.append(bin_var_values) + solution_to_idx[sol] = len(solution_to_idx) + y.append(solution_to_idx[sol]) + + # Extract features + x_sample = self.extractor.get_instance_features(h5) + assert len(x_sample.shape) == 1 + if n_features is None: + n_features = len(x_sample) + else: + assert len(x_sample) == n_features + x.append(x_sample) + + logger.info("Constructing matrices...") + x_np = np.vstack(x) + y_np = np.array(y) + assert len(x_np.shape) == 2 + assert x_np.shape[0] == n_samples + assert x_np.shape[1] == n_features + assert y_np.shape == (n_samples,) + self.solutions_ = np.array(solutions_) + n_classes = len(solution_to_idx) + logger.info( + f"Dataset has {n_samples:,d} samples, " + f"{n_features:,d} features and {n_classes:,d} classes" + ) + + logger.info("Training classifier...") + self.clf.fit(x_np, y_np) + + logger.info("Done fitting.") + + def before_mip( + self, test_h5: str, model: AbstractModel, stats: Dict[str, Any] + ) -> None: + assert self.solutions_ is not None + assert self.bin_var_names_ is not None + + # Read features + with H5File(test_h5, "r") as h5: + x_sample = self.extractor.get_instance_features(h5) + assert len(x_sample.shape) == 1 + x_sample = x_sample.reshape(1, -1) + + # Predict optimal solution + logger.info("Predicting primal solution...") + y_proba = self.clf.predict_proba(x_sample) + assert len(y_proba.shape) == 2 + assert y_proba.shape[0] == 1 + assert y_proba.shape[1] == len(self.solutions_) + + # Construct warm starts, based on prediction + starts = self.constructor.construct(y_proba[0, :], self.solutions_) + self.action.perform(model, self.bin_var_names_, starts, stats) + + +class SelectTopSolutions(SolutionConstructor): + """ + Warm start construction strategy that selects and returns the top k solutions. + """ + + def __init__(self, k: int) -> None: + self.k = k + + def construct(self, y_proba: np.ndarray, solutions: np.ndarray) -> np.ndarray: + # Check arguments + assert len(y_proba.shape) == 1 + assert len(solutions.shape) == 2 + assert len(y_proba) == solutions.shape[0] + + # Select top k solutions + ind = np.argsort(-y_proba, kind="stable") + selected = ind[: min(self.k, len(ind))] + return solutions[selected, :] + + +class MergeTopSolutions(SolutionConstructor): + """ + Warm start construction strategy that first selects the top k solutions, + then merges them into a single solution. + + To merge the solutions, the strategy first computes the mean optimal value of each + decision variable, then: (i) sets the variable to zero if the mean is below + thresholds[0]; (ii) sets the variable to one if the mean is above thresholds[1]; + (iii) leaves the variable free otherwise. + """ + + def __init__(self, k: int, thresholds: List[float]): + assert len(thresholds) == 2 + self.k = k + self.thresholds = thresholds + + def construct(self, y_proba: np.ndarray, solutions: np.ndarray) -> np.ndarray: + filtered = SelectTopSolutions(self.k).construct(y_proba, solutions) + mean = filtered.mean(axis=0) + start = np.full((1, solutions.shape[1]), float("nan")) + start[0, mean <= self.thresholds[0]] = 0 + start[0, mean >= self.thresholds[1]] = 1 + return start diff --git a/miplearn/components/priority.py b/miplearn/components/priority.py new file mode 100644 index 0000000..c232525 --- /dev/null +++ b/miplearn/components/priority.py @@ -0,0 +1,31 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from math import log +from typing import List, Dict, Any +import numpy as np + +import gurobipy as gp + +from ..h5 import H5File + + +class ExpertBranchPriorityComponent: + def __init__(self) -> None: + pass + + def fit(self, train_h5: List[str]) -> None: + pass + + def before_mip(self, test_h5: str, model: gp.Model, _: Dict[str, Any]) -> None: + with H5File(test_h5, "r") as h5: + var_names = h5.get_array("static_var_names") + var_priority = h5.get_array("bb_var_priority") + assert var_priority is not None + assert var_names is not None + + for (var_idx, var_name) in enumerate(var_names): + if np.isfinite(var_priority[var_idx]): + var = model.getVarByName(var_name.decode()) + var.branchPriority = int(log(1 + var_priority[var_idx])) diff --git a/miplearn/components/static_lazy.py b/miplearn/components/static_lazy.py deleted file mode 100644 index e819755..0000000 --- a/miplearn/components/static_lazy.py +++ /dev/null @@ -1,252 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import Dict, Tuple, List, Any, TYPE_CHECKING, Set, Optional - -import numpy as np -from overrides import overrides - -from miplearn.classifiers import Classifier -from miplearn.classifiers.counting import CountingClassifier -from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold -from miplearn.components.component import Component -from miplearn.features.sample import Sample -from miplearn.solvers.internal import Constraints -from miplearn.instance.base import Instance -from miplearn.types import LearningSolveStats, ConstraintName, ConstraintCategory - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from miplearn.solvers.learning import LearningSolver - - -class LazyConstraint: - def __init__(self, cid: ConstraintName, obj: Any) -> None: - self.cid = cid - self.obj = obj - - -class StaticLazyConstraintsComponent(Component): - """ - Component that decides which of the constraints tagged as lazy should - be kept in the formulation, and which should be removed. - """ - - def __init__( - self, - classifier: Classifier = CountingClassifier(), - threshold: Threshold = MinProbabilityThreshold([0.50, 0.50]), - violation_tolerance: float = -0.5, - ) -> None: - assert isinstance(classifier, Classifier) - self.classifier_prototype: Classifier = classifier - self.threshold_prototype: Threshold = threshold - self.classifiers: Dict[ConstraintCategory, Classifier] = {} - self.thresholds: Dict[ConstraintCategory, Threshold] = {} - self.pool: Constraints = Constraints() - self.violation_tolerance: float = violation_tolerance - self.enforced_cids: Set[ConstraintName] = set() - self.n_restored: int = 0 - self.n_iterations: int = 0 - - @overrides - def after_solve_mip( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - sample.put_array( - "mip_constr_lazy_enforced", - np.array(list(self.enforced_cids), dtype="S"), - ) - stats["LazyStatic: Restored"] = self.n_restored - stats["LazyStatic: Iterations"] = self.n_iterations - - @overrides - def before_solve_mip( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - stats: LearningSolveStats, - sample: Sample, - ) -> None: - assert solver.internal_solver is not None - static_lazy_count = sample.get_scalar("static_constr_lazy_count") - assert static_lazy_count is not None - - logger.info("Predicting violated (static) lazy constraints...") - if static_lazy_count == 0: - logger.info("Instance does not have static lazy constraints. Skipping.") - self.enforced_cids = set(self.sample_predict(sample)) - logger.info("Moving lazy constraints to the pool...") - constraints = Constraints.from_sample(sample) - assert constraints.lazy is not None - assert constraints.names is not None - selected = [ - (constraints.lazy[i] and constraints.names[i] not in self.enforced_cids) - for i in range(len(constraints.lazy)) - ] - n_removed = sum(selected) - n_kept = sum(constraints.lazy) - n_removed - self.pool = constraints[selected] - assert self.pool.names is not None - solver.internal_solver.remove_constraints(self.pool.names) - logger.info(f"{n_kept} lazy constraints kept; {n_removed} moved to the pool") - stats["LazyStatic: Removed"] = n_removed - stats["LazyStatic: Kept"] = n_kept - stats["LazyStatic: Restored"] = 0 - self.n_restored = 0 - self.n_iterations = 0 - - @overrides - def fit_xy( - self, - x: Dict[ConstraintCategory, np.ndarray], - y: Dict[ConstraintCategory, np.ndarray], - ) -> None: - for c in y.keys(): - assert c in x - self.classifiers[c] = self.classifier_prototype.clone() - self.thresholds[c] = self.threshold_prototype.clone() - self.classifiers[c].fit(x[c], y[c]) - self.thresholds[c].fit(self.classifiers[c], x[c], y[c]) - - @overrides - def iteration_cb( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - ) -> bool: - if solver.use_lazy_cb: - return False - else: - return self._check_and_add(solver) - - @overrides - def lazy_cb( - self, - solver: "LearningSolver", - instance: "Instance", - model: Any, - ) -> None: - self._check_and_add(solver) - - def sample_predict(self, sample: Sample) -> List[ConstraintName]: - x, y, cids = self._sample_xy_with_cids(sample) - enforced_cids: List[ConstraintName] = [] - for category in x.keys(): - if category not in self.classifiers: - continue - npx = np.array(x[category]) - proba = self.classifiers[category].predict_proba(npx) - thr = self.thresholds[category].predict(npx) - pred = list(proba[:, 1] > thr[1]) - for (i, is_selected) in enumerate(pred): - if is_selected: - enforced_cids += [cids[category][i]] - return enforced_cids - - @overrides - def sample_xy( - self, - _: Optional[Instance], - sample: Sample, - ) -> Tuple[ - Dict[ConstraintCategory, List[List[float]]], - Dict[ConstraintCategory, List[List[float]]], - ]: - x, y, __ = self._sample_xy_with_cids(sample) - return x, y - - def _check_and_add(self, solver: "LearningSolver") -> bool: - assert solver.internal_solver is not None - assert self.pool.names is not None - if len(self.pool.names) == 0: - logger.info("Lazy constraint pool is empty. Skipping violation check.") - return False - self.n_iterations += 1 - logger.info("Finding violated lazy constraints...") - is_satisfied = solver.internal_solver.are_constraints_satisfied( - self.pool, - tol=self.violation_tolerance, - ) - is_violated = [not i for i in is_satisfied] - violated_constraints = self.pool[is_violated] - satisfied_constraints = self.pool[is_satisfied] - self.pool = satisfied_constraints - assert violated_constraints.names is not None - assert satisfied_constraints.names is not None - n_violated = len(violated_constraints.names) - n_satisfied = len(satisfied_constraints.names) - logger.info(f"Found {n_violated} violated lazy constraints found") - if n_violated > 0: - logger.info( - f"Enforcing {n_violated} lazy constraints; " - f"{n_satisfied} left in the pool..." - ) - solver.internal_solver.add_constraints(violated_constraints) - for (i, name) in enumerate(violated_constraints.names): - self.enforced_cids.add(name) - self.n_restored += 1 - return True - else: - return False - - def _sample_xy_with_cids( - self, sample: Sample - ) -> Tuple[ - Dict[ConstraintCategory, List[List[float]]], - Dict[ConstraintCategory, List[List[float]]], - Dict[ConstraintCategory, List[ConstraintName]], - ]: - x: Dict[ConstraintCategory, List[List[float]]] = {} - y: Dict[ConstraintCategory, List[List[float]]] = {} - cids: Dict[ConstraintCategory, List[ConstraintName]] = {} - instance_features = sample.get_array("static_instance_features") - constr_features = sample.get_array("lp_constr_features") - constr_names = sample.get_array("static_constr_names") - constr_categories = sample.get_array("static_constr_categories") - constr_lazy = sample.get_array("static_constr_lazy") - lazy_enforced = sample.get_array("mip_constr_lazy_enforced") - if constr_features is None: - constr_features = sample.get_array("static_constr_features") - - assert instance_features is not None - assert constr_features is not None - assert constr_names is not None - assert constr_categories is not None - assert constr_lazy is not None - - for (cidx, cname) in enumerate(constr_names): - # Initialize categories - if not constr_lazy[cidx]: - continue - category = constr_categories[cidx] - if len(category) == 0: - continue - if category not in x: - x[category] = [] - y[category] = [] - cids[category] = [] - - # Features - features = list(instance_features) - features.extend(constr_features[cidx]) - x[category].append(features) - cids[category].append(cname) - - # Labels - if lazy_enforced is not None: - if cname in lazy_enforced: - y[category] += [[False, True]] - else: - y[category] += [[True, False]] - return x, y, cids diff --git a/miplearn/extractors/AlvLouWeh2017.py b/miplearn/extractors/AlvLouWeh2017.py new file mode 100644 index 0000000..e985db6 --- /dev/null +++ b/miplearn/extractors/AlvLouWeh2017.py @@ -0,0 +1,210 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from typing import Tuple, Optional + +import numpy as np + +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.h5 import H5File + + +class AlvLouWeh2017Extractor(FeaturesExtractor): + def __init__( + self, + with_m1: bool = True, + with_m2: bool = True, + with_m3: bool = True, + ): + self.with_m1 = with_m1 + self.with_m2 = with_m2 + self.with_m3 = with_m3 + + def get_instance_features(self, h5: H5File) -> np.ndarray: + raise NotImplemented() + + def get_var_features(self, h5: H5File) -> np.ndarray: + """ + Computes static variable features described in: + Alvarez, A. M., Louveaux, Q., & Wehenkel, L. (2017). A machine learning-based + approximation of strong branching. INFORMS Journal on Computing, 29(1), + 185-195. + """ + A = h5.get_sparse("static_constr_lhs") + b = h5.get_array("static_constr_rhs") + c = h5.get_array("static_var_obj_coeffs") + c_sa_up = h5.get_array("lp_var_sa_obj_up") + c_sa_down = h5.get_array("lp_var_sa_obj_down") + values = h5.get_array("lp_var_values") + + assert A is not None + assert b is not None + assert c is not None + + nvars = len(c) + curr = 0 + max_n_features = 40 + features = np.zeros((nvars, max_n_features)) + + def push(v: np.ndarray) -> None: + nonlocal curr + assert v.shape == (nvars,), f"{v.shape} != ({nvars},)" + features[:, curr] = v + curr += 1 + + def push_sign_abs(v: np.ndarray) -> None: + assert v.shape == (nvars,), f"{v.shape} != ({nvars},)" + push(np.sign(v)) + push(np.abs(v)) + + def maxmin(M: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + M_max = np.ravel(M.max(axis=0).todense()) + M_min = np.ravel(M.min(axis=0).todense()) + return M_max, M_min + + with np.errstate(divide="ignore", invalid="ignore"): + # Feature 1 + push(np.sign(c)) + + # Feature 2 + c_pos_sum = c[c > 0].sum() + push(np.abs(c) / c_pos_sum) + + # Feature 3 + c_neg_sum = -c[c < 0].sum() + push(np.abs(c) / c_neg_sum) + + if A is not None and self.with_m1: + # Compute A_ji / |b_j| + M1 = A.T.multiply(1.0 / np.abs(b)).T.tocsr() + + # Select rows with positive b_j and compute max/min + M1_pos = M1[b > 0, :] + if M1_pos.shape[0] > 0: + M1_pos_max = np.asarray(M1_pos.max(axis=0).todense()).flatten() + M1_pos_min = np.asarray(M1_pos.min(axis=0).todense()).flatten() + else: + M1_pos_max = np.zeros(nvars) + M1_pos_min = np.zeros(nvars) + + # Select rows with negative b_j and compute max/min + M1_neg = M1[b < 0, :] + if M1_neg.shape[0] > 0: + M1_neg_max = np.asarray(M1_neg.max(axis=0).todense()).flatten() + M1_neg_min = np.asarray(M1_neg.min(axis=0).todense()).flatten() + else: + M1_neg_max = np.zeros(nvars) + M1_neg_min = np.zeros(nvars) + + # Features 4-11 + push_sign_abs(M1_pos_min) + push_sign_abs(M1_pos_max) + push_sign_abs(M1_neg_min) + push_sign_abs(M1_neg_max) + + if A is not None and self.with_m2: + # Compute |c_i| / A_ij + M2 = A.power(-1).multiply(np.abs(c)).tocsc() + + # Compute max/min + M2_max, M2_min = maxmin(M2) + + # Make copies of M2 and erase elements based on sign(c) + M2_pos_max = M2_max.copy() + M2_neg_max = M2_max.copy() + M2_pos_min = M2_min.copy() + M2_neg_min = M2_min.copy() + M2_pos_max[c <= 0] = 0 + M2_pos_min[c <= 0] = 0 + M2_neg_max[c >= 0] = 0 + M2_neg_min[c >= 0] = 0 + + # Features 12-19 + push_sign_abs(M2_pos_min) + push_sign_abs(M2_pos_max) + push_sign_abs(M2_neg_min) + push_sign_abs(M2_neg_max) + + if A is not None and self.with_m3: + # Compute row sums + S_pos = A.maximum(0).sum(axis=1) + S_neg = np.abs(A.minimum(0).sum(axis=1)) + + # Divide A by positive and negative row sums + M3_pos = A.multiply(1 / S_pos).tocsr() + M3_neg = A.multiply(1 / S_neg).tocsr() + + # Remove +inf and -inf generated by division by zero + M3_pos.data[~np.isfinite(M3_pos.data)] = 0.0 + M3_neg.data[~np.isfinite(M3_neg.data)] = 0.0 + M3_pos.eliminate_zeros() + M3_neg.eliminate_zeros() + + # Split each matrix into positive and negative parts + M3_pos_pos = M3_pos.maximum(0) + M3_pos_neg = -(M3_pos.minimum(0)) + M3_neg_pos = M3_neg.maximum(0) + M3_neg_neg = -(M3_neg.minimum(0)) + + # Calculate max/min + M3_pos_pos_max, M3_pos_pos_min = maxmin(M3_pos_pos) + M3_pos_neg_max, M3_pos_neg_min = maxmin(M3_pos_neg) + M3_neg_pos_max, M3_neg_pos_min = maxmin(M3_neg_pos) + M3_neg_neg_max, M3_neg_neg_min = maxmin(M3_neg_neg) + + # Features 20-35 + push_sign_abs(M3_pos_pos_max) + push_sign_abs(M3_pos_pos_min) + push_sign_abs(M3_pos_neg_max) + push_sign_abs(M3_pos_neg_min) + push_sign_abs(M3_neg_pos_max) + push_sign_abs(M3_neg_pos_min) + push_sign_abs(M3_neg_neg_max) + push_sign_abs(M3_neg_neg_min) + + # Feature 36: only available during B&B + + # Feature 37 + if values is not None: + push( + np.minimum( + values - np.floor(values), + np.ceil(values) - values, + ) + ) + + # Features 38-43: only available during B&B + + # Feature 44 + if c_sa_up is not None: + assert c_sa_down is not None + + # Features 44 and 46 + push(np.sign(c_sa_up)) + push(np.sign(c_sa_down)) + + # Feature 45 is duplicated + + # Feature 47-48 + push(np.log(c - c_sa_down / np.sign(c))) + push(np.log(c - c_sa_up / np.sign(c))) + + # Features 49-64: only available during B&B + + features = features[:, 0:curr] + _fix_infinity(features) + return features + + def get_constr_features(self, h5: H5File) -> np.ndarray: + raise NotImplemented() + + +def _fix_infinity(m: Optional[np.ndarray]) -> None: + if m is None: + return + masked = np.ma.masked_invalid(m) # type: ignore + max_values = np.max(masked, axis=0) + min_values = np.min(masked, axis=0) + m[:] = np.maximum(np.minimum(m, max_values), min_values) + m[~np.isfinite(m)] = 0.0 diff --git a/miplearn/extractors/__init__.py b/miplearn/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/miplearn/extractors/abstract.py b/miplearn/extractors/abstract.py new file mode 100644 index 0000000..b9ecd8a --- /dev/null +++ b/miplearn/extractors/abstract.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + +import numpy as np + +from miplearn.h5 import H5File + + +class FeaturesExtractor(ABC): + @abstractmethod + def get_instance_features(self, h5: H5File) -> np.ndarray: + pass + + @abstractmethod + def get_var_features(self, h5: H5File) -> np.ndarray: + pass + + @abstractmethod + def get_constr_features(self, h5: H5File) -> np.ndarray: + pass diff --git a/miplearn/extractors/dummy.py b/miplearn/extractors/dummy.py new file mode 100644 index 0000000..766eca2 --- /dev/null +++ b/miplearn/extractors/dummy.py @@ -0,0 +1,24 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +import numpy as np + +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.h5 import H5File + + +class DummyExtractor(FeaturesExtractor): + def get_instance_features(self, h5: H5File) -> np.ndarray: + return np.zeros(1) + + def get_var_features(self, h5: H5File) -> np.ndarray: + var_types = h5.get_array("static_var_types") + assert var_types is not None + n_vars = len(var_types) + return np.zeros((n_vars, 1)) + + def get_constr_features(self, h5: H5File) -> np.ndarray: + constr_sense = h5.get_array("static_constr_sense") + assert constr_sense is not None + n_constr = len(constr_sense) + return np.zeros((n_constr, 1)) diff --git a/miplearn/extractors/fields.py b/miplearn/extractors/fields.py new file mode 100644 index 0000000..477c573 --- /dev/null +++ b/miplearn/extractors/fields.py @@ -0,0 +1,69 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import Optional, List + +import numpy as np + +from miplearn.extractors.abstract import FeaturesExtractor +from miplearn.h5 import H5File + + +class H5FieldsExtractor(FeaturesExtractor): + def __init__( + self, + instance_fields: Optional[List[str]] = None, + var_fields: Optional[List[str]] = None, + constr_fields: Optional[List[str]] = None, + ): + self.instance_fields = instance_fields + self.var_fields = var_fields + self.constr_fields = constr_fields + + def get_instance_features(self, h5: H5File) -> np.ndarray: + if self.instance_fields is None: + raise Exception("No instance fields provided") + x = [] + for field in self.instance_fields: + try: + data = h5.get_array(field) + except ValueError: + data = h5.get_scalar(field) + assert data is not None + x.append(data) + x = np.hstack(x) + assert len(x.shape) == 1 + return x + + def get_var_features(self, h5: H5File) -> np.ndarray: + var_types = h5.get_array("static_var_types") + assert var_types is not None + n_vars = len(var_types) + if self.var_fields is None: + raise Exception("No var fields provided") + return self._extract(h5, self.var_fields, n_vars) + + def get_constr_features(self, h5: H5File) -> np.ndarray: + constr_sense = h5.get_array("static_constr_sense") + assert constr_sense is not None + n_constr = len(constr_sense) + if self.constr_fields is None: + raise Exception("No constr fields provided") + return self._extract(h5, self.constr_fields, n_constr) + + def _extract(self, h5, fields, n_expected): + x = [] + for field in fields: + try: + data = h5.get_array(field) + except ValueError: + v = h5.get_scalar(field) + data = np.repeat(v, n_expected) + assert data is not None + assert len(data.shape) == 1 + assert data.shape[0] == n_expected + x.append(data) + features = np.vstack(x).T + assert len(features.shape) == 2 + assert features.shape[0] == n_expected + return features diff --git a/miplearn/features/extractor.py b/miplearn/features/extractor.py deleted file mode 100644 index d1466f9..0000000 --- a/miplearn/features/extractor.py +++ /dev/null @@ -1,504 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from math import log, isfinite -from typing import TYPE_CHECKING, List, Tuple, Optional - -import numpy as np -from scipy.sparse import coo_matrix - -from miplearn.features.sample import Sample -from miplearn.solvers.internal import LPSolveStats - -if TYPE_CHECKING: - from miplearn.solvers.internal import InternalSolver - from miplearn.instance.base import Instance - - -# noinspection PyPep8Naming -class FeaturesExtractor: - def __init__( - self, - with_sa: bool = True, - with_lhs: bool = True, - ) -> None: - self.with_sa = with_sa - self.with_lhs = with_lhs - self.var_features_user: Optional[np.ndarray] = None - - def extract_after_load_features( - self, - instance: "Instance", - solver: "InternalSolver", - sample: Sample, - ) -> None: - variables = solver.get_variables(with_static=True) - constraints = solver.get_constraints(with_static=True, with_lhs=self.with_lhs) - assert constraints.names is not None - sample.put_array("static_var_lower_bounds", variables.lower_bounds) - sample.put_array("static_var_names", variables.names) - sample.put_array("static_var_obj_coeffs", variables.obj_coeffs) - sample.put_array("static_var_types", variables.types) - sample.put_array("static_var_upper_bounds", variables.upper_bounds) - sample.put_array("static_constr_names", constraints.names) - sample.put_sparse("static_constr_lhs", constraints.lhs) - sample.put_array("static_constr_rhs", constraints.rhs) - sample.put_array("static_constr_senses", constraints.senses) - - # Instance features - self._extract_user_features_instance(instance, sample) - - # Constraint features - ( - constr_features, - constr_categories, - constr_lazy, - ) = FeaturesExtractor._extract_user_features_constrs( - instance, - constraints.names, - ) - sample.put_array("static_constr_features", constr_features) - sample.put_array("static_constr_categories", constr_categories) - sample.put_array("static_constr_lazy", constr_lazy) - sample.put_scalar("static_constr_lazy_count", int(constr_lazy.sum())) - - # Variable features - ( - vars_features_user, - var_categories, - ) = self._extract_user_features_vars(instance, sample) - self.var_features_user = vars_features_user - sample.put_array("static_var_categories", var_categories) - assert variables.lower_bounds is not None - assert variables.obj_coeffs is not None - assert variables.upper_bounds is not None - sample.put_array( - "static_var_features", - np.hstack( - [ - vars_features_user, - self._compute_AlvLouWeh2017( - A=constraints.lhs, - b=constraints.rhs, - c=variables.obj_coeffs, - ), - ] - ), - ) - - def extract_after_lp_features( - self, - solver: "InternalSolver", - sample: Sample, - lp_stats: LPSolveStats, - ) -> None: - for (k, v) in lp_stats.__dict__.items(): - sample.put_scalar(k, v) - variables = solver.get_variables(with_static=False, with_sa=self.with_sa) - constraints = solver.get_constraints(with_static=False, with_sa=self.with_sa) - sample.put_array("lp_var_basis_status", variables.basis_status) - sample.put_array("lp_var_reduced_costs", variables.reduced_costs) - sample.put_array("lp_var_sa_lb_down", variables.sa_lb_down) - sample.put_array("lp_var_sa_lb_up", variables.sa_lb_up) - sample.put_array("lp_var_sa_obj_down", variables.sa_obj_down) - sample.put_array("lp_var_sa_obj_up", variables.sa_obj_up) - sample.put_array("lp_var_sa_ub_down", variables.sa_ub_down) - sample.put_array("lp_var_sa_ub_up", variables.sa_ub_up) - sample.put_array("lp_var_values", variables.values) - sample.put_array("lp_constr_basis_status", constraints.basis_status) - sample.put_array("lp_constr_dual_values", constraints.dual_values) - sample.put_array("lp_constr_sa_rhs_down", constraints.sa_rhs_down) - sample.put_array("lp_constr_sa_rhs_up", constraints.sa_rhs_up) - sample.put_array("lp_constr_slacks", constraints.slacks) - - # Variable features - lp_var_features_list = [] - for f in [ - self.var_features_user, - self._compute_AlvLouWeh2017( - A=sample.get_sparse("static_constr_lhs"), - b=sample.get_array("static_constr_rhs"), - c=sample.get_array("static_var_obj_coeffs"), - c_sa_up=variables.sa_obj_up, - c_sa_down=variables.sa_obj_down, - values=variables.values, - ), - ]: - if f is not None: - lp_var_features_list.append(f) - for f in [ - variables.reduced_costs, - variables.sa_lb_down, - variables.sa_lb_up, - variables.sa_obj_down, - variables.sa_obj_up, - variables.sa_ub_down, - variables.sa_ub_up, - variables.values, - ]: - if f is not None: - lp_var_features_list.append(f.reshape(-1, 1)) - lp_var_features = np.hstack(lp_var_features_list) - _fix_infinity(lp_var_features) - sample.put_array("lp_var_features", lp_var_features) - - # Constraint features - lp_constr_features_list = [] - for f in [sample.get_array("static_constr_features")]: - if f is not None: - lp_constr_features_list.append(f) - for f in [ - sample.get_array("lp_constr_dual_values"), - sample.get_array("lp_constr_sa_rhs_down"), - sample.get_array("lp_constr_sa_rhs_up"), - sample.get_array("lp_constr_slacks"), - ]: - if f is not None: - lp_constr_features_list.append(f.reshape(-1, 1)) - lp_constr_features = np.hstack(lp_constr_features_list) - _fix_infinity(lp_constr_features) - sample.put_array("lp_constr_features", lp_constr_features) - - # Build lp_instance_features - static_instance_features = sample.get_array("static_instance_features") - assert static_instance_features is not None - assert lp_stats.lp_value is not None - assert lp_stats.lp_wallclock_time is not None - sample.put_array( - "lp_instance_features", - np.hstack( - [ - static_instance_features, - lp_stats.lp_value, - lp_stats.lp_wallclock_time, - ] - ), - ) - - def extract_after_mip_features( - self, - solver: "InternalSolver", - sample: Sample, - ) -> None: - variables = solver.get_variables(with_static=False, with_sa=False) - constraints = solver.get_constraints(with_static=False, with_sa=False) - sample.put_array("mip_var_values", variables.values) - sample.put_array("mip_constr_slacks", constraints.slacks) - - # noinspection DuplicatedCode - def _extract_user_features_vars( - self, - instance: "Instance", - sample: Sample, - ) -> Tuple[np.ndarray, np.ndarray]: - # Query variable names - var_names = sample.get_array("static_var_names") - assert var_names is not None - - # Query variable features - var_features = instance.get_variable_features(var_names) - assert isinstance(var_features, np.ndarray), ( - f"Variable features must be a numpy array. " - f"Found {var_features.__class__} instead." - ) - assert len(var_features.shape) == 2, ( - f"Variable features must be 2-dimensional array. " - f"Found array with shape {var_features.shape} instead." - ) - assert var_features.shape[0] == len(var_names), ( - f"Variable features must have exactly {len(var_names)} rows. " - f"Found {var_features.shape[0]} rows instead." - ) - assert var_features.dtype.kind in ["f"], ( - f"Variable features must be floating point numbers. " - f"Found {var_features.dtype} instead." - ) - - # Query variable categories - var_categories = instance.get_variable_categories(var_names) - assert isinstance(var_categories, np.ndarray), ( - f"Variable categories must be a numpy array. " - f"Found {var_categories.__class__} instead." - ) - assert len(var_categories.shape) == 1, ( - f"Variable categories must be a vector. " - f"Found array with shape {var_categories.shape} instead." - ) - assert len(var_categories) == len(var_names), ( - f"Variable categories must have exactly {len(var_names)} elements. " - f"Found {var_categories.shape[0]} elements instead." - ) - assert var_categories.dtype.kind == "S", ( - f"Variable categories must be a numpy array with dtype='S'. " - f"Found {var_categories.dtype} instead." - ) - return var_features, var_categories - - # noinspection DuplicatedCode - @classmethod - def _extract_user_features_constrs( - cls, - instance: "Instance", - constr_names: np.ndarray, - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - # Query constraint features - constr_features = instance.get_constraint_features(constr_names) - assert isinstance(constr_features, np.ndarray), ( - f"get_constraint_features must return a numpy array. " - f"Found {constr_features.__class__} instead." - ) - assert len(constr_features.shape) == 2, ( - f"get_constraint_features must return a 2-dimensional array. " - f"Found array with shape {constr_features.shape} instead." - ) - assert constr_features.shape[0] == len(constr_names), ( - f"get_constraint_features must return an array with {len(constr_names)} " - f"rows. Found {constr_features.shape[0]} rows instead." - ) - assert constr_features.dtype.kind in ["f"], ( - f"get_constraint_features must return floating point numbers. " - f"Found {constr_features.dtype} instead." - ) - - # Query constraint categories - constr_categories = instance.get_constraint_categories(constr_names) - assert isinstance(constr_categories, np.ndarray), ( - f"get_constraint_categories must return a numpy array. " - f"Found {constr_categories.__class__} instead." - ) - assert len(constr_categories.shape) == 1, ( - f"get_constraint_categories must return a vector. " - f"Found array with shape {constr_categories.shape} instead." - ) - assert len(constr_categories) == len(constr_names), ( - f"get_constraint_categories must return a vector with {len(constr_names)} " - f"elements. Found {constr_categories.shape[0]} elements instead." - ) - assert constr_categories.dtype.kind == "S", ( - f"get_constraint_categories must return a numpy array with dtype='S'. " - f"Found {constr_categories.dtype} instead." - ) - - # Query constraint lazy attribute - constr_lazy = instance.are_constraints_lazy(constr_names) - assert isinstance(constr_lazy, np.ndarray), ( - f"are_constraints_lazy must return a numpy array. " - f"Found {constr_lazy.__class__} instead." - ) - assert len(constr_lazy.shape) == 1, ( - f"are_constraints_lazy must return a vector. " - f"Found array with shape {constr_lazy.shape} instead." - ) - assert constr_lazy.shape[0] == len(constr_names), ( - f"are_constraints_lazy must return a vector with {len(constr_names)} " - f"elements. Found {constr_lazy.shape[0]} elements instead." - ) - assert constr_lazy.dtype.kind == "b", ( - f"are_constraints_lazy must return a boolean array. " - f"Found {constr_lazy.dtype} instead." - ) - - return constr_features, constr_categories, constr_lazy - - def _extract_user_features_instance( - self, - instance: "Instance", - sample: Sample, - ) -> None: - features = instance.get_instance_features() - assert isinstance(features, np.ndarray), ( - f"Instance features must be a numpy array. " - f"Found {features.__class__} instead." - ) - assert len(features.shape) == 1, ( - f"Instance features must be a vector. " - f"Found array with shape {features.shape} instead." - ) - assert features.dtype.kind in [ - "f" - ], f"Instance features have unsupported {features.dtype}" - sample.put_array("static_instance_features", features) - - @classmethod - def _compute_AlvLouWeh2017( - cls, - A: Optional[coo_matrix] = None, - b: Optional[np.ndarray] = None, - c: Optional[np.ndarray] = None, - c_sa_down: Optional[np.ndarray] = None, - c_sa_up: Optional[np.ndarray] = None, - values: Optional[np.ndarray] = None, - with_m1: bool = True, - with_m2: bool = True, - with_m3: bool = True, - ) -> np.ndarray: - """ - Computes static variable features described in: - Alvarez, A. M., Louveaux, Q., & Wehenkel, L. (2017). A machine learning-based - approximation of strong branching. INFORMS Journal on Computing, 29(1), - 185-195. - """ - assert b is not None - assert c is not None - nvars = len(c) - curr = 0 - max_n_features = 40 - features = np.zeros((nvars, max_n_features)) - - def push(v: np.ndarray) -> None: - nonlocal curr - features[:, curr] = v - curr += 1 - - def push_sign_abs(v: np.ndarray) -> None: - push(np.sign(v)) - push(np.abs(v)) - - def maxmin(M: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: - M_max = np.ravel(M.max(axis=0).todense()) - M_min = np.ravel(M.min(axis=0).todense()) - return M_max, M_min - - with np.errstate(divide="ignore", invalid="ignore"): - # Feature 1 - push(np.sign(c)) - - # Feature 2 - c_pos_sum = c[c > 0].sum() - push(np.abs(c) / c_pos_sum) - - # Feature 3 - c_neg_sum = -c[c < 0].sum() - push(np.abs(c) / c_neg_sum) - - if A is not None and with_m1: - # Compute A_ji / |b_j| - M1 = A.T.multiply(1.0 / np.abs(b)).T.tocsr() - - # Select rows with positive b_j and compute max/min - M1_pos = M1[b > 0, :] - if M1_pos.shape[0] > 0: - M1_pos_max = M1_pos.max(axis=0).todense() - M1_pos_min = M1_pos.min(axis=0).todense() - else: - M1_pos_max = np.zeros(nvars) - M1_pos_min = np.zeros(nvars) - - # Select rows with negative b_j and compute max/min - M1_neg = M1[b < 0, :] - if M1_neg.shape[0] > 0: - M1_neg_max = M1_neg.max(axis=0).todense() - M1_neg_min = M1_neg.min(axis=0).todense() - else: - M1_neg_max = np.zeros(nvars) - M1_neg_min = np.zeros(nvars) - - # Features 4-11 - push_sign_abs(M1_pos_min) - push_sign_abs(M1_pos_max) - push_sign_abs(M1_neg_min) - push_sign_abs(M1_neg_max) - - if A is not None and with_m2: - # Compute |c_i| / A_ij - M2 = A.power(-1).multiply(np.abs(c)).tocsc() - - # Compute max/min - M2_max, M2_min = maxmin(M2) - - # Make copies of M2 and erase elements based on sign(c) - M2_pos_max = M2_max.copy() - M2_neg_max = M2_max.copy() - M2_pos_min = M2_min.copy() - M2_neg_min = M2_min.copy() - M2_pos_max[c <= 0] = 0 - M2_pos_min[c <= 0] = 0 - M2_neg_max[c >= 0] = 0 - M2_neg_min[c >= 0] = 0 - - # Features 12-19 - push_sign_abs(M2_pos_min) - push_sign_abs(M2_pos_max) - push_sign_abs(M2_neg_min) - push_sign_abs(M2_neg_max) - - if A is not None and with_m3: - # Compute row sums - S_pos = A.maximum(0).sum(axis=1) - S_neg = np.abs(A.minimum(0).sum(axis=1)) - - # Divide A by positive and negative row sums - M3_pos = A.multiply(1 / S_pos).tocsr() - M3_neg = A.multiply(1 / S_neg).tocsr() - - # Remove +inf and -inf generated by division by zero - M3_pos.data[~np.isfinite(M3_pos.data)] = 0.0 - M3_neg.data[~np.isfinite(M3_neg.data)] = 0.0 - M3_pos.eliminate_zeros() - M3_neg.eliminate_zeros() - - # Split each matrix into positive and negative parts - M3_pos_pos = M3_pos.maximum(0) - M3_pos_neg = -(M3_pos.minimum(0)) - M3_neg_pos = M3_neg.maximum(0) - M3_neg_neg = -(M3_neg.minimum(0)) - - # Calculate max/min - M3_pos_pos_max, M3_pos_pos_min = maxmin(M3_pos_pos) - M3_pos_neg_max, M3_pos_neg_min = maxmin(M3_pos_neg) - M3_neg_pos_max, M3_neg_pos_min = maxmin(M3_neg_pos) - M3_neg_neg_max, M3_neg_neg_min = maxmin(M3_neg_neg) - - # Features 20-35 - push_sign_abs(M3_pos_pos_max) - push_sign_abs(M3_pos_pos_min) - push_sign_abs(M3_pos_neg_max) - push_sign_abs(M3_pos_neg_min) - push_sign_abs(M3_neg_pos_max) - push_sign_abs(M3_neg_pos_min) - push_sign_abs(M3_neg_neg_max) - push_sign_abs(M3_neg_neg_min) - - # Feature 36: only available during B&B - - # Feature 37 - if values is not None: - push( - np.minimum( - values - np.floor(values), - np.ceil(values) - values, - ) - ) - - # Features 38-43: only available during B&B - - # Feature 44 - if c_sa_up is not None: - assert c_sa_down is not None - - # Features 44 and 46 - push(np.sign(c_sa_up)) - push(np.sign(c_sa_down)) - - # Feature 45 is duplicated - - # Feature 47-48 - push(np.log(c - c_sa_down / np.sign(c))) - push(np.log(c - c_sa_up / np.sign(c))) - - # Features 49-64: only available during B&B - - features = features[:, 0:curr] - _fix_infinity(features) - return features - - -def _fix_infinity(m: Optional[np.ndarray]) -> None: - if m is None: - return - masked = np.ma.masked_invalid(m) - max_values = np.max(masked, axis=0) - min_values = np.min(masked, axis=0) - m[:] = np.maximum(np.minimum(m, max_values), min_values) - m[~np.isfinite(m)] = 0.0 diff --git a/miplearn/features/sample.py b/miplearn/h5.py similarity index 58% rename from miplearn/features/sample.py rename to miplearn/h5.py index cd98b0f..bb7c70b 100644 --- a/miplearn/features/sample.py +++ b/miplearn/h5.py @@ -1,19 +1,18 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -import warnings -from abc import ABC, abstractmethod -from copy import deepcopy -from typing import Dict, Optional, Any, Union, List, Tuple, cast, Set -from scipy.sparse import coo_matrix + +from types import TracebackType +from typing import Optional, Any, Union, List, Type, Literal import h5py import numpy as np -from h5py import Dataset -from overrides import overrides +from scipy.sparse import coo_matrix Bytes = Union[bytes, bytearray] + Scalar = Union[None, bool, str, int, float] + Vector = Union[ None, List[bool], @@ -23,6 +22,7 @@ Vector = Union[ List[Optional[str]], np.ndarray, ] + VectorList = Union[ List[List[bool]], List[List[str]], @@ -35,115 +35,7 @@ VectorList = Union[ ] -class Sample(ABC): - """Abstract dictionary-like class that stores training data.""" - - @abstractmethod - def get_scalar(self, key: str) -> Optional[Any]: - pass - - @abstractmethod - def put_scalar(self, key: str, value: Scalar) -> None: - pass - - @abstractmethod - def put_array(self, key: str, value: Optional[np.ndarray]) -> None: - pass - - @abstractmethod - def get_array(self, key: str) -> Optional[np.ndarray]: - pass - - @abstractmethod - def put_sparse(self, key: str, value: coo_matrix) -> None: - pass - - @abstractmethod - def get_sparse(self, key: str) -> Optional[coo_matrix]: - pass - - def _assert_is_scalar(self, value: Any) -> None: - if value is None: - return - if isinstance(value, (str, bool, int, float, bytes, np.bytes_)): - return - assert False, f"scalar expected; found instead: {value} ({value.__class__})" - - def _assert_is_array(self, value: np.ndarray) -> None: - assert isinstance( - value, np.ndarray - ), f"np.ndarray expected; found instead: {value.__class__}" - assert value.dtype.kind in "biufS", f"Unsupported dtype: {value.dtype}" - - def _assert_is_sparse(self, value: Any) -> None: - assert isinstance( - value, coo_matrix - ), f"coo_matrix expected; found: {value.__class__}" - self._assert_is_array(value.data) - - -class MemorySample(Sample): - """Dictionary-like class that stores training data in-memory.""" - - def __init__( - self, - data: Optional[Dict[str, Any]] = None, - ) -> None: - if data is None: - data = {} - self._data: Dict[str, Any] = data - - @overrides - def get_scalar(self, key: str) -> Optional[Any]: - return self._get(key) - - @overrides - def put_scalar(self, key: str, value: Scalar) -> None: - if value is None: - return - self._assert_is_scalar(value) - self._put(key, value) - - def _get(self, key: str) -> Optional[Any]: - if key in self._data: - return self._data[key] - else: - return None - - def _put(self, key: str, value: Any) -> None: - self._data[key] = value - - @overrides - def put_array(self, key: str, value: Optional[np.ndarray]) -> None: - if value is None: - return - self._assert_is_array(value) - self._put(key, value) - - @overrides - def get_array(self, key: str) -> Optional[np.ndarray]: - return cast(Optional[np.ndarray], self._get(key)) - - @overrides - def put_sparse(self, key: str, value: coo_matrix) -> None: - if value is None: - return - self._assert_is_sparse(value) - self._put(key, value) - - @overrides - def get_sparse(self, key: str) -> Optional[coo_matrix]: - return cast(Optional[coo_matrix], self._get(key)) - - -class Hdf5Sample(Sample): - """ - Dictionary-like class that stores training data in an HDF5 file. - - Unlike MemorySample, this class only loads to memory the parts of the data set that - are actually accessed, and therefore it is more scalable. - """ - +class H5File: def __init__( self, filename: str, @@ -151,7 +43,6 @@ class Hdf5Sample(Sample): ) -> None: self.file = h5py.File(filename, mode, libver="latest") - @overrides def get_scalar(self, key: str) -> Optional[Any]: if key not in self.file: return None @@ -164,7 +55,6 @@ class Hdf5Sample(Sample): else: return ds[()].tolist() - @overrides def put_scalar(self, key: str, value: Any) -> None: if value is None: return @@ -173,7 +63,6 @@ class Hdf5Sample(Sample): del self.file[key] self.file.create_dataset(key, data=value) - @overrides def put_array(self, key: str, value: Optional[np.ndarray]) -> None: if value is None: return @@ -184,13 +73,11 @@ class Hdf5Sample(Sample): del self.file[key] return self.file.create_dataset(key, data=value, compression="gzip") - @overrides def get_array(self, key: str) -> Optional[np.ndarray]: if key not in self.file: return None return self.file[key][:] - @overrides def put_sparse(self, key: str, value: coo_matrix) -> None: if value is None: return @@ -199,7 +86,6 @@ class Hdf5Sample(Sample): self.put_array(f"{key}_col", value.col) self.put_array(f"{key}_data", value.data) - @overrides def get_sparse(self, key: str) -> Optional[coo_matrix]: row = self.get_array(f"{key}_row") if row is None: @@ -225,8 +111,36 @@ class Hdf5Sample(Sample): ), f"bytes expected; found: {value.__class__}" # type: ignore self.put_array(key, np.frombuffer(value, dtype="uint8")) - def __enter__(self): + def close(self): + self.file.close() + + def __enter__(self) -> "H5File": return self - def __exit__(self, type, value, traceback): + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> Literal[False]: self.file.close() + return False + + def _assert_is_scalar(self, value: Any) -> None: + if value is None: + return + if isinstance(value, (str, bool, int, float, bytes, np.bytes_)): + return + assert False, f"scalar expected; found instead: {value} ({value.__class__})" + + def _assert_is_array(self, value: np.ndarray) -> None: + assert isinstance( + value, np.ndarray + ), f"np.ndarray expected; found instead: {value.__class__}" + assert value.dtype.kind in "biufS", f"Unsupported dtype: {value.dtype}" + + def _assert_is_sparse(self, value: Any) -> None: + assert isinstance( + value, coo_matrix + ), f"coo_matrix expected; found: {value.__class__}" + self._assert_is_array(value.data) diff --git a/miplearn/instance/__init__.py b/miplearn/instance/__init__.py deleted file mode 100644 index 5fbccb1..0000000 --- a/miplearn/instance/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. diff --git a/miplearn/instance/base.py b/miplearn/instance/base.py deleted file mode 100644 index 8ddcba1..0000000 --- a/miplearn/instance/base.py +++ /dev/null @@ -1,204 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from abc import ABC, abstractmethod -from typing import Any, List, TYPE_CHECKING, Dict - -import numpy as np - -from miplearn.features.sample import Sample, MemorySample -from miplearn.types import ConstraintName - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from miplearn.solvers.learning import InternalSolver - - -# noinspection PyMethodMayBeStatic -class Instance(ABC): - """ - Abstract class holding all the data necessary to generate a concrete model of the - proble. - - In the knapsack problem, for example, this class could hold the number of items, - their weights and costs, as well as the size of the knapsack. Objects - implementing this class are able to convert themselves into a concrete - optimization model, which can be optimized by a solver, or into arrays of - features, which can be provided as inputs to machine learning models. - """ - - def __init__(self) -> None: - self._samples: List[Sample] = [] - - @abstractmethod - def to_model(self) -> Any: - """ - Returns the optimization model corresponding to this instance. - """ - pass - - def get_instance_features(self) -> np.ndarray: - """ - Returns a 1-dimensional array of (numerical) features describing the - entire instance. - - The array is used by LearningSolver to determine how similar two instances - are. It may also be used to predict, in combination with variable-specific - features, the values of binary decision variables in the problem. - - There is not necessarily a one-to-one correspondence between models and - instance features: the features may encode only part of the data necessary to - generate the complete model. Features may also be statistics computed from - the original data. For example, in the knapsack problem, an implementation - may decide to provide as instance features only the average weights, average - prices, number of items and the size of the knapsack. - - The returned array MUST have the same length for all relevant instances of - the problem. If two instances map into arrays of different lengths, - they cannot be solved by the same LearningSolver object. - - By default, returns [0.0]. - """ - return np.zeros(1) - - def get_variable_features(self, names: np.ndarray) -> np.ndarray: - """ - Returns dictionary mapping the name of each variable to a (1-dimensional) list - of numerical features describing a particular decision variable. - - In combination with instance features, variable features are used by - LearningSolver to predict, among other things, the optimal value of each - decision variable before the optimization takes place. In the knapsack - problem, for example, an implementation could provide as variable features - the weight and the price of a specific item. - - Like instance features, the arrays returned by this method MUST have the same - length for all variables within the same category, for all relevant instances - of the problem. - - If features are not provided for a given variable, MIPLearn will use a - default set of features. - - By default, returns [[0.0], ..., [0.0]]. - """ - return np.zeros((len(names), 1)) - - def get_variable_categories(self, names: np.ndarray) -> np.ndarray: - """ - Returns a dictionary mapping the name of each variable to its category. - - If two variables have the same category, LearningSolver will use the same - internal ML model to predict the values of both variables. If a variable is not - listed in the dictionary, ML models will ignore the variable. - - By default, returns `names`. - """ - return names - - def get_constraint_features(self, names: np.ndarray) -> np.ndarray: - return np.zeros((len(names), 1)) - - def get_constraint_categories(self, names: np.ndarray) -> np.ndarray: - return names - - def has_dynamic_lazy_constraints(self) -> bool: - return False - - def are_constraints_lazy(self, names: np.ndarray) -> np.ndarray: - return np.zeros(len(names), dtype=bool) - - def find_violated_lazy_constraints( - self, - solver: "InternalSolver", - model: Any, - ) -> Dict[ConstraintName, Any]: - """ - Returns lazy constraint violations found for the current solution. - - After solving a model, LearningSolver will ask the instance to identify which - lazy constraints are violated by the current solution. For each identified - violation, LearningSolver will then call the enforce_lazy_constraint and - resolve the problem. The process repeats until no further lazy constraint - violations are found. - - Violations should be returned in a dictionary mapping the name of the violation - to some user-specified data that allows the instance to unambiguously generate - the lazy constraints at a later time. In the Traveling Salesman Problem, for - example, this function could return a dictionary identifying violated subtour - inequalities. More concretely, it could return: - { - "s1": [1, 2, 3], - "s2": [4, 5, 6, 7], - } - where "s1" and "s2" are the names of the subtours, and [1,2,3] and [4,5,6,7] - are the cities in each subtour. The names of the violations should be kept - stable across instances. In our example, "s1" should always correspond to - [1,2,3] across all instances. The user-provided data should be picklable. - - The current solution can be queried with `solver.get_solution()`. If the solver - is configured to use lazy callbacks, this solution may be non-integer. - - For a concrete example, see TravelingSalesmanInstance. - """ - return {} - - def enforce_lazy_constraint( - self, - solver: "InternalSolver", - model: Any, - violation_data: Any, - ) -> None: - """ - Adds constraints to the model to ensure that the given violation is fixed. - - This method is typically called immediately after - `find_violated_lazy_constraints`. The argument `violation_data` is the - user-provided data, previously returned by `find_violated_lazy_constraints`. - In the Traveling Salesman Problem, for example, it could be a list of cities - in the subtour. - - After some training, LearningSolver may decide to proactively build some lazy - constraints at the beginning of the optimization process, before a solution - is even available. In this case, `enforce_lazy_constraints` will be called - without a corresponding call to `find_violated_lazy_constraints`. - - For a concrete example, see TravelingSalesmanInstance. - """ - pass - - def has_user_cuts(self) -> bool: - return False - - def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: - return {} - - def enforce_user_cut( - self, - solver: "InternalSolver", - model: Any, - violation_data: Any, - ) -> Any: - return None - - def load(self) -> None: - pass - - def free(self) -> None: - pass - - def flush(self) -> None: - """ - Save any pending changes made to the instance to the underlying data store. - """ - pass - - def get_samples(self) -> List[Sample]: - return self._samples - - def create_sample(self) -> Sample: - sample = MemorySample() - self._samples.append(sample) - return sample diff --git a/miplearn/instance/file.py b/miplearn/instance/file.py deleted file mode 100644 index c1d4a78..0000000 --- a/miplearn/instance/file.py +++ /dev/null @@ -1,131 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -import gc -import os -import pickle -from typing import Any, Optional, List, Dict, TYPE_CHECKING - -import numpy as np -from overrides import overrides - -from miplearn.features.sample import Hdf5Sample, Sample -from miplearn.instance.base import Instance -from miplearn.types import ConstraintName - -if TYPE_CHECKING: - from miplearn.solvers.learning import InternalSolver - - -class FileInstance(Instance): - def __init__(self, filename: str) -> None: - super().__init__() - assert os.path.exists(filename), f"File not found: {filename}" - self.h5 = Hdf5Sample(filename) - self.instance: Optional[Instance] = None - - # Delegation - # ------------------------------------------------------------------------- - @overrides - def to_model(self) -> Any: - assert self.instance is not None - return self.instance.to_model() - - @overrides - def get_instance_features(self) -> np.ndarray: - assert self.instance is not None - return self.instance.get_instance_features() - - @overrides - def get_variable_features(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_variable_features(names) - - @overrides - def get_variable_categories(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_variable_categories(names) - - @overrides - def get_constraint_features(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_constraint_features(names) - - @overrides - def get_constraint_categories(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_constraint_categories(names) - - @overrides - def has_dynamic_lazy_constraints(self) -> bool: - assert self.instance is not None - return self.instance.has_dynamic_lazy_constraints() - - @overrides - def are_constraints_lazy(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.are_constraints_lazy(names) - - @overrides - def find_violated_lazy_constraints( - self, - solver: "InternalSolver", - model: Any, - ) -> Dict[ConstraintName, Any]: - assert self.instance is not None - return self.instance.find_violated_lazy_constraints(solver, model) - - @overrides - def enforce_lazy_constraint( - self, - solver: "InternalSolver", - model: Any, - violation_data: Any, - ) -> None: - assert self.instance is not None - self.instance.enforce_lazy_constraint(solver, model, violation_data) - - @overrides - def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: - assert self.instance is not None - return self.instance.find_violated_user_cuts(model) - - @overrides - def enforce_user_cut( - self, - solver: "InternalSolver", - model: Any, - violation_data: Any, - ) -> None: - assert self.instance is not None - self.instance.enforce_user_cut(solver, model, violation_data) - - # Input & Output - # ------------------------------------------------------------------------- - @overrides - def free(self) -> None: - self.instance = None - gc.collect() - - @overrides - def load(self) -> None: - if self.instance is not None: - return - pkl = self.h5.get_bytes("pickled") - assert pkl is not None - self.instance = pickle.loads(pkl) - assert isinstance(self.instance, Instance) - - @classmethod - def save(cls, instance: Instance, filename: str) -> None: - h5 = Hdf5Sample(filename, mode="w") - instance_pkl = pickle.dumps(instance) - h5.put_bytes("pickled", instance_pkl) - - @overrides - def create_sample(self) -> Sample: - return self.h5 - - @overrides - def get_samples(self) -> List[Sample]: - return [self.h5] diff --git a/miplearn/instance/picklegz.py b/miplearn/instance/picklegz.py deleted file mode 100644 index 74661a9..0000000 --- a/miplearn/instance/picklegz.py +++ /dev/null @@ -1,195 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import gc -import gzip -import os -import pickle -from typing import Optional, Any, List, cast, IO, TYPE_CHECKING, Dict, Callable - -import numpy as np -from overrides import overrides - -from miplearn.features.sample import Sample -from miplearn.instance.base import Instance -from miplearn.types import ConstraintName -from tqdm.auto import tqdm -from p_tqdm import p_umap - -if TYPE_CHECKING: - from miplearn.solvers.learning import InternalSolver - - -class PickleGzInstance(Instance): - """ - An instance backed by a gzipped pickle file. - - The instance is only loaded to memory after an operation is called (for example, - `to_model`). - - Parameters - ---------- - filename: str - Path of the gzipped pickle file that should be loaded. - """ - - # noinspection PyMissingConstructor - def __init__(self, filename: str) -> None: - assert os.path.exists(filename), f"File not found: {filename}" - self.instance: Optional[Instance] = None - self.filename: str = filename - - @overrides - def to_model(self) -> Any: - assert self.instance is not None - return self.instance.to_model() - - @overrides - def get_instance_features(self) -> np.ndarray: - assert self.instance is not None - return self.instance.get_instance_features() - - @overrides - def get_variable_features(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_variable_features(names) - - @overrides - def get_variable_categories(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_variable_categories(names) - - @overrides - def get_constraint_features(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_constraint_features(names) - - @overrides - def get_constraint_categories(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.get_constraint_categories(names) - - @overrides - def has_dynamic_lazy_constraints(self) -> bool: - assert self.instance is not None - return self.instance.has_dynamic_lazy_constraints() - - @overrides - def are_constraints_lazy(self, names: np.ndarray) -> np.ndarray: - assert self.instance is not None - return self.instance.are_constraints_lazy(names) - - @overrides - def find_violated_lazy_constraints( - self, - solver: "InternalSolver", - model: Any, - ) -> Dict[ConstraintName, Any]: - assert self.instance is not None - return self.instance.find_violated_lazy_constraints(solver, model) - - @overrides - def enforce_lazy_constraint( - self, - solver: "InternalSolver", - model: Any, - violation_data: Any, - ) -> None: - assert self.instance is not None - self.instance.enforce_lazy_constraint(solver, model, violation_data) - - @overrides - def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: - assert self.instance is not None - return self.instance.find_violated_user_cuts(model) - - @overrides - def enforce_user_cut( - self, - solver: "InternalSolver", - model: Any, - violation_name: Any, - ) -> None: - assert self.instance is not None - self.instance.enforce_user_cut(solver, model, violation_name) - - @overrides - def load(self) -> None: - if self.instance is None: - obj = read_pickle_gz(self.filename) - assert isinstance(obj, Instance) - self.instance = obj - - @overrides - def free(self) -> None: - self.instance = None # type: ignore - gc.collect() - - @overrides - def flush(self) -> None: - write_pickle_gz(self.instance, self.filename) - - @overrides - def get_samples(self) -> List[Sample]: - assert self.instance is not None - return self.instance.get_samples() - - @overrides - def create_sample(self) -> Sample: - assert self.instance is not None - return self.instance.create_sample() - - -def write_pickle_gz(obj: Any, filename: str) -> None: - os.makedirs(os.path.dirname(filename), exist_ok=True) - with gzip.GzipFile(filename, "wb") as file: - pickle.dump(obj, cast(IO[bytes], file)) - - -def read_pickle_gz(filename: str) -> Any: - with gzip.GzipFile(filename, "rb") as file: - return pickle.load(cast(IO[bytes], file)) - - -def write_pickle_gz_multiple(objs: List[Any], dirname: str) -> None: - for (i, obj) in enumerate(objs): - write_pickle_gz(obj, f"{dirname}/{i:05d}.pkl.gz") - - -def save( - objs: List[Any], - dirname: str, - progress: bool = False, - n_jobs: int = 1, -) -> List[str]: - """ - Saves the provided objects to gzipped pickled files. Files are named sequentially - as `dirname/00000.pkl.gz`, `dirname/00001.pkl.gz`, etc. - - Parameters - ---------- - progress: bool - If True, show progress bar - objs: List[any] - List of files to save - dirname: str - Output directory - - Returns - ------- - List containing the relative paths of the saved files. - """ - - def _process(obj, filename): - write_pickle_gz(obj, filename) - - filenames = [f"{dirname}/{i:05d}.pkl.gz" for i in range(len(objs))] - p_umap(_process, objs, filenames, num_cpus=n_jobs) - return filenames - - -def load(filename: str, build_model: Callable) -> Any: - with gzip.GzipFile(filename, "rb") as file: - data = pickle.load(cast(IO[bytes], file)) - return build_model(data) diff --git a/miplearn/io.py b/miplearn/io.py new file mode 100644 index 0000000..635be8c --- /dev/null +++ b/miplearn/io.py @@ -0,0 +1,92 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from gzip import GzipFile +import os +import pickle +import sys +from typing import IO, Any, Callable, List, cast, TextIO + +from .parallel import p_umap +import shutil + + +class _RedirectOutput: + def __init__(self, streams: List[Any]) -> None: + self.streams = streams + + def write(self, data: Any) -> None: + for stream in self.streams: + stream.write(data) + + def flush(self) -> None: + for stream in self.streams: + stream.flush() + + def __enter__(self) -> Any: + self._original_stdout = sys.stdout + self._original_stderr = sys.stderr + sys.stdout = cast(TextIO, self) + sys.stderr = cast(TextIO, self) + return self + + def __exit__( + self, + _type: Any, + _value: Any, + _traceback: Any, + ) -> None: + sys.stdout = self._original_stdout + sys.stderr = self._original_stderr + + +def write_pkl_gz( + objs: List[Any], + dirname: str, + prefix: str = "", + n_jobs: int = 1, + progress: bool = False, +) -> List[str]: + filenames = [f"{dirname}/{prefix}{i:05d}.pkl.gz" for i in range(len(objs))] + + def _process(i: int) -> None: + filename = filenames[i] + obj = objs[i] + os.makedirs(os.path.dirname(filename), exist_ok=True) + with GzipFile(filename, "wb") as file: + pickle.dump(obj, cast(IO[bytes], file)) + + if n_jobs > 1: + p_umap( + _process, + range(len(objs)), + smoothing=0, + num_cpus=n_jobs, + maxtasksperchild=None, + disable=not progress, + ) + else: + for i in range(len(objs)): + _process(i) + return filenames + + +def gzip(filename: str) -> None: + with open(filename, "rb") as input_file: + with GzipFile(f"{filename}.gz", "wb") as output_file: + shutil.copyfileobj(input_file, output_file) + os.remove(filename) + + +def read_pkl_gz(filename: str) -> Any: + with GzipFile(filename, "rb") as file: + return pickle.load(cast(IO[bytes], file)) + + +def _to_h5_filename(data_filename: str) -> str: + output = f"{data_filename}.h5" + output = output.replace(".pkl.gz.h5", ".h5") + output = output.replace(".pkl.h5", ".h5") + output = output.replace(".jld2.h5", ".h5") + return output diff --git a/miplearn/log.py b/miplearn/log.py deleted file mode 100644 index a58a6df..0000000 --- a/miplearn/log.py +++ /dev/null @@ -1,74 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -import sys -import time -import traceback -import warnings -from typing import Dict, Any, Optional - -_formatwarning = warnings.formatwarning - - -class TimeFormatter(logging.Formatter): - def __init__( - self, - start_time: float, - log_colors: Dict[str, str], - ) -> None: - super().__init__() - self.start_time = start_time - self.log_colors = log_colors - - def format(self, record: logging.LogRecord) -> str: - if record.levelno >= logging.ERROR: - color = self.log_colors["red"] - elif record.levelno >= logging.WARNING: - color = self.log_colors["yellow"] - else: - color = self.log_colors["green"] - return "%s[%12.3f]%s %s" % ( - color, - record.created - self.start_time, - self.log_colors["reset"], - record.getMessage(), - ) - - -def formatwarning_tb(*args: Any, **kwargs: Any) -> str: - s = _formatwarning(*args, **kwargs) - tb = traceback.format_stack() - s += "".join(tb[:-1]) - return s - - -def setup_logger( - start_time: Optional[float] = None, - force_color: bool = False, -) -> None: - if start_time is None: - start_time = time.time() - if sys.stdout.isatty() or force_color: - log_colors = { - "green": "\033[92m", - "yellow": "\033[93m", - "red": "\033[91m", - "reset": "\033[0m", - } - else: - log_colors = { - "green": "", - "yellow": "", - "red": "", - "reset": "", - } - handler = logging.StreamHandler() - handler.setFormatter(TimeFormatter(start_time, log_colors)) - logging.getLogger().addHandler(handler) - logging.getLogger("miplearn").setLevel(logging.INFO) - logging.getLogger("gurobipy").setLevel(logging.ERROR) - logging.getLogger("pyomo.core").setLevel(logging.ERROR) - warnings.formatwarning = formatwarning_tb - logging.captureWarnings(True) diff --git a/miplearn/parallel.py b/miplearn/parallel.py new file mode 100644 index 0000000..a178e9c --- /dev/null +++ b/miplearn/parallel.py @@ -0,0 +1,32 @@ +# Modified version of: https://github.com/swansonk14/p_tqdm +# Copyright (c) 2022 Kyle Swanson +# MIT License + +from collections.abc import Sized +from typing import Any, Callable, Generator, Iterable, List + +from pathos.multiprocessing import _ProcessPool as Pool +from tqdm.auto import tqdm + + +def _parallel(function: Callable, *iterables: Iterable, **kwargs: Any) -> Generator: + # Determine length of tqdm (equal to length of the shortest iterable or total kwarg) + total = kwargs.pop("total", None) + lengths = [len(iterable) for iterable in iterables if isinstance(iterable, Sized)] + length = total or (min(lengths) if lengths else None) + + # Create parallel generator + num_cpus = kwargs.pop("num_cpus", 1) + maxtasksperchild = kwargs.pop("maxtasksperchild", 1) + chunksize = kwargs.pop("chunksize", 1) + with Pool(num_cpus, maxtasksperchild=maxtasksperchild) as pool: + for item in tqdm( + pool.imap_unordered(function, *iterables, chunksize=chunksize), + total=length, + **kwargs + ): + yield item + + +def p_umap(function: Callable, *iterables: Iterable, **kwargs: Any) -> List[Any]: + return list(_parallel(function, *iterables, **kwargs)) diff --git a/miplearn/problems/__init__.py b/miplearn/problems/__init__.py index 5fbccb1..4932a43 100644 --- a/miplearn/problems/__init__.py +++ b/miplearn/problems/__init__.py @@ -1,3 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. diff --git a/miplearn/problems/binpack.py b/miplearn/problems/binpack.py new file mode 100644 index 0000000..0523192 --- /dev/null +++ b/miplearn/problems/binpack.py @@ -0,0 +1,146 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from typing import List, Optional, Union + +import gurobipy as gp +import numpy as np +from gurobipy import GRB, quicksum +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel + + +@dataclass +class BinPackData: + """Data for the bin packing problem. + + Parameters + ---------- + sizes + Sizes of the items + capacity + Capacity of the bin + """ + + sizes: np.ndarray + capacity: int + + +class BinPackGenerator: + """Random instance generator for the bin packing problem. + + If `fix_items=False`, the class samples the user-provided probability distributions + n, sizes and capacity to decide, respectively, the number of items, the sizes of + the items and capacity of the bin. All values are sampled independently. + + If `fix_items=True`, the class creates a reference instance, using the method + previously described, then generates additional instances by perturbing its item + sizes and bin capacity. More specifically, the sizes of the items are set to `s_i + * gamma_i` where `s_i` is the size of the i-th item in the reference instance and + `gamma_i` is sampled from `sizes_jitter`. Similarly, the bin capacity is set to `B * + beta`, where `B` is the reference bin capacity and `beta` is sampled from + `capacity_jitter`. The number of items remains the same across all generated + instances. + + Args + ---- + n + Probability distribution for the number of items. + sizes + Probability distribution for the item sizes. + capacity + Probability distribution for the bin capacity. + sizes_jitter + Probability distribution for the item size randomization. + capacity_jitter + Probability distribution for the bin capacity. + fix_items + If `True`, generates a reference instance, then applies some perturbation to it. + If `False`, generates completely different instances. + """ + + def __init__( + self, + n: rv_frozen, + sizes: rv_frozen, + capacity: rv_frozen, + sizes_jitter: rv_frozen, + capacity_jitter: rv_frozen, + fix_items: bool, + ) -> None: + self.n = n + self.sizes = sizes + self.capacity = capacity + self.sizes_jitter = sizes_jitter + self.capacity_jitter = capacity_jitter + self.fix_items = fix_items + self.ref_data: Optional[BinPackData] = None + + def generate(self, n_samples: int) -> List[BinPackData]: + """Generates random instances. + + Parameters + ---------- + n_samples + Number of samples to generate. + """ + + def _sample() -> BinPackData: + if self.ref_data is None: + n = self.n.rvs() + sizes = self.sizes.rvs(n) + capacity = self.capacity.rvs() + if self.fix_items: + self.ref_data = BinPackData(sizes, capacity) + else: + n = self.ref_data.sizes.shape[0] + sizes = self.ref_data.sizes + capacity = self.ref_data.capacity + + sizes = sizes * self.sizes_jitter.rvs(n) + capacity = capacity * self.capacity_jitter.rvs() + return BinPackData(sizes.round(2), capacity.round(2)) + + return [_sample() for n in range(n_samples)] + + +def build_binpack_model(data: Union[str, BinPackData]) -> GurobiModel: + """Converts bin packing problem data into a concrete Gurobipy model.""" + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, BinPackData) + + model = gp.Model() + n = data.sizes.shape[0] + + # Var: Use bin + y = model.addVars(n, name="y", vtype=GRB.BINARY) + + # Var: Assign item to bin + x = model.addVars(n, n, name="x", vtype=GRB.BINARY) + + # Obj: Minimize number of bins + model.setObjective(quicksum(y[i] for i in range(n))) + + # Eq: Enforce bin capacity + model.addConstrs( + ( + quicksum(data.sizes[i] * x[i, j] for i in range(n)) <= data.capacity * y[j] + for j in range(n) + ), + name="eq_capacity", + ) + + # Eq: Must assign all items to bins + model.addConstrs( + (quicksum(x[i, j] for j in range(n)) == 1 for i in range(n)), + name="eq_assign", + ) + + model.update() + return GurobiModel(model) diff --git a/miplearn/problems/knapsack.py b/miplearn/problems/knapsack.py deleted file mode 100644 index c9a825d..0000000 --- a/miplearn/problems/knapsack.py +++ /dev/null @@ -1,230 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from dataclasses import dataclass -from typing import List, Dict, Optional - -import numpy as np -import pyomo.environ as pe -from overrides import overrides -from scipy.stats import uniform, randint, rv_discrete -from scipy.stats.distributions import rv_frozen - -from miplearn.instance.base import Instance - - -@dataclass -class MultiKnapsackData: - prices: np.ndarray - capacities: np.ndarray - weights: np.ndarray - - -class MultiKnapsackInstance(Instance): - """Representation of the Multidimensional 0-1 Knapsack Problem. - - Given a set of n items and m knapsacks, the problem is to find a subset of items - S maximizing sum(prices[i] for i in S). If selected, each item i occupies - weights[i,j] units of space in each knapsack j. Furthermore, each knapsack j has - limited storage space, given by capacities[j]. - - This implementation assigns a different category for each decision variable, - and therefore trains one ML model per variable. It is only suitable when training - and test instances have same size and items don't shuffle around. - """ - - def __init__( - self, - prices: np.ndarray, - capacities: np.ndarray, - weights: np.ndarray, - ) -> None: - super().__init__() - assert isinstance(prices, np.ndarray) - assert isinstance(capacities, np.ndarray) - assert isinstance(weights, np.ndarray) - assert len(weights.shape) == 2 - self.m, self.n = weights.shape - assert prices.shape == (self.n,) - assert capacities.shape == (self.m,) - self.prices = prices - self.capacities = capacities - self.weights = weights - - @overrides - def to_model(self) -> pe.ConcreteModel: - model = pe.ConcreteModel() - model.x = pe.Var(range(self.n), domain=pe.Binary) - model.OBJ = pe.Objective( - expr=sum(-model.x[j] * self.prices[j] for j in range(self.n)), - sense=pe.minimize, - ) - model.eq_capacity = pe.ConstraintList() - for i in range(self.m): - model.eq_capacity.add( - sum(model.x[j] * self.weights[i, j] for j in range(self.n)) - <= self.capacities[i] - ) - - return model - - -# noinspection PyPep8Naming -class MultiKnapsackGenerator: - def __init__( - self, - n: rv_frozen = randint(low=100, high=101), - m: rv_frozen = randint(low=30, high=31), - w: rv_frozen = randint(low=0, high=1000), - K: rv_frozen = randint(low=500, high=501), - u: rv_frozen = uniform(loc=0.0, scale=1.0), - alpha: rv_frozen = uniform(loc=0.25, scale=0.0), - fix_w: bool = False, - w_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), - p_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), - round: bool = True, - ): - """Initialize the problem generator. - - Instances have a random number of items (or variables) and a random number of - knapsacks (or constraints), as specified by the provided probability - distributions `n` and `m`, respectively. The weight of each item `i` on - knapsack `j` is sampled independently from the provided distribution `w`. The - capacity of knapsack `j` is set to: - - alpha_j * sum(w[i,j] for i in range(n)), - - where `alpha_j`, the tightness ratio, is sampled from the provided - probability distribution `alpha`. To make the instances more challenging, - the costs of the items are linearly correlated to their average weights. More - specifically, the weight of each item `i` is set to: - - sum(w[i,j]/m for j in range(m)) + K * u_i, - - where `K`, the correlation coefficient, and `u_i`, the correlation - multiplier, are sampled from the provided probability distributions. Note - that `K` is only sample once for the entire instance. - - If fix_w=True is provided, then w[i,j] are kept the same in all generated - instances. This also implies that n and m are kept fixed. Although the prices - and capacities are derived from w[i,j], as long as u and K are not constants, - the generated instances will still not be completely identical. - - If a probability distribution w_jitter is provided, then item weights will be - set to w[i,j] * gamma[i,j] where gamma[i,j] is sampled from w_jitter. When - combined with fix_w=True, this argument may be used to generate instances - where the weight of each item is roughly the same, but not exactly identical, - across all instances. The prices of the items and the capacities of the - knapsacks will be calculated as above, but using these perturbed weights - instead. - - By default, all generated prices, weights and capacities are rounded to the - nearest integer number. If `round=False` is provided, this rounding will be - disabled. - - Parameters - ---------- - n: rv_discrete - Probability distribution for the number of items (or variables) - m: rv_discrete - Probability distribution for the number of knapsacks (or constraints) - w: rv_continuous - Probability distribution for the item weights - K: rv_continuous - Probability distribution for the profit correlation coefficient - u: rv_continuous - Probability distribution for the profit multiplier - alpha: rv_continuous - Probability distribution for the tightness ratio - fix_w: boolean - If true, weights are kept the same (minus the noise from w_jitter) in all - instances - w_jitter: rv_continuous - Probability distribution for random noise added to the weights - round: boolean - If true, all prices, weights and capacities are rounded to the nearest - integer - """ - assert isinstance(n, rv_frozen), "n should be a SciPy probability distribution" - assert isinstance(m, rv_frozen), "m should be a SciPy probability distribution" - assert isinstance(w, rv_frozen), "w should be a SciPy probability distribution" - assert isinstance(K, rv_frozen), "K should be a SciPy probability distribution" - assert isinstance(u, rv_frozen), "u should be a SciPy probability distribution" - assert isinstance( - alpha, rv_frozen - ), "alpha should be a SciPy probability distribution" - assert isinstance(fix_w, bool), "fix_w should be boolean" - assert isinstance( - w_jitter, rv_frozen - ), "w_jitter should be a SciPy probability distribution" - - self.n = n - self.m = m - self.w = w - self.u = u - self.K = K - self.alpha = alpha - self.w_jitter = w_jitter - self.p_jitter = p_jitter - self.round = round - self.fix_n: Optional[int] = None - self.fix_m: Optional[int] = None - self.fix_w: Optional[np.ndarray] = None - self.fix_u: Optional[np.ndarray] = None - self.fix_K: Optional[float] = None - - if fix_w: - self.fix_n = self.n.rvs() - self.fix_m = self.m.rvs() - self.fix_w = np.array([self.w.rvs(self.fix_n) for _ in range(self.fix_m)]) - self.fix_u = self.u.rvs(self.fix_n) - self.fix_K = self.K.rvs() - - def generate(self, n_samples: int) -> List[MultiKnapsackData]: - def _sample() -> MultiKnapsackData: - if self.fix_w is not None: - assert self.fix_m is not None - assert self.fix_n is not None - assert self.fix_u is not None - assert self.fix_K is not None - n = self.fix_n - m = self.fix_m - w = self.fix_w - u = self.fix_u - K = self.fix_K - else: - n = self.n.rvs() - m = self.m.rvs() - w = np.array([self.w.rvs(n) for _ in range(m)]) - u = self.u.rvs(n) - K = self.K.rvs() - w = w * np.array([self.w_jitter.rvs(n) for _ in range(m)]) - alpha = self.alpha.rvs(m) - p = np.array( - [w[:, j].sum() / m + K * u[j] for j in range(n)] - ) * self.p_jitter.rvs(n) - b = np.array([w[i, :].sum() * alpha[i] for i in range(m)]) - if self.round: - p = p.round() - b = b.round() - w = w.round() - return MultiKnapsackData(p, b, w) - - return [_sample() for _ in range(n_samples)] - - -def build_multiknapsack_model(data: MultiKnapsackData) -> pe.ConcreteModel: - model = pe.ConcreteModel() - m, n = data.weights.shape - model.x = pe.Var(range(n), domain=pe.Binary) - model.OBJ = pe.Objective( - expr=sum(-model.x[j] * data.prices[j] for j in range(n)), - sense=pe.minimize, - ) - model.eq_capacity = pe.ConstraintList() - for i in range(m): - model.eq_capacity.add( - sum(model.x[j] * data.weights[i, j] for j in range(n)) <= data.capacities[i] - ) - return model diff --git a/miplearn/problems/multiknapsack.py b/miplearn/problems/multiknapsack.py new file mode 100644 index 0000000..79d65a0 --- /dev/null +++ b/miplearn/problems/multiknapsack.py @@ -0,0 +1,189 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from typing import List, Optional, Union + +import gurobipy as gp +import numpy as np +from gurobipy import GRB +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel + + +@dataclass +class MultiKnapsackData: + """Data for the multi-dimensional knapsack problem + + Args + ---- + prices + Item prices. + capacities + Knapsack capacities. + weights + Matrix of item weights. + """ + + prices: np.ndarray + capacities: np.ndarray + weights: np.ndarray + + +# noinspection PyPep8Naming +class MultiKnapsackGenerator: + """Random instance generator for the multi-dimensional knapsack problem. + + Instances have a random number of items (or variables) and a random number of + knapsacks (or constraints), as specified by the provided probability + distributions `n` and `m`, respectively. The weight of each item `i` on knapsack + `j` is sampled independently from the provided distribution `w`. The capacity of + knapsack `j` is set to ``alpha_j * sum(w[i,j] for i in range(n))``, + where `alpha_j`, the tightness ratio, is sampled from the provided probability + distribution `alpha`. + + To make the instances more challenging, the costs of the items are linearly + correlated to their average weights. More specifically, the weight of each item + `i` is set to ``sum(w[i,j]/m for j in range(m)) + K * u_i``, where `K`, + the correlation coefficient, and `u_i`, the correlation multiplier, are sampled + from the provided probability distributions. Note that `K` is only sample once + for the entire instance. + + If `fix_w=True`, then `weights[i,j]` are kept the same in all generated + instances. This also implies that n and m are kept fixed. Although the prices and + capacities are derived from `weights[i,j]`, as long as `u` and `K` are not + constants, the generated instances will still not be completely identical. + + If a probability distribution `w_jitter` is provided, then item weights will be + set to ``w[i,j] * gamma[i,j]`` where `gamma[i,j]` is sampled from `w_jitter`. + When combined with `fix_w=True`, this argument may be used to generate instances + where the weight of each item is roughly the same, but not exactly identical, + across all instances. The prices of the items and the capacities of the knapsacks + will be calculated as above, but using these perturbed weights instead. + + By default, all generated prices, weights and capacities are rounded to the + nearest integer number. If `round=False` is provided, this rounding will be + disabled. + + Parameters + ---------- + n: rv_discrete + Probability distribution for the number of items (or variables). + m: rv_discrete + Probability distribution for the number of knapsacks (or constraints). + w: rv_continuous + Probability distribution for the item weights. + K: rv_continuous + Probability distribution for the profit correlation coefficient. + u: rv_continuous + Probability distribution for the profit multiplier. + alpha: rv_continuous + Probability distribution for the tightness ratio. + fix_w: boolean + If true, weights are kept the same (minus the noise from w_jitter) in all + instances. + w_jitter: rv_continuous + Probability distribution for random noise added to the weights. + round: boolean + If true, all prices, weights and capacities are rounded to the nearest + integer. + """ + + def __init__( + self, + n: rv_frozen = randint(low=100, high=101), + m: rv_frozen = randint(low=30, high=31), + w: rv_frozen = randint(low=0, high=1000), + K: rv_frozen = randint(low=500, high=501), + u: rv_frozen = uniform(loc=0.0, scale=1.0), + alpha: rv_frozen = uniform(loc=0.25, scale=0.0), + fix_w: bool = False, + w_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), + p_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), + round: bool = True, + ): + assert isinstance(n, rv_frozen), "n should be a SciPy probability distribution" + assert isinstance(m, rv_frozen), "m should be a SciPy probability distribution" + assert isinstance(w, rv_frozen), "w should be a SciPy probability distribution" + assert isinstance(K, rv_frozen), "K should be a SciPy probability distribution" + assert isinstance(u, rv_frozen), "u should be a SciPy probability distribution" + assert isinstance( + alpha, rv_frozen + ), "alpha should be a SciPy probability distribution" + assert isinstance(fix_w, bool), "fix_w should be boolean" + assert isinstance( + w_jitter, rv_frozen + ), "w_jitter should be a SciPy probability distribution" + + self.n = n + self.m = m + self.w = w + self.u = u + self.K = K + self.alpha = alpha + self.w_jitter = w_jitter + self.p_jitter = p_jitter + self.round = round + self.fix_n: Optional[int] = None + self.fix_m: Optional[int] = None + self.fix_w: Optional[np.ndarray] = None + self.fix_u: Optional[np.ndarray] = None + self.fix_K: Optional[float] = None + + if fix_w: + self.fix_n = self.n.rvs() + self.fix_m = self.m.rvs() + self.fix_w = np.array([self.w.rvs(self.fix_n) for _ in range(self.fix_m)]) + self.fix_u = self.u.rvs(self.fix_n) + self.fix_K = self.K.rvs() + + def generate(self, n_samples: int) -> List[MultiKnapsackData]: + def _sample() -> MultiKnapsackData: + if self.fix_w is not None: + assert self.fix_m is not None + assert self.fix_n is not None + assert self.fix_u is not None + assert self.fix_K is not None + n = self.fix_n + m = self.fix_m + w = self.fix_w + u = self.fix_u + K = self.fix_K + else: + n = self.n.rvs() + m = self.m.rvs() + w = np.array([self.w.rvs(n) for _ in range(m)]) + u = self.u.rvs(n) + K = self.K.rvs() + w = w * np.array([self.w_jitter.rvs(n) for _ in range(m)]) + alpha = self.alpha.rvs(m) + p = np.array( + [w[:, j].sum() / m + K * u[j] for j in range(n)] + ) * self.p_jitter.rvs(n) + b = np.array([w[i, :].sum() * alpha[i] for i in range(m)]) + if self.round: + p = p.round() + b = b.round() + w = w.round() + return MultiKnapsackData(p, b, w) + + return [_sample() for _ in range(n_samples)] + + +def build_multiknapsack_model(data: Union[str, MultiKnapsackData]) -> GurobiModel: + """Converts multi-knapsack problem data into a concrete Gurobipy model.""" + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, MultiKnapsackData) + + model = gp.Model() + m, n = data.weights.shape + x = model.addMVar(n, vtype=GRB.BINARY, name="x") + model.addConstr(data.weights @ x <= data.capacities) + model.setObjective(-data.prices @ x) + model.update() + return GurobiModel(model) diff --git a/miplearn/problems/pmedian.py b/miplearn/problems/pmedian.py new file mode 100644 index 0000000..6e85f1e --- /dev/null +++ b/miplearn/problems/pmedian.py @@ -0,0 +1,185 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from typing import List, Optional, Union + +import gurobipy as gp +import numpy as np +from gurobipy import quicksum, GRB +from scipy.spatial.distance import pdist, squareform +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel + + +@dataclass +class PMedianData: + """Data for the capacitated p-median problem + + Args + ---- + distances + Matrix of distances between customer i and facility j. + demands + Customer demands. + p + Number of medians that need to be chosen. + capacities + Facility capacities. + """ + + distances: np.ndarray + demands: np.ndarray + p: int + capacities: np.ndarray + + +class PMedianGenerator: + """Random generator for the capacitated p-median problem. + + This class first decides the number of customers and the parameter `p` by + sampling the provided `n` and `p` distributions, respectively. Then, for each + customer `i`, the class builds its geographical location `(xi, yi)` by sampling + the provided `x` and `y` distributions. For each `i`, the demand for customer `i` + and the capacity of facility `i` are decided by sampling the distributions + `demands` and `capacities`, respectively. Finally, the costs `w[i,j]` are set to + the Euclidean distance between the locations of customers `i` and `j`. + + If `fixed=True`, then the number of customers, their locations, the parameter + `p`, the demands and the capacities are only sampled from their respective + distributions exactly once, to build a reference instance which is then + perturbed. Specifically, for each perturbation, the distances, demands and + capacities are multiplied by factors sampled from the distributions + `distances_jitter`, `demands_jitter` and `capacities_jitter`, respectively. The + result is a list of instances that have the same set of customers, but slightly + different demands, capacities and distances. + + Parameters + ---------- + x + Probability distribution for the x-coordinate of the points. + y + Probability distribution for the y-coordinate of the points. + n + Probability distribution for the number of customer. + p + Probability distribution for the number of medians. + demands + Probability distribution for the customer demands. + capacities + Probability distribution for the facility capacities. + distances_jitter + Probability distribution for the random scaling factor applied to distances. + demands_jitter + Probability distribution for the random scaling factor applied to demands. + capacities_jitter + Probability distribution for the random scaling factor applied to capacities. + fixed + If `True`, then customer are kept the same across instances. + """ + + def __init__( + self, + x: rv_frozen = uniform(loc=0.0, scale=100.0), + y: rv_frozen = uniform(loc=0.0, scale=100.0), + n: rv_frozen = randint(low=100, high=101), + p: rv_frozen = randint(low=10, high=11), + demands: rv_frozen = uniform(loc=0, scale=20), + capacities: rv_frozen = uniform(loc=0, scale=100), + distances_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), + demands_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), + capacities_jitter: rv_frozen = uniform(loc=1.0, scale=0.0), + fixed: bool = True, + ): + self.x = x + self.y = y + self.n = n + self.p = p + self.demands = demands + self.capacities = capacities + self.distances_jitter = distances_jitter + self.demands_jitter = demands_jitter + self.capacities_jitter = capacities_jitter + self.fixed = fixed + self.ref_data: Optional[PMedianData] = None + + def generate(self, n_samples: int) -> List[PMedianData]: + def _sample() -> PMedianData: + if self.ref_data is None: + n = self.n.rvs() + p = self.p.rvs() + loc = np.array([(self.x.rvs(), self.y.rvs()) for _ in range(n)]) + distances = squareform(pdist(loc)) + demands = self.demands.rvs(n) + capacities = self.capacities.rvs(n) + else: + n = self.ref_data.demands.shape[0] + distances = self.ref_data.distances * self.distances_jitter.rvs( + size=(n, n) + ) + distances = np.tril(distances) + np.triu(distances.T, 1) + demands = self.ref_data.demands * self.demands_jitter.rvs(n) + capacities = self.ref_data.capacities * self.capacities_jitter.rvs(n) + p = self.ref_data.p + + data = PMedianData( + distances=distances.round(2), + demands=demands.round(2), + p=p, + capacities=capacities.round(2), + ) + + if self.fixed and self.ref_data is None: + self.ref_data = data + + return data + + return [_sample() for _ in range(n_samples)] + + +def build_pmedian_model(data: Union[str, PMedianData]) -> GurobiModel: + """Converts capacitated p-median data into a concrete Gurobipy model.""" + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, PMedianData) + + model = gp.Model() + n = len(data.demands) + + # Decision variables + x = model.addVars(n, n, vtype=GRB.BINARY, name="x") + y = model.addVars(n, vtype=GRB.BINARY, name="y") + + # Objective function + model.setObjective( + quicksum(data.distances[i, j] * x[i, j] for i in range(n) for j in range(n)) + ) + + # Eq: Must serve each customer + model.addConstrs( + (quicksum(x[i, j] for j in range(n)) == 1 for i in range(n)), + name="eq_demand", + ) + + # Eq: Must choose p medians + model.addConstr( + quicksum(y[j] for j in range(n)) == data.p, + name="eq_choose", + ) + + # Eq: Must not exceed capacity + model.addConstrs( + ( + quicksum(data.demands[i] * x[i, j] for i in range(n)) + <= data.capacities[j] * y[j] + for j in range(n) + ), + name="eq_capacity", + ) + + model.update() + return GurobiModel(model) diff --git a/miplearn/problems/setcover.py b/miplearn/problems/setcover.py new file mode 100644 index 0000000..d0e8f8a --- /dev/null +++ b/miplearn/problems/setcover.py @@ -0,0 +1,120 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from typing import List, Union + +import gurobipy as gp +import numpy as np +import pyomo.environ as pe +from gurobipy.gurobipy import GRB +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel +from miplearn.solvers.pyomo import PyomoModel + + +@dataclass +class SetCoverData: + costs: np.ndarray + incidence_matrix: np.ndarray + + +class SetCoverGenerator: + def __init__( + self, + n_elements: rv_frozen = randint(low=50, high=51), + n_sets: rv_frozen = randint(low=100, high=101), + costs: rv_frozen = uniform(loc=0.0, scale=100.0), + costs_jitter: rv_frozen = uniform(loc=-5.0, scale=10.0), + K: rv_frozen = uniform(loc=25.0, scale=0.0), + density: rv_frozen = uniform(loc=0.02, scale=0.00), + fix_sets: bool = True, + ): + self.n_elements = n_elements + self.n_sets = n_sets + self.costs = costs + self.costs_jitter = costs_jitter + self.density = density + self.K = K + self.fix_sets = fix_sets + self.fixed_costs = None + self.fixed_matrix = None + + def generate(self, n_samples: int) -> List[SetCoverData]: + def _sample() -> SetCoverData: + if self.fixed_matrix is None: + n_sets = self.n_sets.rvs() + n_elements = self.n_elements.rvs() + density = self.density.rvs() + + incidence_matrix = np.random.rand(n_elements, n_sets) < density + incidence_matrix = incidence_matrix.astype(int) + + # Ensure each element belongs to at least one set + for j in range(n_elements): + if incidence_matrix[j, :].sum() == 0: + incidence_matrix[j, randint(low=0, high=n_sets).rvs()] = 1 + + # Ensure each set contains at least one element + for i in range(n_sets): + if incidence_matrix[:, i].sum() == 0: + incidence_matrix[randint(low=0, high=n_elements).rvs(), i] = 1 + + costs = self.costs.rvs(n_sets) + self.K.rvs() * incidence_matrix.sum( + axis=0 + ) + if self.fix_sets: + self.fixed_matrix = incidence_matrix + self.fixed_costs = costs + else: + incidence_matrix = self.fixed_matrix + (_, n_sets) = incidence_matrix.shape + costs = self.fixed_costs * self.costs_jitter.rvs(n_sets) + return SetCoverData( + costs=costs.round(2), + incidence_matrix=incidence_matrix, + ) + + return [_sample() for _ in range(n_samples)] + + +def build_setcover_model_gurobipy(data: Union[str, SetCoverData]) -> GurobiModel: + data = _read_setcover_data(data) + (n_elements, n_sets) = data.incidence_matrix.shape + model = gp.Model() + x = model.addMVar(n_sets, vtype=GRB.BINARY, name="x") + model.addConstr(data.incidence_matrix @ x >= np.ones(n_elements), name="eqs") + model.setObjective(data.costs @ x) + model.update() + return GurobiModel(model) + + +def build_setcover_model_pyomo( + data: Union[str, SetCoverData], + solver="gurobi_persistent", +) -> PyomoModel: + data = _read_setcover_data(data) + (n_elements, n_sets) = data.incidence_matrix.shape + model = pe.ConcreteModel() + model.sets = pe.Set(initialize=range(n_sets)) + model.x = pe.Var(model.sets, domain=pe.Boolean, name="x") + model.eqs = pe.Constraint(pe.Any) + for i in range(n_elements): + model.eqs[i] = ( + sum(data.incidence_matrix[i, j] * model.x[j] for j in range(n_sets)) >= 1 + ) + model.obj = pe.Objective( + expr=sum(data.costs[j] * model.x[j] for j in range(n_sets)) + ) + return PyomoModel(model, solver) + + +def _read_setcover_data(data: Union[str, SetCoverData]) -> SetCoverData: + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, SetCoverData) + return data diff --git a/miplearn/problems/setpack.py b/miplearn/problems/setpack.py new file mode 100644 index 0000000..a194b90 --- /dev/null +++ b/miplearn/problems/setpack.py @@ -0,0 +1,66 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from typing import List, Union + +import gurobipy as gp +import numpy as np +from gurobipy.gurobipy import GRB +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from .setcover import SetCoverGenerator +from miplearn.solvers.gurobi import GurobiModel +from ..io import read_pkl_gz + + +@dataclass +class SetPackData: + costs: np.ndarray + incidence_matrix: np.ndarray + + +class SetPackGenerator: + def __init__( + self, + n_elements: rv_frozen = randint(low=50, high=51), + n_sets: rv_frozen = randint(low=100, high=101), + costs: rv_frozen = uniform(loc=0.0, scale=100.0), + costs_jitter: rv_frozen = uniform(loc=-5.0, scale=10.0), + K: rv_frozen = uniform(loc=25.0, scale=0.0), + density: rv_frozen = uniform(loc=0.02, scale=0.00), + fix_sets: bool = True, + ) -> None: + self.gen = SetCoverGenerator( + n_elements=n_elements, + n_sets=n_sets, + costs=costs, + costs_jitter=costs_jitter, + K=K, + density=density, + fix_sets=fix_sets, + ) + + def generate(self, n_samples: int) -> List[SetPackData]: + return [ + SetPackData( + s.costs, + s.incidence_matrix, + ) + for s in self.gen.generate(n_samples) + ] + + +def build_setpack_model(data: Union[str, SetPackData]) -> GurobiModel: + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, SetPackData) + (n_elements, n_sets) = data.incidence_matrix.shape + model = gp.Model() + x = model.addMVar(n_sets, vtype=GRB.BINARY, name="x") + model.addConstr(data.incidence_matrix @ x <= np.ones(n_elements)) + model.setObjective(-data.costs @ x) + model.update() + return GurobiModel(model) diff --git a/miplearn/problems/stab.py b/miplearn/problems/stab.py index 07c15b8..f216161 100644 --- a/miplearn/problems/stab.py +++ b/miplearn/problems/stab.py @@ -1,19 +1,22 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. from dataclasses import dataclass -from typing import List +from typing import List, Union +import gurobipy as gp import networkx as nx import numpy as np import pyomo.environ as pe +from gurobipy import GRB, quicksum from networkx import Graph -from overrides import overrides from scipy.stats import uniform, randint from scipy.stats.distributions import rv_frozen -from miplearn.instance.base import Instance +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel +from miplearn.solvers.pyomo import PyomoModel @dataclass @@ -22,36 +25,6 @@ class MaxWeightStableSetData: weights: np.ndarray -class MaxWeightStableSetInstance(Instance): - """An instance of the Maximum-Weight Stable Set Problem. - - Given a graph G=(V,E) and a weight w_v for each vertex v, the problem asks for a stable - set S of G maximizing sum(w_v for v in S). A stable set (also called independent set) is - a subset of vertices, no two of which are adjacent. - - This is one of Karp's 21 NP-complete problems. - """ - - def __init__(self, graph: Graph, weights: np.ndarray) -> None: - super().__init__() - self.graph = graph - self.weights = weights - self.nodes = list(self.graph.nodes) - - @overrides - def to_model(self) -> pe.ConcreteModel: - model = pe.ConcreteModel() - model.x = pe.Var(self.nodes, domain=pe.Binary) - model.OBJ = pe.Objective( - expr=sum(model.x[v] * self.weights[v] for v in self.nodes), - sense=pe.maximize, - ) - model.clique_eqs = pe.ConstraintList() - for clique in nx.find_cliques(self.graph): - model.clique_eqs.add(sum(model.x[v] for v in clique) <= 1) - return model - - class MaxWeightStableSetGenerator: """Random instance generator for the Maximum-Weight Stable Set Problem. @@ -100,7 +73,7 @@ class MaxWeightStableSetGenerator: graph = self.graph else: graph = self._generate_graph() - weights = self.w.rvs(graph.number_of_nodes()) + weights = np.round(self.w.rvs(graph.number_of_nodes()), 2) return MaxWeightStableSetData(graph, weights) return [_sample() for _ in range(n_samples)] @@ -109,15 +82,35 @@ class MaxWeightStableSetGenerator: return nx.generators.random_graphs.binomial_graph(self.n.rvs(), self.p.rvs()) -def build_stab_model(data: MaxWeightStableSetData) -> pe.ConcreteModel: - model = pe.ConcreteModel() +def build_stab_model_gurobipy(data: MaxWeightStableSetData) -> GurobiModel: + data = _read_stab_data(data) + model = gp.Model() nodes = list(data.graph.nodes) - model.x = pe.Var(nodes, domain=pe.Binary) - model.OBJ = pe.Objective( - expr=sum(-model.x[v] * data.weights[v] for v in nodes), - sense=pe.minimize, - ) + x = model.addVars(nodes, vtype=GRB.BINARY, name="x") + model.setObjective(quicksum(-data.weights[i] * x[i] for i in nodes)) + for clique in nx.find_cliques(data.graph): + model.addConstr(quicksum(x[i] for i in clique) <= 1) + model.update() + return GurobiModel(model) + + +def build_stab_model_pyomo( + data: MaxWeightStableSetData, + solver="gurobi_persistent", +) -> PyomoModel: + data = _read_stab_data(data) + model = pe.ConcreteModel() + nodes = pe.Set(initialize=list(data.graph.nodes)) + model.x = pe.Var(nodes, domain=pe.Boolean, name="x") + model.obj = pe.Objective(expr=sum([-data.weights[i] * model.x[i] for i in nodes])) model.clique_eqs = pe.ConstraintList() for clique in nx.find_cliques(data.graph): - model.clique_eqs.add(sum(model.x[v] for v in clique) <= 1) - return model + model.clique_eqs.add(expr=sum(model.x[i] for i in clique) <= 1) + return PyomoModel(model, solver) + + +def _read_stab_data(data: Union[str, MaxWeightStableSetData]) -> MaxWeightStableSetData: + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, MaxWeightStableSetData) + return data diff --git a/miplearn/problems/tsp.py b/miplearn/problems/tsp.py index 7868a2b..084f6ba 100644 --- a/miplearn/problems/tsp.py +++ b/miplearn/problems/tsp.py @@ -1,22 +1,23 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. from dataclasses import dataclass -from typing import List, Tuple, Any, Optional, Dict +from typing import List, Tuple, Optional, Any, Union +import gurobipy as gp import networkx as nx import numpy as np -import pyomo.environ as pe -from overrides import overrides +from gurobipy import quicksum, GRB, tuplelist from scipy.spatial.distance import pdist, squareform from scipy.stats import uniform, randint from scipy.stats.distributions import rv_frozen +import logging -from miplearn.instance.base import Instance -from miplearn.solvers.learning import InternalSolver -from miplearn.solvers.pyomo.base import BasePyomoSolver -from miplearn.types import ConstraintName +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel + +logger = logging.getLogger(__name__) @dataclass @@ -25,80 +26,6 @@ class TravelingSalesmanData: distances: np.ndarray -class TravelingSalesmanInstance(Instance): - """An instance ot the Traveling Salesman Problem. - - Given a list of cities and the distance between each pair of cities, the problem - asks for the shortest route starting at the first city, visiting each other city - exactly once, then returning to the first city. This problem is a generalization - of the Hamiltonian path problem, one of Karp's 21 NP-complete problems. - """ - - def __init__(self, n_cities: int, distances: np.ndarray) -> None: - super().__init__() - assert isinstance(distances, np.ndarray) - assert distances.shape == (n_cities, n_cities) - self.n_cities = n_cities - self.distances = distances - self.edges = [ - (i, j) for i in range(self.n_cities) for j in range(i + 1, self.n_cities) - ] - - @overrides - def to_model(self) -> pe.ConcreteModel: - model = pe.ConcreteModel() - model.x = pe.Var(self.edges, domain=pe.Binary) - model.obj = pe.Objective( - expr=sum(model.x[i, j] * self.distances[i, j] for (i, j) in self.edges), - sense=pe.minimize, - ) - model.eq_degree = pe.ConstraintList() - model.eq_subtour = pe.ConstraintList() - for i in range(self.n_cities): - model.eq_degree.add( - sum( - model.x[min(i, j), max(i, j)] - for j in range(self.n_cities) - if i != j - ) - == 2 - ) - return model - - @overrides - def find_violated_lazy_constraints( - self, - solver: InternalSolver, - model: Any, - ) -> Dict[ConstraintName, List]: - selected_edges = [e for e in self.edges if model.x[e].value > 0.5] - graph = nx.Graph() - graph.add_edges_from(selected_edges) - violations = {} - for c in list(nx.connected_components(graph)): - if len(c) < self.n_cities: - cname = ("st[" + ",".join(map(str, c)) + "]").encode() - violations[cname] = list(c) - return violations - - @overrides - def enforce_lazy_constraint( - self, - solver: InternalSolver, - model: Any, - component: List, - ) -> None: - assert isinstance(solver, BasePyomoSolver) - cut_edges = [ - e - for e in self.edges - if (e[0] in component and e[1] not in component) - or (e[0] not in component and e[1] in component) - ] - constr = model.eq_subtour.add(expr=sum(model.x[e] for e in cut_edges) >= 2) - solver.add_constraint(constr) - - class TravelingSalesmanGenerator: """Random generator for the Traveling Salesman Problem.""" @@ -118,7 +45,7 @@ class TravelingSalesmanGenerator: distributions `n`, `x` and `y`. For each (unordered) pair of cities (i,j), the distance d[i,j] between them is set to: - d[i,j] = gamma[i,j] \sqrt{(x_i - x_j)^2 + (y_i - y_j)^2} + d[i,j] = gamma[i,j] \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2} where gamma is sampled from the provided probability distribution `gamma`. @@ -183,3 +110,68 @@ class TravelingSalesmanGenerator: n = self.n.rvs() cities = np.array([(self.x.rvs(), self.y.rvs()) for _ in range(n)]) return n, cities + + +def build_tsp_model(data: Union[str, TravelingSalesmanData]) -> GurobiModel: + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, TravelingSalesmanData) + + edges = tuplelist( + (i, j) for i in range(data.n_cities) for j in range(i + 1, data.n_cities) + ) + model = gp.Model() + + # Decision variables + x = model.addVars(edges, vtype=GRB.BINARY, name="x") + + model._x = x + model._edges = edges + model._n_cities = data.n_cities + + # Objective function + model.setObjective(quicksum(x[(i, j)] * data.distances[i, j] for (i, j) in edges)) + + # Eq: Must choose two edges adjacent to each node + model.addConstrs( + ( + quicksum(x[min(i, j), max(i, j)] for j in range(data.n_cities) if i != j) + == 2 + for i in range(data.n_cities) + ), + name="eq_degree", + ) + + def find_violations(model: GurobiModel) -> List[Any]: + violations = [] + x = model.inner.cbGetSolution(model.inner._x) + selected_edges = [e for e in model.inner._edges if x[e] > 0.5] + graph = nx.Graph() + graph.add_edges_from(selected_edges) + for component in list(nx.connected_components(graph)): + if len(component) < model.inner._n_cities: + cut_edges = [ + e + for e in model.inner._edges + if (e[0] in component and e[1] not in component) + or (e[0] not in component and e[1] in component) + ] + violations.append(cut_edges) + return violations + + def fix_violations(model: GurobiModel, violations: List[Any], where: str) -> None: + for violation in violations: + constr = quicksum(model.inner._x[e[0], e[1]] for e in violation) >= 2 + if where == "cb": + model.inner.cbLazy(constr) + else: + model.inner.addConstr(constr) + logger.info(f"tsp: added {len(violations)} subtour elimination constraints") + + model.update() + + return GurobiModel( + model, + find_violations=find_violations, + fix_violations=fix_violations, + ) diff --git a/miplearn/problems/uc.py b/miplearn/problems/uc.py new file mode 100644 index 0000000..28fc314 --- /dev/null +++ b/miplearn/problems/uc.py @@ -0,0 +1,201 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from math import pi +from typing import List, Optional, Union + +import gurobipy as gp +import numpy as np +from gurobipy import GRB, quicksum +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from miplearn.io import read_pkl_gz +from miplearn.solvers.gurobi import GurobiModel + + +@dataclass +class UnitCommitmentData: + demand: np.ndarray + min_power: np.ndarray + max_power: np.ndarray + min_uptime: np.ndarray + min_downtime: np.ndarray + cost_startup: np.ndarray + cost_prod: np.ndarray + cost_fixed: np.ndarray + + +class UnitCommitmentGenerator: + def __init__( + self, + n_units: rv_frozen = randint(low=1_000, high=1_001), + n_periods: rv_frozen = randint(low=72, high=73), + max_power: rv_frozen = uniform(loc=50, scale=450), + min_power: rv_frozen = uniform(loc=0.5, scale=0.25), + cost_startup: rv_frozen = uniform(loc=0, scale=10_000), + cost_prod: rv_frozen = uniform(loc=0, scale=50), + cost_fixed: rv_frozen = uniform(loc=0, scale=1_000), + min_uptime: rv_frozen = randint(low=2, high=8), + min_downtime: rv_frozen = randint(low=2, high=8), + cost_jitter: rv_frozen = uniform(loc=0.75, scale=0.5), + demand_jitter: rv_frozen = uniform(loc=0.9, scale=0.2), + fix_units: bool = False, + ) -> None: + self.n_units = n_units + self.n_periods = n_periods + self.max_power = max_power + self.min_power = min_power + self.cost_startup = cost_startup + self.cost_prod = cost_prod + self.cost_fixed = cost_fixed + self.min_uptime = min_uptime + self.min_downtime = min_downtime + self.cost_jitter = cost_jitter + self.demand_jitter = demand_jitter + self.fix_units = fix_units + self.ref_data: Optional[UnitCommitmentData] = None + + def generate(self, n_samples: int) -> List[UnitCommitmentData]: + def _sample() -> UnitCommitmentData: + if self.ref_data is None: + T = self.n_periods.rvs() + G = self.n_units.rvs() + + # Generate unit parameteres + max_power = self.max_power.rvs(G) + min_power = max_power * self.min_power.rvs(G) + max_power = max_power + min_uptime = self.min_uptime.rvs(G) + min_downtime = self.min_downtime.rvs(G) + cost_startup = self.cost_startup.rvs(G) + cost_prod = self.cost_prod.rvs(G) + cost_fixed = self.cost_fixed.rvs(G) + capacity = max_power.sum() + + # Generate periodic demand in the range [0.4, 0.8] * capacity, with a peak every 12 hours. + demand = np.sin([i / 6 * pi for i in range(T)]) + demand *= uniform(loc=0, scale=1).rvs(T) + demand -= demand.min() + demand /= demand.max() / 0.4 + demand += 0.4 + demand *= capacity + else: + T, G = len(self.ref_data.demand), len(self.ref_data.max_power) + demand = self.ref_data.demand * self.demand_jitter.rvs(T) + min_power = self.ref_data.min_power + max_power = self.ref_data.max_power + min_uptime = self.ref_data.min_uptime + min_downtime = self.ref_data.min_downtime + cost_startup = self.ref_data.cost_startup * self.cost_jitter.rvs(G) + cost_prod = self.ref_data.cost_prod * self.cost_jitter.rvs(G) + cost_fixed = self.ref_data.cost_fixed * self.cost_jitter.rvs(G) + + data = UnitCommitmentData( + demand.round(2), + min_power.round(2), + max_power.round(2), + min_uptime, + min_downtime, + cost_startup.round(2), + cost_prod.round(2), + cost_fixed.round(2), + ) + + if self.ref_data is None and self.fix_units: + self.ref_data = data + + return data + + return [_sample() for _ in range(n_samples)] + + +def build_uc_model(data: Union[str, UnitCommitmentData]) -> GurobiModel: + """ + Models the unit commitment problem according to equations (1)-(5) of: + + Bendotti, P., Fouilhoux, P. & Rottner, C. The min-up/min-down unit + commitment polytope. J Comb Optim 36, 1024-1058 (2018). + https://doi.org/10.1007/s10878-018-0273-y + + """ + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, UnitCommitmentData) + + T = len(data.demand) + G = len(data.min_power) + D = data.demand + Pmin, Pmax = data.min_power, data.max_power + L = data.min_uptime + l = data.min_downtime + + model = gp.Model() + is_on = model.addVars(G, T, vtype=GRB.BINARY, name="is_on") + switch_on = model.addVars(G, T, vtype=GRB.BINARY, name="switch_on") + prod = model.addVars(G, T, name="prod") + + # Objective function + model.setObjective( + quicksum( + is_on[g, t] * data.cost_fixed[g] + + switch_on[g, t] * data.cost_startup[g] + + prod[g, t] * data.cost_prod[g] + for g in range(G) + for t in range(T) + ) + ) + + # Eq 1: Minimum up-time constraint: If unit g is down at time t, then it + # cannot have start up during the previous L[g] periods. + model.addConstrs( + ( + quicksum(switch_on[g, k] for k in range(t - L[g] + 1, t + 1)) <= is_on[g, t] + for g in range(G) + for t in range(L[g] - 1, T) + ), + name="eq_min_uptime", + ) + + # Eq 2: Minimum down-time constraint: Symmetric to the minimum-up constraint. + model.addConstrs( + ( + quicksum(switch_on[g, k] for k in range(t - l[g] + 1, t + 1)) + <= 1 - is_on[g, t - l[g] + 1] + for g in range(G) + for t in range(l[g] - 1, T) + ), + name="eq_min_downtime", + ) + + # Eq 3: Ensures that if unit g start up at time t, then the start-up variable + # must be one. + model.addConstrs( + ( + switch_on[g, t] >= is_on[g, t] - is_on[g, t - 1] + for g in range(G) + for t in range(1, T) + ), + name="eq_startup", + ) + + # Eq 4: Ensures that demand is satisfied at each time period. + model.addConstrs( + (quicksum(prod[g, t] for g in range(G)) >= D[t] for t in range(T)), + name="eq_demand", + ) + + # Eq 5: Sets the bounds to the quantity of power produced by each unit. + model.addConstrs( + (Pmin[g] * is_on[g, t] <= prod[g, t] for g in range(G) for t in range(T)), + name="eq_prod_lb", + ) + model.addConstrs( + (prod[g, t] <= Pmax[g] * is_on[g, t] for g in range(G) for t in range(T)), + name="eq_prod_ub", + ) + model.update() + + return GurobiModel(model) diff --git a/miplearn/problems/vertexcover.py b/miplearn/problems/vertexcover.py new file mode 100644 index 0000000..097c48a --- /dev/null +++ b/miplearn/problems/vertexcover.py @@ -0,0 +1,54 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from dataclasses import dataclass +from typing import List, Union + +import gurobipy as gp +import numpy as np +from gurobipy import GRB, quicksum +from networkx import Graph +from scipy.stats import uniform, randint +from scipy.stats.distributions import rv_frozen + +from .stab import MaxWeightStableSetGenerator +from miplearn.solvers.gurobi import GurobiModel +from ..io import read_pkl_gz + + +@dataclass +class MinWeightVertexCoverData: + graph: Graph + weights: np.ndarray + + +class MinWeightVertexCoverGenerator: + def __init__( + self, + w: rv_frozen = uniform(loc=10.0, scale=1.0), + n: rv_frozen = randint(low=250, high=251), + p: rv_frozen = uniform(loc=0.05, scale=0.0), + fix_graph: bool = True, + ): + self._generator = MaxWeightStableSetGenerator(w, n, p, fix_graph) + + def generate(self, n_samples: int) -> List[MinWeightVertexCoverData]: + return [ + MinWeightVertexCoverData(s.graph, s.weights) + for s in self._generator.generate(n_samples) + ] + + +def build_vertexcover_model(data: Union[str, MinWeightVertexCoverData]) -> GurobiModel: + if isinstance(data, str): + data = read_pkl_gz(data) + assert isinstance(data, MinWeightVertexCoverData) + model = gp.Model() + nodes = list(data.graph.nodes) + x = model.addVars(nodes, vtype=GRB.BINARY, name="x") + model.setObjective(quicksum(data.weights[i] * x[i] for i in nodes)) + for (v1, v2) in data.graph.edges: + model.addConstr(x[v1] + x[v2] >= 1) + model.update() + return GurobiModel(model) diff --git a/miplearn/solvers/__init__.py b/miplearn/solvers/__init__.py index 060153c..4932a43 100644 --- a/miplearn/solvers/__init__.py +++ b/miplearn/solvers/__init__.py @@ -1,48 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - -import logging -import sys -from typing import Any, List, TextIO, cast, TypeVar, Optional, Sized - -logger = logging.getLogger(__name__) - - -class _RedirectOutput: - def __init__(self, streams: List[Any]) -> None: - self.streams = streams - - def write(self, data: Any) -> None: - for stream in self.streams: - stream.write(data) - - def flush(self) -> None: - for stream in self.streams: - stream.flush() - - def __enter__(self) -> Any: - self._original_stdout = sys.stdout - self._original_stderr = sys.stderr - sys.stdout = cast(TextIO, self) - sys.stderr = cast(TextIO, self) - return self - - def __exit__( - self, - _type: Any, - _value: Any, - _traceback: Any, - ) -> None: - sys.stdout = self._original_stdout - sys.stderr = self._original_stderr - - -T = TypeVar("T", bound=Sized) - - -def _none_if_empty(obj: T) -> Optional[T]: - if len(obj) == 0: - return None - else: - return obj diff --git a/miplearn/solvers/abstract.py b/miplearn/solvers/abstract.py new file mode 100644 index 0000000..f37da41 --- /dev/null +++ b/miplearn/solvers/abstract.py @@ -0,0 +1,70 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from abc import ABC, abstractmethod +from typing import Optional, Dict + +import numpy as np + +from miplearn.h5 import H5File + + +class AbstractModel(ABC): + _supports_basis_status = False + _supports_sensitivity_analysis = False + _supports_node_count = False + _supports_solution_pool = False + + @abstractmethod + def add_constrs( + self, + var_names: np.ndarray, + constrs_lhs: np.ndarray, + constrs_sense: np.ndarray, + constrs_rhs: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + pass + + @abstractmethod + def extract_after_load(self, h5: H5File) -> None: + pass + + @abstractmethod + def extract_after_lp(self, h5: H5File) -> None: + pass + + @abstractmethod + def extract_after_mip(self, h5: H5File) -> None: + pass + + @abstractmethod + def fix_variables( + self, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + pass + + @abstractmethod + def optimize(self) -> None: + pass + + @abstractmethod + def relax(self) -> "AbstractModel": + pass + + @abstractmethod + def set_warm_starts( + self, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + pass + + @abstractmethod + def write(self, filename: str) -> None: + pass diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index aaefec4..f9ba4f1 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -1,319 +1,216 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -import logging -import re -import sys -from io import StringIO -from random import randint -from typing import List, Any, Dict, Optional, TYPE_CHECKING +from typing import Dict, Optional, Callable, Any, List +import gurobipy as gp +from gurobipy import GRB, GurobiError import numpy as np -from overrides import overrides -from scipy.sparse import coo_matrix, lil_matrix +from scipy.sparse import lil_matrix -from miplearn.instance.base import Instance -from miplearn.solvers import _RedirectOutput -from miplearn.solvers.internal import ( - InternalSolver, - LPSolveStats, - IterationCallback, - LazyCallback, - MIPSolveStats, - Variables, - Constraints, -) -from miplearn.solvers.pyomo.base import PyomoTestInstanceKnapsack -from miplearn.types import ( - SolverParams, - UserCutCallback, - Solution, -) +from miplearn.h5 import H5File -if TYPE_CHECKING: - import gurobipy -logger = logging.getLogger(__name__) - - -class GurobiSolver(InternalSolver): - """ - An InternalSolver backed by Gurobi's Python API (without Pyomo). - - Parameters - ---------- - params: Optional[SolverParams] - Parameters to pass to Gurobi. For example, `params={"MIPGap": 1e-3}` - sets the gap tolerance to 1e-3. - lazy_cb_frequency: int - If 1, calls lazy constraint callbacks whenever an integer solution - is found. If 2, calls it also at every node, after solving the - LP relaxation of that node. - """ +class GurobiModel: + _supports_basis_status = True + _supports_sensitivity_analysis = True + _supports_node_count = True + _supports_solution_pool = True def __init__( self, - params: Optional[SolverParams] = None, - lazy_cb_frequency: int = 1, + inner: gp.Model, + find_violations: Optional[Callable] = None, + fix_violations: Optional[Callable] = None, ) -> None: - import gurobipy - - assert lazy_cb_frequency in [1, 2] - if params is None: - params = {} - params["InfUnbdInfo"] = True - params["Seed"] = randint(0, 1_000_000) + self.fix_violations = fix_violations + self.find_violations = find_violations + self.inner = inner + self.violations_: Optional[List[Any]] = None - self.gp = gurobipy - self.instance: Optional[Instance] = None - self.model: Optional["gurobipy.Model"] = None - self.params: SolverParams = params - self.cb_where: Optional[int] = None - self.lazy_cb_frequency = lazy_cb_frequency - self._dirty = True - self._has_lp_solution = False - self._has_mip_solution = False - - self._varname_to_var: Dict[bytes, "gurobipy.Var"] = {} - self._cname_to_constr: Dict[str, "gurobipy.Constr"] = {} - self._gp_vars: List["gurobipy.Var"] = [] - self._gp_constrs: List["gurobipy.Constr"] = [] - self._var_names: np.ndarray = np.empty(0) - self._constr_names: List[str] = [] - self._var_types: np.ndarray = np.empty(0) - self._var_lbs: np.ndarray = np.empty(0) - self._var_ubs: np.ndarray = np.empty(0) - self._var_obj_coeffs: np.ndarray = np.empty(0) - - if self.lazy_cb_frequency == 1: - self.lazy_cb_where = [self.gp.GRB.Callback.MIPSOL] - else: - self.lazy_cb_where = [ - self.gp.GRB.Callback.MIPSOL, - self.gp.GRB.Callback.MIPNODE, - ] - - @overrides - def add_constraints(self, cf: Constraints) -> None: - assert cf.names is not None - assert cf.senses is not None - assert cf.lhs is not None - assert cf.rhs is not None - assert self.model is not None - lhs = cf.lhs.tocsr() - for i in range(len(cf.names)): - sense = cf.senses[i] - row = lhs[i, :] - row_expr = self.gp.quicksum( - self._gp_vars[row.indices[j]] * row.data[j] for j in range(row.getnnz()) - ) - if sense == b"=": - self.model.addConstr(row_expr == cf.rhs[i], name=cf.names[i]) - elif sense == b"<": - self.model.addConstr(row_expr <= cf.rhs[i], name=cf.names[i]) - elif sense == b">": - self.model.addConstr(row_expr >= cf.rhs[i], name=cf.names[i]) - else: - raise Exception(f"Unknown sense: {sense}") - self.model.update() - self._dirty = True - self._has_lp_solution = False - self._has_mip_solution = False - - @overrides - def are_callbacks_supported(self) -> bool: - return True - - @overrides - def are_constraints_satisfied( + def add_constrs( self, - cf: Constraints, - tol: float = 1e-5, - ) -> List[bool]: - assert cf.names is not None - assert cf.senses is not None - assert cf.lhs is not None - assert cf.rhs is not None - assert self.model is not None - result = [] - x = np.array(self.model.getAttr("x", self.model.getVars())) - lhs = cf.lhs.tocsr() * x - for i in range(len(cf.names)): - sense = cf.senses[i] - if sense == b"<": - result.append(lhs[i] <= cf.rhs[i] + tol) - elif sense == b">": - result.append(lhs[i] >= cf.rhs[i] - tol) - elif sense == b"<": - result.append(abs(cf.rhs[i] - lhs[i]) <= tol) - else: - raise Exception(f"unknown sense: {sense}") - return result - - @overrides - def build_test_instance_infeasible(self) -> Instance: - return GurobiTestInstanceInfeasible() - - @overrides - def build_test_instance_knapsack(self) -> Instance: - return GurobiTestInstanceKnapsack( - weights=[23.0, 26.0, 20.0, 18.0], - prices=[505.0, 352.0, 458.0, 220.0], - capacity=67.0, + var_names: np.ndarray, + constrs_lhs: np.ndarray, + constrs_sense: np.ndarray, + constrs_rhs: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + assert len(var_names.shape) == 1 + nvars = len(var_names) + assert len(constrs_lhs.shape) == 2 + nconstrs = constrs_lhs.shape[0] + assert constrs_lhs.shape[1] == nvars + assert constrs_sense.shape == (nconstrs,) + assert constrs_rhs.shape == (nconstrs,) + + gp_vars = [self.inner.getVarByName(var_name.decode()) for var_name in var_names] + self.inner.addMConstr(constrs_lhs, gp_vars, constrs_sense, constrs_rhs) + + if stats is not None: + if "Added constraints" not in stats: + stats["Added constraints"] = 0 + stats["Added constraints"] += nconstrs + + def extract_after_load(self, h5: H5File) -> None: + """ + Given a model that has just been loaded, extracts static problem + features, such as variable names and types, objective coefficients, etc. + """ + self.inner.update() + self._extract_after_load_vars(h5) + self._extract_after_load_constrs(h5) + h5.put_scalar("static_sense", "min" if self.inner.modelSense > 0 else "max") + h5.put_scalar("static_obj_offset", self.inner.objCon) + + def extract_after_lp(self, h5: H5File) -> None: + """ + Given a linear programming model that has just been solved, extracts + dynamic problem features, such as optimal LP solution, basis status, + etc. + """ + self._extract_after_lp_vars(h5) + self._extract_after_lp_constrs(h5) + h5.put_scalar("lp_obj_value", self.inner.objVal) + h5.put_scalar("lp_wallclock_time", self.inner.runtime) + + def extract_after_mip(self, h5: H5File) -> None: + """ + Given a mixed-integer linear programming model that has just been + solved, extracts dynamic problem features, such as optimal MIP solution. + """ + h5.put_scalar("mip_wallclock_time", self.inner.runtime) + h5.put_scalar("mip_node_count", self.inner.nodeCount) + if self.inner.status == GRB.INFEASIBLE: + return + gp_vars = self.inner.getVars() + gp_constrs = self.inner.getConstrs() + h5.put_array( + "mip_var_values", + np.array(self.inner.getAttr("x", gp_vars), dtype=float), ) - - @overrides - def clone(self) -> "GurobiSolver": - return GurobiSolver( - params=self.params, - lazy_cb_frequency=self.lazy_cb_frequency, + h5.put_array( + "mip_constr_slacks", + np.abs(np.array(self.inner.getAttr("slack", gp_constrs), dtype=float)), ) - - @overrides - def fix(self, solution: Solution) -> None: - self._raise_if_callback() - for (varname, value) in solution.items(): - if value is None: - continue - var = self._varname_to_var[varname] - var.vtype = self.gp.GRB.CONTINUOUS - var.lb = value - var.ub = value - - @overrides - def get_constraint_attrs(self) -> List[str]: - return [ - "basis_status", - "categories", - "dual_values", - "lazy", - "lhs", - "names", - "rhs", - "sa_rhs_down", - "sa_rhs_up", - "senses", - "slacks", - "user_features", - ] - - @overrides - def get_constraints( + h5.put_scalar("mip_obj_value", self.inner.objVal) + h5.put_scalar("mip_obj_bound", self.inner.objBound) + try: + h5.put_scalar("mip_gap", self.inner.mipGap) + except AttributeError: + pass + self._extract_after_mip_solution_pool(h5) + + def fix_variables( self, - with_static: bool = True, - with_sa: bool = True, - with_lhs: bool = True, - ) -> Constraints: - model = self.model - assert model is not None - assert model.numVars == len(self._gp_vars) - - def _parse_gurobi_cbasis(v: int) -> str: - if v == 0: - return "B" - if v == -1: - return "N" - raise Exception(f"unknown cbasis: {v}") + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + assert len(var_values.shape) == 1 + assert len(var_values.shape) == 1 + assert var_names.shape == var_values.shape + + n_fixed = 0 + for (var_idx, var_name) in enumerate(var_names): + var_val = var_values[var_idx] + if np.isfinite(var_val): + var = self.inner.getVarByName(var_name.decode()) + var.vtype = "C" + var.lb = var_val + var.ub = var_val + n_fixed += 1 + if stats is not None: + stats["Fixed variables"] = n_fixed + + def optimize(self) -> None: + self.violations_ = [] + + def callback(m: gp.Model, where: int) -> None: + assert self.find_violations is not None + assert self.violations_ is not None + assert self.fix_violations is not None + if where == GRB.Callback.MIPSOL: + violations = self.find_violations(self) + self.violations_.extend(violations) + self.fix_violations(self, violations, "cb") + + if self.fix_violations is not None: + self.inner.Params.lazyConstraints = 1 + self.inner.optimize(callback) + else: + self.inner.optimize() - gp_constrs = model.getConstrs() - constr_names = np.array(model.getAttr("constrName", gp_constrs), dtype="S") - lhs: Optional[coo_matrix] = None - rhs, senses, slacks, basis_status = None, None, None, None - dual_value, basis_status, sa_rhs_up, sa_rhs_down = None, None, None, None + def relax(self) -> "GurobiModel": + return GurobiModel(self.inner.relax()) - if with_static: - rhs = np.array(model.getAttr("rhs", gp_constrs), dtype=float) - senses = np.array(model.getAttr("sense", gp_constrs), dtype="S") - if with_lhs: - nrows = len(gp_constrs) - ncols = len(self._var_names) - tmp = lil_matrix((nrows, ncols), dtype=float) - for (i, gp_constr) in enumerate(gp_constrs): - expr = model.getRow(gp_constr) - for j in range(expr.size()): - tmp[i, expr.getVar(j).index] = expr.getCoeff(j) - lhs = tmp.tocoo() + def set_time_limit(self, time_limit_sec: float) -> None: + self.inner.params.timeLimit = time_limit_sec - if self._has_lp_solution: - dual_value = np.array(model.getAttr("pi", gp_constrs), dtype=float) - basis_status = np.array( - [_parse_gurobi_cbasis(c) for c in model.getAttr("cbasis", gp_constrs)], - dtype="S", + def set_warm_starts( + self, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + assert len(var_values.shape) == 2 + (n_starts, n_vars) = var_values.shape + assert len(var_names.shape) == 1 + assert var_names.shape[0] == n_vars + + self.inner.numStart = n_starts + for start_idx in range(n_starts): + self.inner.params.startNumber = start_idx + for (var_idx, var_name) in enumerate(var_names): + var_val = var_values[start_idx, var_idx] + if np.isfinite(var_val): + var = self.inner.getVarByName(var_name.decode()) + var.start = var_val + + if stats is not None: + stats["WS: Count"] = n_starts + stats["WS: Number of variables set"] = ( + np.isfinite(var_values).mean(axis=0).sum() ) - if with_sa: - sa_rhs_up = np.array(model.getAttr("saRhsUp", gp_constrs), dtype=float) - sa_rhs_down = np.array( - model.getAttr("saRhsLow", gp_constrs), dtype=float - ) - - if self._has_lp_solution or self._has_mip_solution: - slacks = np.array(model.getAttr("slack", gp_constrs), dtype=float) - return Constraints( - basis_status=basis_status, - dual_values=dual_value, - lhs=lhs, - names=constr_names, - rhs=rhs, - sa_rhs_down=sa_rhs_down, - sa_rhs_up=sa_rhs_up, - senses=senses, - slacks=slacks, - ) - - @overrides - def get_solution(self) -> Optional[Solution]: - assert self.model is not None - if self.cb_where is not None: - if self.cb_where == self.gp.GRB.Callback.MIPNODE: - return { - v.varName.encode(): self.model.cbGetNodeRel(v) - for v in self.model.getVars() - } - elif self.cb_where == self.gp.GRB.Callback.MIPSOL: - return { - v.varName.encode(): self.model.cbGetSolution(v) - for v in self.model.getVars() - } - else: - raise Exception( - f"get_solution can only be called from a callback " - f"when cb_where is either MIPNODE or MIPSOL" - ) - if self.model.solCount == 0: - return None - return {v.varName.encode(): v.x for v in self.model.getVars()} + def _extract_after_load_vars(self, h5: H5File) -> None: + gp_vars = self.inner.getVars() + for (h5_field, gp_field) in { + "static_var_names": "varName", + "static_var_types": "vtype", + }.items(): + h5.put_array( + h5_field, np.array(self.inner.getAttr(gp_field, gp_vars), dtype="S") + ) + for (h5_field, gp_field) in { + "static_var_upper_bounds": "ub", + "static_var_lower_bounds": "lb", + "static_var_obj_coeffs": "obj", + }.items(): + h5.put_array( + h5_field, np.array(self.inner.getAttr(gp_field, gp_vars), dtype=float) + ) - @overrides - def get_variable_attrs(self) -> List[str]: - return [ - "names", - "basis_status", - "categories", - "lower_bounds", - "obj_coeffs", - "reduced_costs", - "sa_lb_down", - "sa_lb_up", - "sa_obj_down", - "sa_obj_up", - "sa_ub_down", - "sa_ub_up", - "types", - "upper_bounds", - "user_features", - "values", - ] + def _extract_after_load_constrs(self, h5: H5File) -> None: + gp_constrs = self.inner.getConstrs() + gp_vars = self.inner.getVars() + rhs = np.array(self.inner.getAttr("rhs", gp_constrs), dtype=float) + senses = np.array(self.inner.getAttr("sense", gp_constrs), dtype="S") + names = np.array(self.inner.getAttr("constrName", gp_constrs), dtype="S") + nrows, ncols = len(gp_constrs), len(gp_vars) + tmp = lil_matrix((nrows, ncols), dtype=float) + for (i, gp_constr) in enumerate(gp_constrs): + expr = self.inner.getRow(gp_constr) + for j in range(expr.size()): + tmp[i, expr.getVar(j).index] = expr.getCoeff(j) + lhs = tmp.tocoo() - @overrides - def get_variables( - self, - with_static: bool = True, - with_sa: bool = True, - ) -> Variables: - model = self.model - assert model is not None + h5.put_array("static_constr_names", names) + h5.put_array("static_constr_rhs", rhs) + h5.put_array("static_constr_sense", senses) + h5.put_sparse("static_constr_lhs", lhs) + def _extract_after_lp_vars(self, h5: H5File) -> None: def _parse_gurobi_vbasis(b: int) -> str: if b == 0: return "B" @@ -324,393 +221,81 @@ class GurobiSolver(InternalSolver): elif b == -3: return "S" else: - raise Exception(f"unknown vbasis: {basis_status}") - - basis_status: Optional[np.ndarray] = None - upper_bounds, lower_bounds, types, values = None, None, None, None - obj_coeffs, reduced_costs = None, None - sa_obj_up, sa_ub_up, sa_lb_up = None, None, None - sa_obj_down, sa_ub_down, sa_lb_down = None, None, None + raise Exception(f"unknown vbasis: {b}") - if with_static: - upper_bounds = self._var_ubs - lower_bounds = self._var_lbs - types = self._var_types - obj_coeffs = self._var_obj_coeffs - - if self._has_lp_solution: - reduced_costs = np.array(model.getAttr("rc", self._gp_vars), dtype=float) - basis_status = np.array( + gp_vars = self.inner.getVars() + h5.put_array( + "lp_var_basis_status", + np.array( [ _parse_gurobi_vbasis(b) - for b in model.getAttr("vbasis", self._gp_vars) + for b in self.inner.getAttr("vbasis", gp_vars) ], dtype="S", - ) - - if with_sa: - sa_obj_up = np.array( - model.getAttr("saobjUp", self._gp_vars), - dtype=float, - ) - sa_obj_down = np.array( - model.getAttr("saobjLow", self._gp_vars), - dtype=float, - ) - sa_ub_up = np.array( - model.getAttr("saubUp", self._gp_vars), - dtype=float, - ) - sa_ub_down = np.array( - model.getAttr("saubLow", self._gp_vars), - dtype=float, - ) - sa_lb_up = np.array( - model.getAttr("salbUp", self._gp_vars), - dtype=float, - ) - sa_lb_down = np.array( - model.getAttr("salbLow", self._gp_vars), - dtype=float, - ) - - if model.solCount > 0: - values = np.array(model.getAttr("x", self._gp_vars), dtype=float) - - return Variables( - names=self._var_names, - upper_bounds=upper_bounds, - lower_bounds=lower_bounds, - types=types, - obj_coeffs=obj_coeffs, - reduced_costs=reduced_costs, - basis_status=basis_status, - sa_obj_up=sa_obj_up, - sa_obj_down=sa_obj_down, - sa_ub_up=sa_ub_up, - sa_ub_down=sa_ub_down, - sa_lb_up=sa_lb_up, - sa_lb_down=sa_lb_down, - values=values, + ), ) - - @overrides - def is_infeasible(self) -> bool: - assert self.model is not None - return self.model.status in [self.gp.GRB.INFEASIBLE, self.gp.GRB.INF_OR_UNBD] - - @overrides - def remove_constraints(self, names: List[str]) -> None: - assert self.model is not None - constrs = [self.model.getConstrByName(n) for n in names] - self.model.remove(constrs) - self.model.update() - - @overrides - def set_instance( - self, - instance: Instance, - model: Any = None, - ) -> None: - self._raise_if_callback() - if model is None: - model = instance.to_model() - assert isinstance(model, self.gp.Model) - self.instance = instance - self.model = model - self.model.update() - self._update() - - @overrides - def set_warm_start(self, solution: Solution) -> None: - self._raise_if_callback() - self._clear_warm_start() - for (var_name, value) in solution.items(): - var = self._varname_to_var[var_name] - if value is not None: - var.start = value - - @overrides - def solve( - self, - tee: bool = False, - iteration_cb: Optional[IterationCallback] = None, - lazy_cb: Optional[LazyCallback] = None, - user_cut_cb: Optional[UserCutCallback] = None, - ) -> MIPSolveStats: - self._raise_if_callback() - assert self.model is not None - if iteration_cb is None: - iteration_cb = lambda: False - callback_exceptions = [] - - # Create callback wrapper - def cb_wrapper(cb_model: Any, cb_where: int) -> None: - try: - self.cb_where = cb_where - if lazy_cb is not None and cb_where in self.lazy_cb_where: - lazy_cb(self, self.model) - if user_cut_cb is not None and cb_where == self.gp.GRB.Callback.MIPNODE: - user_cut_cb(self, self.model) - except Exception as e: - logger.exception("callback error") - callback_exceptions.append(e) - finally: - self.cb_where = None - - # Configure Gurobi - if lazy_cb is not None: - self.params["LazyConstraints"] = 1 - if user_cut_cb is not None: - self.params["PreCrush"] = 1 - - # Solve problem - total_wallclock_time = 0 - total_nodes = 0 - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - self._apply_params(streams) - while True: - with _RedirectOutput(streams): - self.model.optimize(cb_wrapper) - self._dirty = False - if len(callback_exceptions) > 0: - raise callback_exceptions[0] - total_wallclock_time += self.model.runtime - total_nodes += int(self.model.nodeCount) - should_repeat = iteration_cb() - if not should_repeat: - break - self._has_lp_solution = False - self._has_mip_solution = self.model.solCount > 0 - - # Fetch results and stats - log = streams[0].getvalue() - ub, lb = None, None - sense = "min" if self.model.modelSense == 1 else "max" - if self.model.solCount > 0: - if self.model.modelSense == 1: - lb = self.model.objBound - ub = self.model.objVal - else: - lb = self.model.objVal - ub = self.model.objBound - ws_value = self._extract_warm_start_value(log) - return MIPSolveStats( - mip_lower_bound=lb, - mip_upper_bound=ub, - mip_wallclock_time=total_wallclock_time, - mip_nodes=total_nodes, - mip_sense=sense, - mip_log=log, - mip_warm_start_value=ws_value, - ) - - @overrides - def solve_lp( - self, - tee: bool = False, - ) -> LPSolveStats: - self._raise_if_callback() - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - self._apply_params(streams) - assert self.model is not None - for (i, var) in enumerate(self._gp_vars): - if self._var_types[i] == b"B": - var.vtype = self.gp.GRB.CONTINUOUS - var.lb = 0.0 - var.ub = 1.0 - elif self._var_types[i] == b"I": - var.vtype = self.gp.GRB.CONTINUOUS - with _RedirectOutput(streams): - self.model.optimize() - self._dirty = False - for (i, var) in enumerate(self._gp_vars): - if self._var_types[i] == b"B": - var.vtype = self.gp.GRB.BINARY - elif self._var_types[i] == b"I": - var.vtype = self.gp.GRB.INTEGER - log = streams[0].getvalue() - self._has_lp_solution = self.model.solCount > 0 - self._has_mip_solution = False - opt_value = None - if not self.is_infeasible(): - opt_value = self.model.objVal - return LPSolveStats( - lp_value=opt_value, - lp_log=log, - lp_wallclock_time=self.model.runtime, - ) - - def _apply_params(self, streams: List[Any]) -> None: - assert self.model is not None - with _RedirectOutput(streams): - for (name, value) in self.params.items(): - self.model.setParam(name, value) - - def _clear_warm_start(self) -> None: - for var in self._varname_to_var.values(): - var.start = self.gp.GRB.UNDEFINED - - @staticmethod - def _extract( - log: str, - regexp: str, - default: Optional[str] = None, - ) -> Optional[str]: - value = default - for line in log.splitlines(): - matches = re.findall(regexp, line) - if len(matches) == 0: - continue - value = matches[0] - return value - - def _extract_warm_start_value(self, log: str) -> Optional[float]: - ws = self._extract(log, "MIP start with objective ([0-9.e+-]*)") - if ws is None: - return None - return float(ws) - - def _get_value(self, var: Any) -> float: - assert self.model is not None - if self.cb_where == self.gp.GRB.Callback.MIPSOL: - return self.model.cbGetSolution(var) - elif self.cb_where == self.gp.GRB.Callback.MIPNODE: - return self.model.cbGetNodeRel(var) - elif self.cb_where is None: - return var.x - else: - raise Exception( - "get_value cannot be called from cb_where=%s" % self.cb_where + for (h5_field, gp_field) in { + "lp_var_reduced_costs": "rc", + "lp_var_sa_obj_up": "saobjUp", + "lp_var_sa_obj_down": "saobjLow", + "lp_var_sa_ub_up": "saubUp", + "lp_var_sa_ub_down": "saubLow", + "lp_var_sa_lb_up": "salbUp", + "lp_var_sa_lb_down": "salbLow", + "lp_var_values": "x", + }.items(): + h5.put_array( + h5_field, + np.array(self.inner.getAttr(gp_field, gp_vars), dtype=float), ) - def _raise_if_callback(self) -> None: - if self.cb_where is not None: - raise Exception("method cannot be called from a callback") + def _extract_after_lp_constrs(self, h5: H5File) -> None: + def _parse_gurobi_cbasis(v: int) -> str: + if v == 0: + return "B" + if v == -1: + return "N" + raise Exception(f"unknown cbasis: {v}") - def _update(self) -> None: - assert self.model is not None - gp_vars: List["gurobipy.Var"] = self.model.getVars() - gp_constrs: List["gurobipy.Constr"] = self.model.getConstrs() - var_names: np.ndarray = np.array( - self.model.getAttr("varName", gp_vars), - dtype="S", - ) - var_types: np.ndarray = np.array( - self.model.getAttr("vtype", gp_vars), - dtype="S", - ) - var_ubs: np.ndarray = np.array( - self.model.getAttr("ub", gp_vars), - dtype=float, - ) - var_lbs: np.ndarray = np.array( - self.model.getAttr("lb", gp_vars), - dtype=float, - ) - var_obj_coeffs: np.ndarray = np.array( - self.model.getAttr("obj", gp_vars), - dtype=float, + gp_constrs = self.inner.getConstrs() + h5.put_array( + "lp_constr_basis_status", + np.array( + [ + _parse_gurobi_cbasis(c) + for c in self.inner.getAttr("cbasis", gp_constrs) + ], + dtype="S", + ), ) - constr_names: List[str] = self.model.getAttr("constrName", gp_constrs) - varname_to_var: Dict[bytes, "gurobipy.Var"] = {} - cname_to_constr: Dict = {} - for (i, gp_var) in enumerate(gp_vars): - assert var_names[i] not in varname_to_var, ( - f"Duplicated variable name detected: {var_names[i]}. " - f"Unique variable names are currently required." - ) - assert var_types[i] in [b"B", b"C", b"I"], ( - "Only binary and continuous variables are currently supported. " - f"Variable {var_names[i]} has type {var_types[i]}." + for (h5_field, gp_field) in { + "lp_constr_dual_values": "pi", + "lp_constr_sa_rhs_up": "saRhsUp", + "lp_constr_sa_rhs_down": "saRhsLow", + }.items(): + h5.put_array( + h5_field, + np.array(self.inner.getAttr(gp_field, gp_constrs), dtype=float), ) - varname_to_var[var_names[i]] = gp_var - for (i, gp_constr) in enumerate(gp_constrs): - assert constr_names[i] not in cname_to_constr, ( - f"Duplicated constraint name detected: {constr_names[i]}. " - f"Unique constraint names are currently required." - ) - cname_to_constr[constr_names[i]] = gp_constr - self._varname_to_var = varname_to_var - self._cname_to_constr = cname_to_constr - self._gp_vars = gp_vars - self._gp_constrs = gp_constrs - self._var_names = var_names - self._constr_names = constr_names - self._var_types = var_types - self._var_lbs = var_lbs - self._var_ubs = var_ubs - self._var_obj_coeffs = var_obj_coeffs - - def __getstate__(self) -> Dict: - return { - "params": self.params, - "lazy_cb_frequency": self.lazy_cb_frequency, - } - - def __setstate__(self, state: Dict) -> None: - self.params = state["params"] - self.lazy_cb_frequency = state["lazy_cb_frequency"] - self.instance = None - self.model = None - self.cb_where = None - - -class GurobiTestInstanceInfeasible(Instance): - @overrides - def to_model(self) -> Any: - import gurobipy as gp - from gurobipy import GRB - - model = gp.Model() - x = model.addVars(1, vtype=GRB.BINARY, name="x") - model.addConstr(x[0] >= 2) - model.setObjective(x[0]) - return model - - -class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack): - """ - Simpler (one-dimensional) knapsack instance, implemented directly in Gurobi - instead of Pyomo, used for testing. - """ - - def __init__( - self, - weights: List[float], - prices: List[float], - capacity: float, - ) -> None: - super().__init__(weights, prices, capacity) - - @overrides - def to_model(self) -> Any: - import gurobipy as gp - from gurobipy import GRB - - model = gp.Model("Knapsack") - n = len(self.weights) - x = model.addVars(n, vtype=GRB.BINARY, name="x") - z = model.addVar(vtype=GRB.CONTINUOUS, name="z", ub=self.capacity) - model.addConstr( - gp.quicksum(x[i] * self.weights[i] for i in range(n)) == z, - "eq_capacity", - ) - model.setObjective( - gp.quicksum(x[i] * self.prices[i] for i in range(n)), GRB.MAXIMIZE + h5.put_array( + "lp_constr_slacks", + np.abs(np.array(self.inner.getAttr("slack", gp_constrs), dtype=float)), ) - return model - @overrides - def enforce_lazy_constraint( - self, - solver: InternalSolver, - model: Any, - violation_data: Any, - ) -> None: - x0 = model.getVarByName("x[0]") - model.cbLazy(x0 <= 0) + def _extract_after_mip_solution_pool(self, h5: H5File) -> None: + gp_vars = self.inner.getVars() + pool_var_values = [] + pool_obj_values = [] + for i in range(self.inner.SolCount): + self.inner.params.SolutionNumber = i + try: + pool_var_values.append(self.inner.getAttr("Xn", gp_vars)) + pool_obj_values.append(self.inner.PoolObjVal) + except GurobiError: + pass + h5.put_array("pool_var_values", np.array(pool_var_values)) + h5.put_array("pool_obj_values", np.array(pool_obj_values)) + + def write(self, filename: str) -> None: + self.inner.update() + self.inner.write(filename) diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py deleted file mode 100644 index 9688cb0..0000000 --- a/miplearn/solvers/internal.py +++ /dev/null @@ -1,340 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, Optional, List, TYPE_CHECKING - -import numpy as np -from scipy.sparse import coo_matrix - -from miplearn.instance.base import Instance -from miplearn.types import ( - IterationCallback, - LazyCallback, - UserCutCallback, - Solution, -) - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from miplearn.features.sample import Sample - - -@dataclass -class LPSolveStats: - lp_log: Optional[str] = None - lp_value: Optional[float] = None - lp_wallclock_time: Optional[float] = None - - def to_list(self) -> List[float]: - features: List[float] = [] - for attr in ["lp_value", "lp_wallclock_time"]: - if getattr(self, attr) is not None: - features.append(getattr(self, attr)) - return features - - -@dataclass -class MIPSolveStats: - mip_lower_bound: Optional[float] = None - mip_log: Optional[str] = None - mip_nodes: Optional[int] = None - mip_sense: Optional[str] = None - mip_upper_bound: Optional[float] = None - mip_wallclock_time: Optional[float] = None - mip_warm_start_value: Optional[float] = None - - -@dataclass -class Variables: - names: Optional[np.ndarray] = None - basis_status: Optional[np.ndarray] = None - lower_bounds: Optional[np.ndarray] = None - obj_coeffs: Optional[np.ndarray] = None - reduced_costs: Optional[np.ndarray] = None - sa_lb_down: Optional[np.ndarray] = None - sa_lb_up: Optional[np.ndarray] = None - sa_obj_down: Optional[np.ndarray] = None - sa_obj_up: Optional[np.ndarray] = None - sa_ub_down: Optional[np.ndarray] = None - sa_ub_up: Optional[np.ndarray] = None - types: Optional[np.ndarray] = None - upper_bounds: Optional[np.ndarray] = None - values: Optional[np.ndarray] = None - - -@dataclass -class Constraints: - basis_status: Optional[np.ndarray] = None - dual_values: Optional[np.ndarray] = None - lazy: Optional[np.ndarray] = None - lhs: Optional[coo_matrix] = None - names: Optional[np.ndarray] = None - rhs: Optional[np.ndarray] = None - sa_rhs_down: Optional[np.ndarray] = None - sa_rhs_up: Optional[np.ndarray] = None - senses: Optional[np.ndarray] = None - slacks: Optional[np.ndarray] = None - - @staticmethod - def from_sample(sample: "Sample") -> "Constraints": - return Constraints( - basis_status=sample.get_array("lp_constr_basis_status"), - dual_values=sample.get_array("lp_constr_dual_values"), - lazy=sample.get_array("static_constr_lazy"), - # lhs=sample.get_vector("static_constr_lhs"), - names=sample.get_array("static_constr_names"), - rhs=sample.get_array("static_constr_rhs"), - sa_rhs_down=sample.get_array("lp_constr_sa_rhs_down"), - sa_rhs_up=sample.get_array("lp_constr_sa_rhs_up"), - senses=sample.get_array("static_constr_senses"), - slacks=sample.get_array("lp_constr_slacks"), - ) - - def __getitem__(self, selected: List[bool]) -> "Constraints": - return Constraints( - basis_status=( - None if self.basis_status is None else self.basis_status[selected] - ), - dual_values=( - None if self.dual_values is None else self.dual_values[selected] - ), - names=(None if self.names is None else self.names[selected]), - lazy=(None if self.lazy is None else self.lazy[selected]), - lhs=(None if self.lhs is None else self.lhs.tocsr()[selected].tocoo()), - rhs=(None if self.rhs is None else self.rhs[selected]), - sa_rhs_down=( - None if self.sa_rhs_down is None else self.sa_rhs_down[selected] - ), - sa_rhs_up=(None if self.sa_rhs_up is None else self.sa_rhs_up[selected]), - senses=(None if self.senses is None else self.senses[selected]), - slacks=(None if self.slacks is None else self.slacks[selected]), - ) - - -class InternalSolver(ABC): - """ - Abstract class representing the MIP solver used internally by LearningSolver. - """ - - @abstractmethod - def add_constraints(self, cf: Constraints) -> None: - """Adds the given constraints to the model.""" - pass - - @abstractmethod - def are_constraints_satisfied( - self, - cf: Constraints, - tol: float = 1e-5, - ) -> List[bool]: - """ - Checks whether the current solution satisfies the given constraints. - """ - pass - - def are_callbacks_supported(self) -> bool: - """ - Returns True if this solver supports native callbacks, such as lazy constraints - callback or user cuts callback. - """ - return False - - @abstractmethod - def build_test_instance_infeasible(self) -> Instance: - """ - Returns an infeasible instance, for testing purposes. - """ - pass - - @abstractmethod - def build_test_instance_knapsack(self) -> Instance: - """ - Returns an instance corresponding to the following MIP, for testing purposes: - - maximize 505 x0 + 352 x1 + 458 x2 + 220 x3 - s.t. eq_capacity: z = 23 x0 + 26 x1 + 20 x2 + 18 x3 - x0, x1, x2, x3 binary - 0 <= z <= 67 continuous - """ - pass - - @abstractmethod - def clone(self) -> "InternalSolver": - """ - Returns a new copy of this solver with identical parameters, but otherwise - completely unitialized. - """ - pass - - @abstractmethod - def fix(self, solution: Solution) -> None: - """ - Fixes the values of a subset of decision variables. Missing values in the - solution indicate variables that should be left free. - """ - pass - - @abstractmethod - def get_solution(self) -> Optional[Solution]: - """ - Returns current solution found by the solver. - - If called after `solve`, returns the best primal solution found during - the search. If called after `solve_lp`, returns the optimal solution - to the LP relaxation. If no primal solution is available, return None. - """ - pass - - @abstractmethod - def get_constraint_attrs(self) -> List[str]: - """ - Returns a list of constraint attributes supported by this solver. Used for - testing purposes only. - """ - - pass - - @abstractmethod - def get_constraints( - self, - with_static: bool = True, - with_sa: bool = True, - with_lhs: bool = True, - ) -> Constraints: - pass - - @abstractmethod - def get_variable_attrs(self) -> List[str]: - """ - Returns a list of variable attributes supported by this solver. Used for - testing purposes only. - """ - pass - - @abstractmethod - def get_variables( - self, - with_static: bool = True, - with_sa: bool = True, - ) -> Variables: - """ - Returns a description of the decision variables in the problem. - - Parameters - ---------- - with_static: bool - If True, include features that do not change during the solution process, - such as variable types and names. This parameter is used to reduce the - amount of duplicated data collected by LearningSolver. Features that do - not change are only collected once. - with_sa: bool - If True, collect sensitivity analysis information. For large models, - collecting this information may be expensive, so this parameter is useful - for reducing running times. - """ - pass - - @abstractmethod - def is_infeasible(self) -> bool: - """ - Returns True if the model has been proved to be infeasible. - Must be called after solve. - """ - pass - - @abstractmethod - def remove_constraints(self, names: np.ndarray) -> None: - """ - Removes the given constraints from the model. - """ - pass - - @abstractmethod - def set_instance( - self, - instance: Instance, - model: Any = None, - ) -> None: - """ - Loads the given instance into the solver. - - Parameters - ---------- - instance: Instance - The instance to be loaded. - model: Any - The concrete optimization model corresponding to this instance - (e.g. JuMP.Model or pyomo.core.ConcreteModel). If not provided, - it will be generated by calling `instance.to_model()`. - """ - pass - - @abstractmethod - def set_warm_start(self, solution: Solution) -> None: - """ - Sets the warm start to be used by the solver. - - Only one warm start is supported. Calling this function when a warm start - already exists will remove the previous warm start. - """ - pass - - @abstractmethod - def solve( - self, - tee: bool = False, - iteration_cb: Optional[IterationCallback] = None, - lazy_cb: Optional[LazyCallback] = None, - user_cut_cb: Optional[UserCutCallback] = None, - ) -> MIPSolveStats: - """ - Solves the currently loaded instance. After this method finishes, - the best solution found can be retrieved by calling `get_solution`. - - Parameters - ---------- - iteration_cb: IterationCallback - By default, InternalSolver makes a single call to the native `solve` - method and returns the result. If an iteration callback is provided - instead, InternalSolver enters a loop, where `solve` and `iteration_cb` - are called alternatively. To stop the loop, `iteration_cb` should return - False. Any other result causes the solver to loop again. - lazy_cb: LazyCallback - This function is called whenever the solver finds a new candidate - solution and can be used to add lazy constraints to the model. Only the - following operations within the callback are allowed: - - Querying the value of a variable - - Querying if a constraint is satisfied - - Adding a new constraint to the problem - Additional operations may be allowed by specific subclasses. - user_cut_cb: UserCutCallback - This function is called whenever the solver found a new integer-infeasible - solution and needs to generate cutting planes to cut it off. - tee: bool - If true, prints the solver log to the screen. - """ - pass - - @abstractmethod - def solve_lp( - self, - tee: bool = False, - ) -> LPSolveStats: - """ - Solves the LP relaxation of the currently loaded instance. After this - method finishes, the solution can be retrieved by calling `get_solution`. - - This method should not permanently modify the problem. That is, subsequent - calls to `solve` should solve the original MIP, not the LP relaxation. - - Parameters - ---------- - tee - If true, prints the solver log to the screen. - """ - pass diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index e06a356..8e792b9 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -1,591 +1,43 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. - -import logging -import time -import traceback -from typing import Optional, List, Any, cast, Dict, Tuple, Callable, IO, Union - -from overrides import overrides -from p_tqdm import p_map, p_umap -from tqdm.auto import tqdm - -from miplearn.features.sample import Hdf5Sample, Sample -from miplearn.components.component import Component -from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent -from miplearn.components.dynamic_user_cuts import UserCutsComponent -from miplearn.components.objective import ObjectiveValueComponent -from miplearn.components.primal import PrimalSolutionComponent -from miplearn.features.extractor import FeaturesExtractor -from miplearn.instance.base import Instance -from miplearn.solvers import _RedirectOutput -from miplearn.solvers.internal import InternalSolver -from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver -from miplearn.types import LearningSolveStats, ConstraintName -import gzip -import pickle -import miplearn -import json from os.path import exists -from os import remove -import pyomo.environ as pe - - -logger = logging.getLogger(__name__) - - -class PyomoFindLazyCutCallbackHandler: - def __init__(self): - pass - - def value(self, var): - return var.value - - -class PyomoEnforceLazyCutsCallbackHandler: - def __init__(self, opt, model): - self.model = model - self.opt = opt - if not hasattr(model, "miplearn_lazy_cb"): - model.miplearn_lazy_cb = pe.ConstraintList() - - def enforce(self, expr): - constr = self.model.miplearn_lazy_cb.add(expr=expr) - self.opt.add_constraint(constr) - - -class FileInstanceWrapper(Instance): - def __init__( - self, data_filename: Any, build_model: Callable, mode: Optional[str] = None - ): - super().__init__() - assert data_filename.endswith(".pkl.gz") - self.filename = data_filename - self.sample_filename = data_filename.replace(".pkl.gz", ".h5") - self.build_model = build_model - self.mode = mode - self.sample = None - self.model = None - - @overrides - def to_model(self) -> Any: - if self.model is None: - self.model = miplearn.load(self.filename, self.build_model) - return self.model - - @overrides - def create_sample(self) -> Sample: - return self.sample - - @overrides - def get_samples(self) -> List[Sample]: - return [self.sample] - - @overrides - def free(self) -> None: - self.sample.file.close() - - @overrides - def load(self) -> None: - if self.mode is None: - self.mode = "r+" if exists(self.sample_filename) else "w" - self.sample = Hdf5Sample(self.sample_filename, mode=self.mode) - - @overrides - def has_dynamic_lazy_constraints(self) -> bool: - assert hasattr(self, "model") - return hasattr(self.model, "_miplearn_find_lazy_cuts") +from tempfile import NamedTemporaryFile +from typing import List, Any, Union - @overrides - def find_violated_lazy_constraints( - self, - solver: "InternalSolver", - model: Any, - ) -> Dict[ConstraintName, Any]: - if not hasattr(self.model, "_miplearn_find_lazy_cuts"): - return {} - cb = PyomoFindLazyCutCallbackHandler() - violations = model._miplearn_find_lazy_cuts(cb) - return {json.dumps(v).encode(): v for v in violations} - - @overrides - def enforce_lazy_constraint( - self, - solver: "InternalSolver", - model: Any, - violation: Any, - ) -> None: - assert isinstance(solver, GurobiPyomoSolver) - cb = PyomoEnforceLazyCutsCallbackHandler(solver._pyomo_solver, model) - model._miplearn_enforce_lazy_cuts(cb, violation) - - -class MemoryInstanceWrapper(Instance): - def __init__(self, model: Any) -> None: - super().__init__() - assert model is not None - self.model = model - - @overrides - def to_model(self) -> Any: - return self.model - - @overrides - def has_dynamic_lazy_constraints(self) -> bool: - assert hasattr(self, "model") - return hasattr(self.model, "_miplearn_find_lazy_cuts") - - @overrides - def find_violated_lazy_constraints( - self, - solver: "InternalSolver", - model: Any, - ) -> Dict[ConstraintName, Any]: - cb = PyomoFindLazyCutCallbackHandler() - violations = model._miplearn_find_lazy_cuts(cb) - return {json.dumps(v).encode(): v for v in violations} - - @overrides - def enforce_lazy_constraint( - self, - solver: "InternalSolver", - model: Any, - violation: Any, - ) -> None: - assert isinstance(solver, GurobiPyomoSolver) - cb = PyomoEnforceLazyCutsCallbackHandler(solver._pyomo_solver, model) - model._miplearn_enforce_lazy_cuts(cb, violation) - - -class _GlobalVariables: - def __init__(self) -> None: - self.solver: Optional[LearningSolver] = None - self.build_model: Optional[Callable] = None - self.filenames: Optional[List[str]] = None - self.skip = False - - -# Global variables used for multiprocessing. Global variables are copied by the -# operating system when the process forks. Local variables are copied through -# serialization, which is a much slower process. -_GLOBAL = [_GlobalVariables()] - - -def _parallel_solve( - idx: int, -) -> Tuple[Optional[int], Optional[LearningSolveStats]]: - solver = _GLOBAL[0].solver - filenames = _GLOBAL[0].filenames - build_model = _GLOBAL[0].build_model - skip = _GLOBAL[0].skip - assert solver is not None - try: - stats = solver.solve([filenames[idx]], build_model, skip=skip) - return idx, stats[0] - except Exception as e: - traceback.print_exc() - logger.exception(f"Exception while solving {filenames[idx]}. Ignoring.") - return idx, None +from miplearn.h5 import H5File +from miplearn.io import _to_h5_filename +from miplearn.solvers.abstract import AbstractModel class LearningSolver: - """ - Mixed-Integer Linear Programming (MIP) solver that extracts information - from previous runs and uses Machine Learning methods to accelerate the - solution of new (yet unseen) instances. - - Parameters - ---------- - components: List[Component] - Set of components in the solver. By default, includes - `ObjectiveValueComponent`, `PrimalSolutionComponent`, - `DynamicLazyConstraintsComponent` and `UserCutsComponent`. - mode: str - If "exact", solves problem to optimality, keeping all optimality - guarantees provided by the MIP solver. If "heuristic", uses machine - learning more aggressively, and may return suboptimal solutions. - solver: Callable[[], InternalSolver] - A callable that constructs the internal solver. If None is provided, - use GurobiPyomoSolver. - use_lazy_cb: bool - If true, use native solver callbacks for enforcing lazy constraints, - instead of a simple loop. May not be supported by all solvers. - solve_lp: bool - If true, solve the root LP relaxation before solving the MIP. This - option should be activated if the LP relaxation is not very - expensive to solve and if it provides good hints for the integer - solution. - """ - - def __init__( - self, - components: Optional[List[Component]] = None, - mode: str = "exact", - solver: Optional[InternalSolver] = None, - use_lazy_cb: bool = False, - solve_lp: bool = True, - extractor: Optional[FeaturesExtractor] = None, - extract_lhs: bool = True, - extract_sa: bool = True, - ) -> None: - if solver is None: - solver = GurobiPyomoSolver() - if extractor is None: - extractor = FeaturesExtractor( - with_sa=extract_sa, - with_lhs=extract_lhs, - ) - assert isinstance(solver, InternalSolver) - self.components: Dict[str, Component] = {} - self.internal_solver: Optional[InternalSolver] = None - self.internal_solver_prototype: InternalSolver = solver - self.mode: str = mode - self.solve_lp: bool = solve_lp - self.tee = False - self.use_lazy_cb: bool = use_lazy_cb - self.extractor = extractor - if components is not None: - for comp in components: - self._add_component(comp) - else: - self._add_component(ObjectiveValueComponent()) - self._add_component(PrimalSolutionComponent(mode=mode)) - self._add_component(DynamicLazyConstraintsComponent()) - self._add_component(UserCutsComponent()) - assert self.mode in ["exact", "heuristic"] - - def _solve( - self, - instance: Instance, - model: Any = None, - discard_output: bool = False, - tee: bool = False, - ) -> LearningSolveStats: - """ - Solves the given instance. If trained machine-learning models are - available, they will be used to accelerate the solution process. - - The argument `instance` may be either an Instance object or a - filename pointing to a pickled Instance object. - - This method adds a new training sample to `instance.training_sample`. - If a filename is provided, then the file is modified in-place. That is, - the original file is overwritten. - - If `solver.solve_lp_first` is False, the properties lp_solution and - lp_value will be set to dummy values. - - Parameters - ---------- - instance: Instance - The instance to be solved. - model: Any - The corresponding Pyomo model. If not provided, it will be created. - discard_output: bool - If True, do not write the modified instances anywhere; simply discard - them. Useful during benchmarking. - tee: bool - If true, prints solver log to screen. - - Returns - ------- - LearningSolveStats - A dictionary of solver statistics containing at least the following - keys: "Lower bound", "Upper bound", "Wallclock time", "Nodes", - "Sense", "Log", "Warm start value" and "LP value". - - Additional components may generate additional keys. For example, - ObjectiveValueComponent adds the keys "Predicted LB" and - "Predicted UB". See the documentation of each component for more - details. - """ - - # Generate model - # ------------------------------------------------------- - instance.load() - if model is None: - with _RedirectOutput([]): - model = instance.to_model() - - # Initialize training sample - # ------------------------------------------------------- - sample = instance.create_sample() - - # Initialize stats - # ------------------------------------------------------- - stats: LearningSolveStats = {} - - # Initialize internal solver - # ------------------------------------------------------- - self.tee = tee - self.internal_solver = self.internal_solver_prototype.clone() - assert self.internal_solver is not None - assert isinstance(self.internal_solver, InternalSolver) - self.internal_solver.set_instance(instance, model) - - # Extract features (after-load) - # ------------------------------------------------------- - logger.info("Extracting features (after-load)...") - initial_time = time.time() - self.extractor.extract_after_load_features( - instance, self.internal_solver, sample - ) - logger.info( - "Features (after-load) extracted in %.2f seconds" - % (time.time() - initial_time) - ) - - callback_args = ( - self, - instance, - model, - stats, - sample, - ) - - # Solve root LP relaxation - # ------------------------------------------------------- - lp_stats = None - if self.solve_lp: - logger.debug("Running before_solve_lp callbacks...") - for component in self.components.values(): - component.before_solve_lp(*callback_args) - - logger.info("Solving root LP relaxation...") - lp_stats = self.internal_solver.solve_lp(tee=tee) - stats.update(cast(LearningSolveStats, lp_stats.__dict__)) - assert lp_stats.lp_wallclock_time is not None - logger.info( - "LP relaxation solved in %.2f seconds" % lp_stats.lp_wallclock_time - ) - - logger.debug("Running after_solve_lp callbacks...") - for component in self.components.values(): - component.after_solve_lp(*callback_args) - - # Extract features (after-lp) - # ------------------------------------------------------- - logger.info("Extracting features (after-lp)...") - initial_time = time.time() - self.extractor.extract_after_lp_features( - self.internal_solver, sample, lp_stats - ) - logger.info( - "Features (after-lp) extracted in %.2f seconds" - % (time.time() - initial_time) - ) - - # Callback wrappers - # ------------------------------------------------------- - def iteration_cb_wrapper() -> bool: - should_repeat = False - for comp in self.components.values(): - if comp.iteration_cb(self, instance, model): - should_repeat = True - return should_repeat - - def lazy_cb_wrapper( - cb_solver: InternalSolver, - cb_model: Any, - ) -> None: - for comp in self.components.values(): - comp.lazy_cb(self, instance, model) - - def user_cut_cb_wrapper( - cb_solver: InternalSolver, - cb_model: Any, - ) -> None: - for comp in self.components.values(): - comp.user_cut_cb(self, instance, model) - - lazy_cb = None - if self.use_lazy_cb: - lazy_cb = lazy_cb_wrapper - - user_cut_cb = None - if instance.has_user_cuts(): - user_cut_cb = user_cut_cb_wrapper - - # Before-solve callbacks - # ------------------------------------------------------- - logger.debug("Running before_solve_mip callbacks...") - for component in self.components.values(): - component.before_solve_mip(*callback_args) - - # Solve MIP - # ------------------------------------------------------- - logger.info("Solving MIP...") - mip_stats = self.internal_solver.solve( - tee=tee, - iteration_cb=iteration_cb_wrapper, - user_cut_cb=user_cut_cb, - lazy_cb=lazy_cb, - ) - assert mip_stats.mip_wallclock_time is not None - logger.info("MIP solved in %.2f seconds" % mip_stats.mip_wallclock_time) - stats.update(cast(LearningSolveStats, mip_stats.__dict__)) - stats["Solver"] = "default" - stats["Gap"] = self._compute_gap( - ub=mip_stats.mip_upper_bound, - lb=mip_stats.mip_lower_bound, - ) - stats["Mode"] = self.mode - - # Extract features (after-mip) - # ------------------------------------------------------- - logger.info("Extracting features (after-mip)...") - initial_time = time.time() - for (k, v) in mip_stats.__dict__.items(): - sample.put_scalar(k, v) - self.extractor.extract_after_mip_features(self.internal_solver, sample) - logger.info( - "Features (after-mip) extracted in %.2f seconds" - % (time.time() - initial_time) - ) - - # After-solve callbacks - # ------------------------------------------------------- - logger.debug("Calling after_solve_mip callbacks...") - for component in self.components.values(): - component.after_solve_mip(*callback_args) - - # Flush - # ------------------------------------------------------- - if not discard_output: - instance.flush() - instance.free() - - return stats - - def solve( - self, - arg: Union[Any, List[str]], - build_model: Optional[Callable] = None, - tee: bool = False, - progress: bool = False, - skip: bool = False, - ) -> Union[LearningSolveStats, List[LearningSolveStats]]: - if isinstance(arg, list): + def __init__(self, components: List[Any], skip_lp=False): + self.components = components + self.skip_lp = skip_lp + + def fit(self, data_filenames): + h5_filenames = [_to_h5_filename(f) for f in data_filenames] + for comp in self.components: + comp.fit(h5_filenames) + + def optimize(self, model: Union[str, AbstractModel], build_model=None): + if isinstance(model, str): + h5_filename = _to_h5_filename(model) assert build_model is not None - stats = [] - for i in tqdm(arg, disable=not progress): - instance = FileInstanceWrapper(i, build_model) - solved = False - if exists(instance.sample_filename): - try: - with Hdf5Sample(instance.sample_filename, mode="r") as sample: - if sample.get_scalar("mip_lower_bound"): - solved = True - except OSError: - # File exists but it is unreadable/corrupted. Delete it. - remove(instance.sample_filename) - if solved and skip: - stats.append({}) - else: - s = self._solve(instance, tee=tee) - - # Export to gzipped MPS file - mps_filename = instance.sample_filename.replace(".h5", ".mps") - instance.model.write( - filename=mps_filename, - io_options={ - "labeler": pe.NameLabeler(), - "skip_objective_sense": True, - }, - ) - with open(mps_filename, "rb") as original: - with gzip.open(f"{mps_filename}.gz", "wb") as compressed: - compressed.writelines(original) - remove(mps_filename) - - stats.append(s) - return stats + model = build_model(model) else: - return self._solve(MemoryInstanceWrapper(arg), tee=tee) - - def fit( - self, - filenames: List[str], - build_model: Callable, - progress: bool = False, - n_jobs: int = 1, - ) -> None: - instances: List[Instance] = [ - FileInstanceWrapper(f, build_model, mode="r") for f in filenames - ] - self._fit(instances, progress=progress, n_jobs=n_jobs) + h5_filename = NamedTemporaryFile().name + stats = {} + mode = "r+" if exists(h5_filename) else "w" + with H5File(h5_filename, mode) as h5: + model.extract_after_load(h5) + if not self.skip_lp: + relaxed = model.relax() + relaxed.optimize() + relaxed.extract_after_lp(h5) + for comp in self.components: + comp.before_mip(h5_filename, model, stats) + model.optimize() + model.extract_after_mip(h5) - def parallel_solve( - self, - filenames: List[str], - build_model: Optional[Callable] = None, - n_jobs: int = 4, - progress: bool = False, - label: str = "solve", - skip: bool = False, - ) -> List[LearningSolveStats]: - self.internal_solver = None - self._silence_miplearn_logger() - _GLOBAL[0].solver = self - _GLOBAL[0].build_model = build_model - _GLOBAL[0].filenames = filenames - _GLOBAL[0].skip = skip - results = p_umap( - _parallel_solve, - list(range(len(filenames))), - num_cpus=n_jobs, - disable=not progress, - desc=label, - ) - stats: List[LearningSolveStats] = [{} for _ in range(len(filenames))] - for (idx, s) in results: - if s: - stats[idx] = s - self._restore_miplearn_logger() return stats - - def _fit( - self, - training_instances: List[Instance], - n_jobs: int = 1, - progress: bool = False, - ) -> None: - if len(training_instances) == 0: - logger.warning("Empty list of training instances provided. Skipping.") - return - Component.fit_multiple( - list(self.components.values()), - training_instances, - n_jobs=n_jobs, - progress=progress, - ) - - def _add_component(self, component: Component) -> None: - name = component.__class__.__name__ - self.components[name] = component - - def _silence_miplearn_logger(self) -> None: - miplearn_logger = logging.getLogger("miplearn") - self.prev_log_level = miplearn_logger.getEffectiveLevel() - miplearn_logger.setLevel(logging.WARNING) - - def _restore_miplearn_logger(self) -> None: - miplearn_logger = logging.getLogger("miplearn") - miplearn_logger.setLevel(self.prev_log_level) - - def __getstate__(self) -> Dict: - self.internal_solver = None - return self.__dict__ - - @staticmethod - def _compute_gap(ub: Optional[float], lb: Optional[float]) -> Optional[float]: - if lb is None or ub is None or lb * ub < 0: - # solver did not find a solution and/or bound - return None - elif abs(ub - lb) < 1e-6: - # avoid division by zero when ub = lb = 0 - return 0.0 - else: - # divide by max(abs(ub),abs(lb)) to ensure gap <= 1 - return (ub - lb) / max(abs(ub), abs(lb)) diff --git a/miplearn/solvers/pyomo.py b/miplearn/solvers/pyomo.py new file mode 100644 index 0000000..5776c63 --- /dev/null +++ b/miplearn/solvers/pyomo.py @@ -0,0 +1,366 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from numbers import Number +from typing import Optional, Dict, List, Any + +import numpy as np +import pyomo +from pyomo.core import Objective, Var, Suffix +from pyomo.core.base import _GeneralVarData +from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression +from scipy.sparse import coo_matrix + +from miplearn.h5 import H5File +from miplearn.solvers.abstract import AbstractModel +import pyomo.environ as pe + + +class PyomoModel(AbstractModel): + def __init__(self, model: pe.ConcreteModel, solver_name: str = "gurobi_persistent"): + self.inner = model + self.solver_name = solver_name + self.solver = pe.SolverFactory(solver_name) + self.is_persistent = hasattr(self.solver, "set_instance") + if self.is_persistent: + self.solver.set_instance(model) + self.results = None + self._is_warm_start_available = False + if not hasattr(self.inner, "dual"): + self.inner.dual = Suffix(direction=Suffix.IMPORT) + self.inner.rc = Suffix(direction=Suffix.IMPORT) + self.inner.slack = Suffix(direction=Suffix.IMPORT) + + def add_constrs( + self, + var_names: np.ndarray, + constrs_lhs: np.ndarray, + constrs_sense: np.ndarray, + constrs_rhs: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + variables = self._var_names_to_vars(var_names) + if not hasattr(self.inner, "added_eqs"): + self.inner.added_eqs = pe.ConstraintList() + for i in range(len(constrs_sense)): + lhs = sum([variables[j] * constrs_lhs[i, j] for j in range(len(variables))]) + sense = constrs_sense[i] + rhs = constrs_rhs[i] + if sense == b"=": + eq = self.inner.added_eqs.add(lhs == rhs) + elif sense == b"<": + eq = self.inner.added_eqs.add(lhs <= rhs) + elif sense == b">": + eq = self.inner.added_eqs.add(lhs >= rhs) + else: + raise Exception(f"Unknown sense: {sense}") + self.solver.add_constraint(eq) + + def _var_names_to_vars(self, var_names): + varname_to_var = {} + for var in self.inner.component_objects(Var): + for idx in var: + v = var[idx] + varname_to_var[v.name] = var[idx] + return [varname_to_var[var_name.decode()] for var_name in var_names] + + def extract_after_load(self, h5: H5File) -> None: + self._extract_after_load_vars(h5) + self._extract_after_load_constrs(h5) + h5.put_scalar("static_sense", self._get_sense()) + + def extract_after_lp(self, h5: H5File) -> None: + self._extract_after_lp_vars(h5) + self._extract_after_lp_constrs(h5) + h5.put_scalar("lp_obj_value", self.results["Problem"][0]["Lower bound"]) + h5.put_scalar("lp_wallclock_time", self._get_runtime()) + + def _get_runtime(self): + solver_dict = self.results["Solver"][0] + for key in ["Wallclock time", "User time"]: + if isinstance(solver_dict[key], Number): + return solver_dict[key] + raise Exception("Time unavailable") + + def extract_after_mip(self, h5: H5File) -> None: + h5.put_scalar("mip_wallclock_time", self._get_runtime()) + if self.results["Solver"][0]["Termination condition"] == "infeasible": + return + self._extract_after_mip_vars(h5) + self._extract_after_mip_constrs(h5) + if self._get_sense() == "max": + obj_value = self.results["Problem"][0]["Lower bound"] + obj_bound = self.results["Problem"][0]["Upper bound"] + else: + obj_value = self.results["Problem"][0]["Upper bound"] + obj_bound = self.results["Problem"][0]["Lower bound"] + h5.put_scalar("mip_obj_value", obj_value) + h5.put_scalar("mip_obj_bound", obj_bound) + h5.put_scalar("mip_gap", self._gap(obj_value, obj_bound)) + + def fix_variables( + self, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + variables = self._var_names_to_vars(var_names) + for (var, val) in zip(variables, var_values): + if np.isfinite(val): + var.fix(val) + self.solver.update_var(var) + + def optimize(self) -> None: + if self.is_persistent: + self.results = self.solver.solve( + tee=True, + warmstart=self._is_warm_start_available, + ) + else: + self.results = self.solver.solve( + self.inner, + tee=True, + ) + + def relax(self) -> "AbstractModel": + relaxed = self.inner.clone() + for var in relaxed.component_objects(Var): + for idx in var: + if var[idx].domain == pyomo.core.base.set_types.Binary: + lb, ub = var[idx].bounds + var[idx].setlb(lb) + var[idx].setub(ub) + var[idx].domain = pyomo.core.base.set_types.Reals + return PyomoModel(relaxed, self.solver_name) + + def set_warm_starts( + self, + var_names: np.ndarray, + var_values: np.ndarray, + stats: Optional[Dict] = None, + ) -> None: + assert len(var_values.shape) == 2 + (n_starts, n_vars) = var_values.shape + assert len(var_names.shape) == 1 + assert var_names.shape[0] == n_vars + assert n_starts == 1, "Pyomo does not support multiple warm starts" + variables = self._var_names_to_vars(var_names) + for (var, val) in zip(variables, var_values[0, :]): + if np.isfinite(val): + var.value = val + self._is_warm_start_available = True + + def _extract_after_load_vars(self, h5): + names: List[str] = [] + types: List[str] = [] + upper_bounds: List[float] = [] + lower_bounds: List[float] = [] + obj_coeffs: List[float] = [] + + obj = None + obj_offset = 0.0 + obj_count = 0 + for obj in self.inner.component_objects(Objective): + obj, obj_offset = self._parse_pyomo_expr(obj.expr) + obj_count += 1 + assert obj_count == 1, f"One objective function expected; found {obj_count}" + + for (i, var) in enumerate(self.inner.component_objects(pyomo.core.Var)): + for idx in var: + v = var[idx] + + # Variable name + if idx is None: + names.append(var.name) + else: + names.append(var[idx].name) + + # Variable type + if v.domain == pyomo.core.Binary: + types.append("B") + elif v.domain in [ + pyomo.core.Reals, + pyomo.core.NonNegativeReals, + pyomo.core.NonPositiveReals, + pyomo.core.NegativeReals, + pyomo.core.PositiveReals, + ]: + types.append("C") + else: + raise Exception(f"unknown variable domain: {v.domain}") + + # Variable upper/lower bounds + lb, ub = v.bounds + if lb is None: + lb = -float("inf") + if ub is None: + ub = float("Inf") + upper_bounds.append(float(ub)) + lower_bounds.append(float(lb)) + + # Objective coefficients + if v.name in obj: + obj_coeffs.append(obj[v.name]) + else: + obj_coeffs.append(0.0) + + h5.put_array("static_var_names", np.array(names, dtype="S")) + h5.put_array("static_var_types", np.array(types, dtype="S")) + h5.put_array("static_var_lower_bounds", np.array(lower_bounds)) + h5.put_array("static_var_upper_bounds", np.array(upper_bounds)) + h5.put_array("static_var_obj_coeffs", np.array(obj_coeffs)) + h5.put_scalar("static_obj_offset", obj_offset) + + def _extract_after_load_constrs(self, h5): + names: List[str] = [] + rhs: List[float] = [] + senses: List[str] = [] + lhs_row: List[int] = [] + lhs_col: List[int] = [] + lhs_data: List[float] = [] + + varname_to_idx = {} + for var in self.inner.component_objects(Var): + for idx in var: + varname = var.name + if idx is not None: + varname = var[idx].name + varname_to_idx[varname] = len(varname_to_idx) + + def _parse_constraint(c: pe.Constraint, row: int) -> None: + # Extract RHS and sense + has_ub = c.has_ub() + has_lb = c.has_lb() + assert ( + (not has_lb) or (not has_ub) or c.upper() == c.lower() + ), "range constraints not supported" + if not has_ub: + senses.append(">") + rhs.append(float(c.lower())) + elif not has_lb: + senses.append("<") + rhs.append(float(c.upper())) + else: + senses.append("=") + rhs.append(float(c.upper())) + + # Extract LHS + expr = c.body + if isinstance(expr, SumExpression): + for term in expr._args_: + if isinstance(term, MonomialTermExpression): + lhs_row.append(row) + lhs_col.append(varname_to_idx[term._args_[1].name]) + lhs_data.append(float(term._args_[0])) + elif isinstance(term, _GeneralVarData): + lhs_row.append(row) + lhs_col.append(varname_to_idx[term.name]) + lhs_data.append(1.0) + else: + raise Exception(f"Unknown term type: {term.__class__.__name__}") + elif isinstance(expr, _GeneralVarData): + lhs_row.append(row) + lhs_col.append(varname_to_idx[expr.name]) + lhs_data.append(1.0) + else: + raise Exception(f"Unknown expression type: {expr.__class__.__name__}") + + curr_row = 0 + for (i, constr) in enumerate( + self.inner.component_objects(pyomo.core.Constraint) + ): + if len(constr) > 0: + for idx in constr: + names.append(constr[idx].name) + _parse_constraint(constr[idx], curr_row) + curr_row += 1 + else: + names.append(constr.name) + _parse_constraint(constr, curr_row) + curr_row += 1 + + lhs = coo_matrix((lhs_data, (lhs_row, lhs_col))).tocoo() + h5.put_sparse("static_constr_lhs", lhs) + h5.put_array("static_constr_names", np.array(names, dtype="S")) + h5.put_array("static_constr_rhs", np.array(rhs)) + h5.put_array("static_constr_sense", np.array(senses, dtype="S")) + + def _extract_after_lp_vars(self, h5): + rc = [] + values = [] + for var in self.inner.component_objects(Var): + for idx in var: + v = var[idx] + rc.append(self.inner.rc[v]) + values.append(v.value) + h5.put_array("lp_var_reduced_costs", np.array(rc)) + h5.put_array("lp_var_values", np.array(values)) + + def _extract_after_lp_constrs(self, h5): + dual = [] + slacks = [] + for constr in self.inner.component_objects(pyomo.core.Constraint): + for idx in constr: + c = constr[idx] + dual.append(self.inner.dual[c]) + slacks.append(abs(self.inner.slack[c])) + h5.put_array("lp_constr_dual_values", np.array(dual)) + h5.put_array("lp_constr_slacks", np.array(slacks)) + + def _extract_after_mip_vars(self, h5): + values = [] + for var in self.inner.component_objects(Var): + for idx in var: + v = var[idx] + values.append(v.value) + h5.put_array("mip_var_values", np.array(values)) + + def _extract_after_mip_constrs(self, h5): + slacks = [] + for constr in self.inner.component_objects(pyomo.core.Constraint): + for idx in constr: + c = constr[idx] + slacks.append(abs(self.inner.slack[c])) + h5.put_array("mip_constr_slacks", np.array(slacks)) + + def _parse_pyomo_expr(self, expr: Any): + lhs = {} + offset = 0.0 + if isinstance(expr, SumExpression): + for term in expr._args_: + if isinstance(term, MonomialTermExpression): + lhs[term._args_[1].name] = float(term._args_[0]) + elif isinstance(term, _GeneralVarData): + lhs[term.name] = 1.0 + elif isinstance(term, Number): + offset += term + else: + raise Exception(f"Unknown term type: {term.__class__.__name__}") + elif isinstance(expr, _GeneralVarData): + lhs[expr.name] = 1.0 + else: + raise Exception(f"Unknown expression type: {expr.__class__.__name__}") + return lhs, offset + + def _gap(self, zp, zd, tol=1e-6): + # Reference: https://www.gurobi.com/documentation/9.5/refman/mipgap2.html + if abs(zp) < tol: + if abs(zd) < tol: + return 0 + else: + return float("inf") + else: + return abs(zp - zd) / abs(zp) + + def _get_sense(self): + for obj in self.inner.component_objects(Objective): + sense = obj.sense + if sense == pyomo.core.kernel.objective.minimize: + return "min" + elif sense == pyomo.core.kernel.objective.maximize: + return "max" + else: + raise Exception(f"Unknown sense: ${sense}") + + def write(self, filename: str) -> None: + self.inner.write(filename, io_options={"symbolic_solver_labels": True}) diff --git a/miplearn/solvers/pyomo/__init__.py b/miplearn/solvers/pyomo/__init__.py deleted file mode 100644 index 5fbccb1..0000000 --- a/miplearn/solvers/pyomo/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py deleted file mode 100644 index 8c34533..0000000 --- a/miplearn/solvers/pyomo/base.py +++ /dev/null @@ -1,677 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -import re -import sys -from io import StringIO -from typing import Any, List, Dict, Optional - -import numpy as np -import pyomo -from overrides import overrides -from pyomo import environ as pe -from pyomo.core import Var, Suffix, Objective -from pyomo.core.base import _GeneralVarData -from pyomo.core.base.constraint import ConstraintList -from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression -from pyomo.opt import TerminationCondition -from pyomo.opt.base.solvers import SolverFactory -from scipy.sparse import coo_matrix - -from miplearn.instance.base import Instance -from miplearn.solvers import _RedirectOutput, _none_if_empty -from miplearn.solvers.internal import ( - InternalSolver, - LPSolveStats, - IterationCallback, - LazyCallback, - MIPSolveStats, - Variables, - Constraints, -) -from miplearn.types import ( - SolverParams, - UserCutCallback, - Solution, -) - -logger = logging.getLogger(__name__) - - -class BasePyomoSolver(InternalSolver): - """ - Base class for all Pyomo solvers. - """ - - def __init__( - self, - solver_factory: SolverFactory, - params: SolverParams, - ) -> None: - self.instance: Optional[Instance] = None - self.model: Optional[pe.ConcreteModel] = None - self.params = params - self._all_vars: List[pe.Var] = [] - self._bin_vars: List[pe.Var] = [] - self._is_warm_start_available: bool = False - self._pyomo_solver: SolverFactory = solver_factory - self._obj_sense: str = "min" - self._varname_to_var: Dict[bytes, pe.Var] = {} - self._varname_to_idx: Dict[str, int] = {} - self._name_buffer = {} - self._cname_to_constr: Dict[str, pe.Constraint] = {} - self._termination_condition: str = "" - self._has_lp_solution = False - self._has_mip_solution = False - self._obj: Dict[str, float] = {} - - for (key, value) in params.items(): - self._pyomo_solver.options[key] = value - - def add_constraint( - self, - constr: Any, - ) -> None: - assert self.model is not None - self._pyomo_solver.add_constraint(constr) - self._termination_condition = "" - self._has_lp_solution = False - self._has_mip_solution = False - - @overrides - def add_constraints(self, cf: Constraints) -> None: - assert cf.names is not None - assert cf.senses is not None - assert cf.lhs is not None - assert cf.rhs is not None - assert self.model is not None - lhs = cf.lhs.tocsr() - for i in range(len(cf.names)): - row = lhs[i, :] - lhsi = 0.0 - for j in range(row.getnnz()): - lhsi += self._all_vars[row.indices[j]] * row.data[j] - if cf.senses[i] == b"=": - expr = lhsi == cf.rhs[i] - elif cf.senses[i] == b"<": - expr = lhsi <= cf.rhs[i] - elif cf.senses[i] == b">": - expr = lhsi >= cf.rhs[i] - else: - raise Exception(f"Unknown sense: {cf.senses[i]}") - cl = pe.Constraint(expr=expr, name=cf.names[i]) - self.model.add_component(cf.names[i].decode(), cl) - self._pyomo_solver.add_constraint(cl) - self._cname_to_constr[cf.names[i]] = cl - self._termination_condition = "" - self._has_lp_solution = False - self._has_mip_solution = False - - @overrides - def are_callbacks_supported(self) -> bool: - return False - - @overrides - def are_constraints_satisfied( - self, - cf: Constraints, - tol: float = 1e-5, - ) -> List[bool]: - assert cf.names is not None - assert cf.lhs is not None - assert cf.rhs is not None - assert cf.senses is not None - x = [v.value for v in self._all_vars] - lhs = cf.lhs.tocsr() * x - result = [] - for i in range(len(lhs)): - if cf.senses[i] == b"<": - result.append(lhs[i] <= cf.rhs[i] + tol) - elif cf.senses[i] == b">": - result.append(lhs[i] >= cf.rhs[i] - tol) - elif cf.senses[i] == b"=": - result.append(abs(cf.rhs[i] - lhs[i]) < tol) - else: - raise Exception(f"unknown sense: {cf.senses[i]}") - return result - - @overrides - def build_test_instance_infeasible(self) -> Instance: - return PyomoTestInstanceInfeasible() - - @overrides - def build_test_instance_knapsack(self) -> Instance: - return PyomoTestInstanceKnapsack( - weights=[23.0, 26.0, 20.0, 18.0], - prices=[505.0, 352.0, 458.0, 220.0], - capacity=67.0, - ) - - @overrides - def fix(self, solution: Solution) -> None: - for (varname, value) in solution.items(): - if value is None: - continue - var = self._varname_to_var[varname] - var.fix(value) - self._pyomo_solver.update_var(var) - - @overrides - def get_constraints( - self, - with_static: bool = True, - with_sa: bool = True, - with_lhs: bool = True, - ) -> Constraints: - model = self.model - assert model is not None - names: List[str] = [] - rhs: List[float] = [] - senses: List[str] = [] - dual_values: List[float] = [] - slacks: List[float] = [] - lhs_row: List[int] = [] - lhs_col: List[int] = [] - lhs_data: List[float] = [] - lhs: Optional[coo_matrix] = None - - def _parse_constraint(c: pe.Constraint, row: int) -> None: - assert model is not None - if with_static: - # Extract RHS and sense - has_ub = c.has_ub() - has_lb = c.has_lb() - assert ( - (not has_lb) or (not has_ub) or c.upper() == c.lower() - ), "range constraints not supported" - if not has_ub: - senses.append(">") - rhs.append(float(c.lower())) - elif not has_lb: - senses.append("<") - rhs.append(float(c.upper())) - else: - senses.append("=") - rhs.append(float(c.upper())) - - if with_lhs: - # Extract LHS - expr = c.body - if isinstance(expr, SumExpression): - for term in expr._args_: - if isinstance(term, MonomialTermExpression): - lhs_row.append(row) - lhs_col.append( - self._varname_to_idx[self.name(term._args_[1])] - ) - lhs_data.append(float(term._args_[0])) - elif isinstance(term, _GeneralVarData): - lhs_row.append(row) - lhs_col.append(self._varname_to_idx[self.name(term)]) - lhs_data.append(1.0) - else: - raise Exception( - f"Unknown term type: {term.__class__.__name__}" - ) - elif isinstance(expr, _GeneralVarData): - lhs_row.append(row) - lhs_col.append(self._varname_to_idx[self.name(expr)]) - lhs_data.append(1.0) - else: - raise Exception( - f"Unknown expression type: {expr.__class__.__name__}" - ) - - # Extract dual values - if self._has_lp_solution: - dual_values.append(model.dual[c]) - - # Extract slacks - if self._has_mip_solution or self._has_lp_solution: - slacks.append(model.slack[c]) - - curr_row = 0 - for (i, constr) in enumerate(model.component_objects(pyomo.core.Constraint)): - if isinstance(constr, pe.ConstraintList): - for idx in constr: - names.append(self.name(constr[idx])) - _parse_constraint(constr[idx], curr_row) - curr_row += 1 - else: - names.append(self.name(constr)) - _parse_constraint(constr, curr_row) - curr_row += 1 - - if len(lhs_data) > 0: - lhs = coo_matrix((lhs_data, (lhs_row, lhs_col))).tocoo() - - return Constraints( - names=_none_if_empty(np.array(names, dtype="S")), - rhs=_none_if_empty(np.array(rhs, dtype=float)), - senses=_none_if_empty(np.array(senses, dtype="S")), - lhs=lhs, - slacks=_none_if_empty(np.array(slacks, dtype=float)), - dual_values=_none_if_empty(np.array(dual_values, dtype=float)), - ) - - @overrides - def get_constraint_attrs(self) -> List[str]: - return [ - "dual_values", - "lhs", - "names", - "rhs", - "senses", - "slacks", - ] - - @overrides - def get_solution(self) -> Optional[Solution]: - assert self.model is not None - if self.is_infeasible(): - return None - solution: Solution = {} - for var in self.model.component_objects(Var): - for index in var: - if var[index].fixed: - continue - solution[self.name(var[index]).encode()] = var[index].value - return solution - - @overrides - def get_variables( - self, - with_static: bool = True, - with_sa: bool = True, - ) -> Variables: - assert self.model is not None - - names: List[str] = [] - types: List[str] = [] - upper_bounds: List[float] = [] - lower_bounds: List[float] = [] - obj_coeffs: List[float] = [] - reduced_costs: List[float] = [] - values: List[float] = [] - - for (i, var) in enumerate(self.model.component_objects(pyomo.core.Var)): - for idx in var: - v = var[idx] - - # Variable name - if idx is None: - names.append(self.name(var)) - else: - names.append(self.name(var[idx])) - - if with_static: - # Variable type - if v.domain == pyomo.core.Binary: - types.append("B") - elif v.domain in [ - pyomo.core.Reals, - pyomo.core.NonNegativeReals, - pyomo.core.NonPositiveReals, - pyomo.core.NegativeReals, - pyomo.core.PositiveReals, - ]: - types.append("C") - else: - raise Exception(f"unknown variable domain: {v.domain}") - - # Bounds - lb, ub = v.bounds - if ub is not None: - upper_bounds.append(float(ub)) - else: - upper_bounds.append(float("inf")) - if lb is not None: - lower_bounds.append(float(lb)) - else: - lower_bounds.append(-float("inf")) - - # Objective coefficient - name = self.name(v) - if name in self._obj: - obj_coeffs.append(self._obj[name]) - else: - obj_coeffs.append(0.0) - - # Reduced costs - if self._has_lp_solution: - reduced_costs.append(self.model.rc[v]) - - # Values - if self._has_lp_solution or self._has_mip_solution: - values.append(v.value) - - return Variables( - names=_none_if_empty(np.array(names, dtype="S")), - types=_none_if_empty(np.array(types, dtype="S")), - upper_bounds=_none_if_empty(np.array(upper_bounds, dtype=float)), - lower_bounds=_none_if_empty(np.array(lower_bounds, dtype=float)), - obj_coeffs=_none_if_empty(np.array(obj_coeffs, dtype=float)), - reduced_costs=_none_if_empty(np.array(reduced_costs, dtype=float)), - values=_none_if_empty(np.array(values, dtype=float)), - ) - - @overrides - def get_variable_attrs(self) -> List[str]: - return [ - "names", - # "basis_status", - "categories", - "lower_bounds", - "obj_coeffs", - "reduced_costs", - # "sa_lb_down", - # "sa_lb_up", - # "sa_obj_down", - # "sa_obj_up", - # "sa_ub_down", - # "sa_ub_up", - "types", - "upper_bounds", - "user_features", - "values", - ] - - @overrides - def is_infeasible(self) -> bool: - return self._termination_condition == TerminationCondition.infeasible - - @overrides - def remove_constraints(self, names: List[str]) -> None: - assert self.model is not None - for name in names: - constr = self._cname_to_constr[name] - del self._cname_to_constr[name] - self.model.del_component(constr) - self._pyomo_solver.remove_constraint(constr) - - @overrides - def set_instance( - self, - instance: Instance, - model: Any = None, - ) -> None: - if model is None: - model = instance.to_model() - assert isinstance( - model, pe.ConcreteModel - ), f"expected pe.ConcreteModel; found {model.__class__} instead" - self.instance = instance - self.model = model - self.model.extra_constraints = ConstraintList() - self.model.dual = Suffix(direction=Suffix.IMPORT) - self.model.rc = Suffix(direction=Suffix.IMPORT) - self.model.slack = Suffix(direction=Suffix.IMPORT) - self._pyomo_solver.set_instance(model) - self._update_obj() - self._update_vars() - self._update_constrs() - - @overrides - def set_warm_start(self, solution: Solution) -> None: - self._clear_warm_start() - count_fixed = 0 - for (var_name, value) in solution.items(): - if value is None: - continue - var = self._varname_to_var[var_name] - var.value = solution[var_name] - count_fixed += 1 - if count_fixed > 0: - self._is_warm_start_available = True - - @overrides - def solve( - self, - tee: bool = False, - iteration_cb: Optional[IterationCallback] = None, - lazy_cb: Optional[LazyCallback] = None, - user_cut_cb: Optional[UserCutCallback] = None, - ) -> MIPSolveStats: - assert lazy_cb is None, "callbacks are not currently supported" - assert user_cut_cb is None, "callbacks are not currently supported" - total_wallclock_time = 0 - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - if iteration_cb is None: - iteration_cb = lambda: False - while True: - logger.debug("Solving MIP...") - with _RedirectOutput(streams): - results = self._pyomo_solver.solve( - tee=True, - warmstart=self._is_warm_start_available, - ) - self._termination_condition = results["Solver"][0]["Termination condition"] - total_wallclock_time += results["Solver"][0]["Wallclock time"] - if self.is_infeasible(): - break - should_repeat = iteration_cb() - if not should_repeat: - break - log = streams[0].getvalue() - node_count = self._extract_node_count(log) - ws_value = self._extract_warm_start_value(log) - lb, ub = None, None - self._has_mip_solution = False - self._has_lp_solution = False - if not self.is_infeasible(): - self._has_mip_solution = True - lb = results["Problem"][0]["Lower bound"] - ub = results["Problem"][0]["Upper bound"] - return MIPSolveStats( - mip_lower_bound=lb, - mip_upper_bound=ub, - mip_wallclock_time=total_wallclock_time, - mip_sense=self._obj_sense, - mip_log=log, - mip_nodes=node_count, - mip_warm_start_value=ws_value, - ) - - @overrides - def solve_lp( - self, - tee: bool = False, - ) -> LPSolveStats: - self._relax() - streams: List[Any] = [StringIO()] - if tee: - streams += [sys.stdout] - with _RedirectOutput(streams): - results = self._pyomo_solver.solve(tee=True) - self._termination_condition = results["Solver"][0]["Termination condition"] - self._restore_integrality() - opt_value = None - self._has_lp_solution = False - self._has_mip_solution = False - if not self.is_infeasible(): - opt_value = results["Problem"][0]["Lower bound"] - self._has_lp_solution = True - return LPSolveStats( - lp_value=opt_value, - lp_log=streams[0].getvalue(), - lp_wallclock_time=results["Solver"][0]["Wallclock time"], - ) - - def _clear_warm_start(self) -> None: - for var in self._all_vars: - if not var.fixed: - var.value = None - self._is_warm_start_available = False - - @staticmethod - def _extract( - log: str, - regexp: Optional[str], - default: Optional[str] = None, - ) -> Optional[str]: - if regexp is None: - return default - value = default - for line in log.splitlines(): - matches = re.findall(regexp, line) - if len(matches) == 0: - continue - value = matches[0] - return value - - def _extract_node_count(self, log: str) -> Optional[int]: - value = self._extract(log, self._get_node_count_regexp()) - if value is None: - return None - return int(value) - - def _extract_warm_start_value(self, log: str) -> Optional[float]: - value = self._extract(log, self._get_warm_start_regexp()) - if value is None: - return None - return float(value) - - def _get_node_count_regexp(self) -> Optional[str]: - return None - - def _get_warm_start_regexp(self) -> Optional[str]: - return None - - def _parse_pyomo_expr(self, expr: Any) -> Dict[str, float]: - lhs = {} - if isinstance(expr, SumExpression): - for term in expr._args_: - if isinstance(term, MonomialTermExpression): - lhs[self.name(term._args_[1])] = float(term._args_[0]) - elif isinstance(term, _GeneralVarData): - lhs[self.name(term)] = 1.0 - else: - raise Exception(f"Unknown term type: {term.__class__.__name__}") - elif isinstance(expr, _GeneralVarData): - lhs[self.name(expr)] = 1.0 - else: - raise Exception(f"Unknown expression type: {expr.__class__.__name__}") - return lhs - - def _relax(self) -> None: - for var in self._bin_vars: - lb, ub = var.bounds - var.setlb(lb) - var.setub(ub) - var.domain = pyomo.core.base.set_types.Reals - self._pyomo_solver.update_var(var) - - def _restore_integrality(self) -> None: - for var in self._bin_vars: - var.domain = pyomo.core.base.set_types.Binary - self._pyomo_solver.update_var(var) - - def _update_obj(self) -> None: - self._obj_sense = "max" - if self._pyomo_solver._objective.sense == pyomo.core.kernel.objective.minimize: - self._obj_sense = "min" - - def _update_vars(self) -> None: - assert self.model is not None - self._all_vars = [] - self._bin_vars = [] - self._varname_to_var = {} - self._varname_to_idx = {} - for var in self.model.component_objects(Var): - for idx in var: - varname = self.name(var) - if idx is not None: - varname = self.name(var[idx]) - self._varname_to_var[varname.encode()] = var[idx] - self._varname_to_idx[varname] = len(self._all_vars) - self._all_vars += [var[idx]] - if var[idx].domain == pyomo.core.base.set_types.Binary: - self._bin_vars += [var[idx]] - for obj in self.model.component_objects(Objective): - self._obj = self._parse_pyomo_expr(obj.expr) - break - - def _update_constrs(self) -> None: - assert self.model is not None - self._cname_to_constr.clear() - for constr in self.model.component_objects(pyomo.core.Constraint): - if isinstance(constr, pe.ConstraintList): - for idx in constr: - self._cname_to_constr[self.name(constr[idx])] = constr[idx] - else: - self._cname_to_constr[self.name(constr)] = constr - - def name(self, comp): - return comp.getname(name_buffer=self._name_buffer) - - -class PyomoTestInstanceInfeasible(Instance): - @overrides - def to_model(self) -> pe.ConcreteModel: - model = pe.ConcreteModel() - model.x = pe.Var([0], domain=pe.Binary) - model.OBJ = pe.Objective(expr=model.x[0], sense=pe.maximize) - model.eq = pe.Constraint(expr=model.x[0] >= 2) - return model - - -class PyomoTestInstanceKnapsack(Instance): - """ - Simpler (one-dimensional) Knapsack Problem, used for testing. - """ - - def __init__( - self, - weights: List[float], - prices: List[float], - capacity: float, - ) -> None: - super().__init__() - self.weights = weights - self.prices = prices - self.capacity = capacity - self.n = len(weights) - - @overrides - def to_model(self) -> pe.ConcreteModel: - model = pe.ConcreteModel() - items = range(len(self.weights)) - model.x = pe.Var(items, domain=pe.Binary) - model.z = pe.Var(domain=pe.Reals, bounds=(0, self.capacity)) - model.OBJ = pe.Objective( - expr=sum(model.x[v] * self.prices[v] for v in items), - sense=pe.maximize, - ) - model.eq_capacity = pe.Constraint( - expr=sum(model.x[v] * self.weights[v] for v in items) == model.z - ) - return model - - @overrides - def get_instance_features(self) -> np.ndarray: - return np.array( - [ - self.capacity, - np.average(self.weights), - ] - ) - - @overrides - def get_variable_features(self, names: np.ndarray) -> np.ndarray: - return np.vstack( - [ - [[self.weights[i], self.prices[i]] for i in range(self.n)], - [0.0, 0.0], - ] - ) - - @overrides - def get_variable_categories(self, names: np.ndarray) -> np.ndarray: - return np.array( - ["default" if n.decode().startswith("x") else "" for n in names], - dtype="S", - ) diff --git a/miplearn/solvers/pyomo/cplex.py b/miplearn/solvers/pyomo/cplex.py deleted file mode 100644 index 021a430..0000000 --- a/miplearn/solvers/pyomo/cplex.py +++ /dev/null @@ -1,48 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from typing import Optional - -from overrides import overrides -from pyomo import environ as pe -from scipy.stats import randint - -from miplearn.solvers.pyomo.base import BasePyomoSolver -from miplearn.types import SolverParams - - -class CplexPyomoSolver(BasePyomoSolver): - """ - An InternalSolver that uses CPLEX and the Pyomo modeling language. - - Parameters - ---------- - params: dict - Dictionary of options to pass to the Pyomo solver. For example, - {"mip_display": 5} to increase the log verbosity. - """ - - def __init__( - self, - params: Optional[SolverParams] = None, - ) -> None: - if params is None: - params = {} - if "mip_display" not in params.keys(): - params["mip_display"] = 4 - super().__init__( - solver_factory=pe.SolverFactory("cplex_persistent"), - params=params, - ) - - @overrides - def _get_warm_start_regexp(self) -> str: - return "MIP start .* with objective ([0-9.e+-]*)\\." - - @overrides - def _get_node_count_regexp(self) -> str: - return "^[ *] *([0-9]+)" - - @overrides - def clone(self) -> "CplexPyomoSolver": - return CplexPyomoSolver(params=self.params) diff --git a/miplearn/solvers/pyomo/gurobi.py b/miplearn/solvers/pyomo/gurobi.py deleted file mode 100644 index 9487845..0000000 --- a/miplearn/solvers/pyomo/gurobi.py +++ /dev/null @@ -1,61 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import Optional - -from overrides import overrides -from pyomo import environ as pe -from scipy.stats import randint - -from miplearn.solvers.pyomo.base import BasePyomoSolver -from miplearn.types import SolverParams - -logger = logging.getLogger(__name__) - - -class GurobiPyomoSolver(BasePyomoSolver): - """ - An InternalSolver that uses Gurobi and the Pyomo modeling language. - - Parameters - ---------- - params: dict - Dictionary of options to pass to the Pyomo solver. For example, - {"Threads": 4} to set the number of threads. - """ - - def __init__( - self, - params: Optional[SolverParams] = None, - ) -> None: - if params is None: - params = {} - super().__init__( - solver_factory=pe.SolverFactory("gurobi_persistent"), - params=params, - ) - - @overrides - def clone(self) -> "GurobiPyomoSolver": - return GurobiPyomoSolver(params=self.params) - - @overrides - def _extract_node_count(self, log: str) -> int: - return max(1, int(self._pyomo_solver._solver_model.getAttr("NodeCount"))) - - @overrides - def _get_warm_start_regexp(self) -> str: - return "MIP start with objective ([0-9.e+-]*)" - - @overrides - def _get_node_count_regexp(self) -> Optional[str]: - return None - - def set_priorities(self, priorities): - for (var_name, priority) in priorities.items(): - pvar = self._varname_to_var[var_name] - gvar = self._pyomo_solver._pyomo_var_to_solver_var_map[pvar] - gvar.branchPriority = priority - return None diff --git a/miplearn/solvers/pyomo/xpress.py b/miplearn/solvers/pyomo/xpress.py deleted file mode 100644 index b0cf763..0000000 --- a/miplearn/solvers/pyomo/xpress.py +++ /dev/null @@ -1,42 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import Optional - -from overrides import overrides -from pyomo import environ as pe -from scipy.stats import randint - -from miplearn.solvers.pyomo.base import BasePyomoSolver -from miplearn.types import SolverParams - -logger = logging.getLogger(__name__) - - -class XpressPyomoSolver(BasePyomoSolver): - """ - An InternalSolver that uses XPRESS and the Pyomo modeling language. - - Parameters - ---------- - params: dict - Dictionary of options to pass to the Pyomo solver. For example, - {"Threads": 4} to set the number of threads. - """ - - def __init__( - self, - params: Optional[SolverParams] = None, - ) -> None: - if params is None: - params = {} - super().__init__( - solver_factory=pe.SolverFactory("xpress_persistent"), - params=params, - ) - - @overrides - def clone(self) -> "XpressPyomoSolver": - return XpressPyomoSolver(params=self.params) diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py deleted file mode 100644 index 34a8416..0000000 --- a/miplearn/solvers/tests/__init__.py +++ /dev/null @@ -1,288 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from typing import Any, List - -import numpy as np -from scipy.sparse import coo_matrix - -from miplearn.solvers.internal import InternalSolver, Variables, Constraints - -inf = float("inf") - - -# NOTE: -# This file is in the main source folder, so that it can be called from Julia. - - -def _filter_attrs(allowed_keys: List[str], obj: Any) -> Any: - for key in obj.__dict__.keys(): - if key not in allowed_keys: - setattr(obj, key, None) - return obj - - -def run_internal_solver_tests(solver: InternalSolver) -> None: - run_basic_usage_tests(solver.clone()) - run_warm_start_tests(solver.clone()) - run_infeasibility_tests(solver.clone()) - run_iteration_cb_tests(solver.clone()) - if solver.are_callbacks_supported(): - run_lazy_cb_tests(solver.clone()) - - -def run_basic_usage_tests(solver: InternalSolver) -> None: - # Create and set instance - instance = solver.build_test_instance_knapsack() - model = instance.to_model() - solver.set_instance(instance, model) - - # Fetch variables (after-load) - assert_equals( - solver.get_variables(), - Variables( - names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), - lower_bounds=np.array([0.0, 0.0, 0.0, 0.0, 0.0]), - upper_bounds=np.array([1.0, 1.0, 1.0, 1.0, 67.0]), - types=np.array(["B", "B", "B", "B", "C"], dtype="S"), - obj_coeffs=np.array([505.0, 352.0, 458.0, 220.0, 0.0]), - ), - ) - - # Fetch constraints (after-load) - assert_equals( - solver.get_constraints(), - Constraints( - names=np.array(["eq_capacity"], dtype="S"), - rhs=np.array([0.0]), - lhs=coo_matrix([[23.0, 26.0, 20.0, 18.0, -1.0]]), - senses=np.array(["="], dtype="S"), - ), - ) - - # Solve linear programming relaxation - lp_stats = solver.solve_lp() - assert not solver.is_infeasible() - assert lp_stats.lp_value is not None - assert_equals(round(lp_stats.lp_value, 3), 1287.923) - assert lp_stats.lp_log is not None - assert len(lp_stats.lp_log) > 100 - assert lp_stats.lp_wallclock_time is not None - assert lp_stats.lp_wallclock_time > 0 - - # Fetch variables (after-lp) - assert_equals( - solver.get_variables(with_static=False), - _filter_attrs( - solver.get_variable_attrs(), - Variables( - names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), - basis_status=np.array(["U", "B", "U", "L", "U"], dtype="S"), - reduced_costs=np.array( - [193.615385, 0.0, 187.230769, -23.692308, 13.538462] - ), - sa_lb_down=np.array([-inf, -inf, -inf, -0.111111, -inf]), - sa_lb_up=np.array([1.0, 0.923077, 1.0, 1.0, 67.0]), - sa_obj_down=np.array( - [311.384615, 317.777778, 270.769231, -inf, -13.538462] - ), - sa_obj_up=np.array([inf, 570.869565, inf, 243.692308, inf]), - sa_ub_down=np.array([0.913043, 0.923077, 0.9, 0.0, 43.0]), - sa_ub_up=np.array([2.043478, inf, 2.2, inf, 69.0]), - values=np.array([1.0, 0.923077, 1.0, 0.0, 67.0]), - ), - ), - ) - - # Fetch constraints (after-lp) - assert_equals( - solver.get_constraints(with_static=False), - _filter_attrs( - solver.get_constraint_attrs(), - Constraints( - basis_status=np.array(["N"], dtype="S"), - dual_values=np.array([13.538462]), - names=np.array(["eq_capacity"], dtype="S"), - sa_rhs_down=np.array([-24.0]), - sa_rhs_up=np.array([2.0]), - slacks=np.array([0.0]), - ), - ), - ) - - # Solve MIP - mip_stats = solver.solve( - tee=True, - ) - assert not solver.is_infeasible() - assert mip_stats.mip_log is not None - assert len(mip_stats.mip_log) > 100 - assert mip_stats.mip_lower_bound is not None - assert_equals(mip_stats.mip_lower_bound, 1183.0) - assert mip_stats.mip_upper_bound is not None - assert_equals(mip_stats.mip_upper_bound, 1183.0) - assert mip_stats.mip_sense is not None - assert_equals(mip_stats.mip_sense, "max") - assert mip_stats.mip_wallclock_time is not None - assert isinstance(mip_stats.mip_wallclock_time, float) - assert mip_stats.mip_wallclock_time > 0 - - # Fetch variables (after-mip) - assert_equals( - solver.get_variables(with_static=False), - _filter_attrs( - solver.get_variable_attrs(), - Variables( - names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), - values=np.array([1.0, 0.0, 1.0, 1.0, 61.0]), - ), - ), - ) - - # Fetch constraints (after-mip) - assert_equals( - solver.get_constraints(with_static=False), - _filter_attrs( - solver.get_constraint_attrs(), - Constraints( - names=np.array(["eq_capacity"], dtype="S"), - slacks=np.array([0.0]), - ), - ), - ) - - # Build new constraint and verify that it is violated - cf = Constraints( - names=np.array(["cut"], dtype="S"), - lhs=coo_matrix([[1.0, 0.0, 0.0, 0.0, 0.0]]), - rhs=np.array([0.0]), - senses=np.array(["<"], dtype="S"), - ) - assert_equals(solver.are_constraints_satisfied(cf), [False]) - - # Add constraint and verify it affects solution - solver.add_constraints(cf) - assert_equals( - solver.get_constraints(with_static=True), - _filter_attrs( - solver.get_constraint_attrs(), - Constraints( - names=np.array(["eq_capacity", "cut"], dtype="S"), - rhs=np.array([0.0, 0.0]), - lhs=coo_matrix( - [ - [23.0, 26.0, 20.0, 18.0, -1.0], - [1.0, 0.0, 0.0, 0.0, 0.0], - ] - ), - senses=np.array(["=", "<"], dtype="S"), - ), - ), - ) - stats = solver.solve() - assert_equals(stats.mip_lower_bound, 1030.0) - assert_equals(solver.are_constraints_satisfied(cf), [True]) - - # Remove the new constraint - solver.remove_constraints(np.array(["cut"], dtype="S")) - - # New constraint should no longer affect solution - stats = solver.solve() - assert_equals(stats.mip_lower_bound, 1183.0) - - -def run_warm_start_tests(solver: InternalSolver) -> None: - instance = solver.build_test_instance_knapsack() - model = instance.to_model() - solver.set_instance(instance, model) - solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0}) - stats = solver.solve(tee=True) - if stats.mip_warm_start_value is not None: - assert_equals(stats.mip_warm_start_value, 725.0) - - solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 1.0, b"x[2]": 1.0, b"x[3]": 1.0}) - stats = solver.solve(tee=True) - assert stats.mip_warm_start_value is None - - solver.fix({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0}) - stats = solver.solve(tee=True) - assert_equals(stats.mip_lower_bound, 725.0) - assert_equals(stats.mip_upper_bound, 725.0) - - -def run_infeasibility_tests(solver: InternalSolver) -> None: - instance = solver.build_test_instance_infeasible() - solver.set_instance(instance) - mip_stats = solver.solve() - assert solver.is_infeasible() - assert solver.get_solution() is None - assert mip_stats.mip_upper_bound is None - assert mip_stats.mip_lower_bound is None - lp_stats = solver.solve_lp() - assert solver.get_solution() is None - assert lp_stats.lp_value is None - - -def run_iteration_cb_tests(solver: InternalSolver) -> None: - instance = solver.build_test_instance_knapsack() - solver.set_instance(instance) - count = 0 - - def custom_iteration_cb() -> bool: - nonlocal count - count += 1 - return count < 5 - - solver.solve(iteration_cb=custom_iteration_cb) - assert_equals(count, 5) - - -def run_lazy_cb_tests(solver: InternalSolver) -> None: - instance = solver.build_test_instance_knapsack() - model = instance.to_model() - - def lazy_cb(cb_solver: InternalSolver, cb_model: Any) -> None: - relsol = cb_solver.get_solution() - assert relsol is not None - assert relsol[b"x[0]"] is not None - if relsol[b"x[0]"] > 0: - instance.enforce_lazy_constraint(cb_solver, cb_model, None) - - solver.set_instance(instance, model) - solver.solve(lazy_cb=lazy_cb) - solution = solver.get_solution() - assert solution is not None - assert_equals(solution[b"x[0]"], 0.0) - - -def _equals_preprocess(obj: Any) -> Any: - if isinstance(obj, np.ndarray): - if obj.dtype == "float64": - return np.round(obj, decimals=6).tolist() - else: - return obj.tolist() - elif isinstance(obj, coo_matrix): - return obj.todense().tolist() - elif isinstance(obj, (int, str, bool, np.bool_, np.bytes_, bytes, bytearray)): - return obj - elif isinstance(obj, float): - return round(obj, 6) - elif isinstance(obj, list): - return [_equals_preprocess(i) for i in obj] - elif isinstance(obj, tuple): - return tuple(_equals_preprocess(i) for i in obj) - elif obj is None: - return None - elif isinstance(obj, dict): - return {k: _equals_preprocess(v) for (k, v) in obj.items()} - else: - for key in obj.__dict__.keys(): - obj.__dict__[key] = _equals_preprocess(obj.__dict__[key]) - return obj - - -def assert_equals(left: Any, right: Any) -> None: - left = _equals_preprocess(left) - right = _equals_preprocess(right) - assert left == right, f"left:\n{left}\nright:\n{right}" diff --git a/miplearn/types.py b/miplearn/types.py deleted file mode 100644 index 2fd0345..0000000 --- a/miplearn/types.py +++ /dev/null @@ -1,52 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from typing import Optional, Dict, Callable, Any, Union, TYPE_CHECKING - -from mypy_extensions import TypedDict - -if TYPE_CHECKING: - # noinspection PyUnresolvedReferences - from miplearn.solvers.learning import InternalSolver - -Category = bytes -ConstraintName = bytes -ConstraintCategory = bytes -IterationCallback = Callable[[], bool] -LazyCallback = Callable[[Any, Any], None] -SolverParams = Dict[str, Any] -UserCutCallback = Callable[["InternalSolver", Any], None] -Solution = Dict[bytes, Optional[float]] - -LearningSolveStats = TypedDict( - "LearningSolveStats", - { - "Gap": Optional[float], - "Instance": Union[str, int], - "lp_log": str, - "lp_value": Optional[float], - "lp_wallclock_time": Optional[float], - "mip_lower_bound": Optional[float], - "mip_log": str, - "Mode": str, - "mip_nodes": Optional[int], - "Objective: Predicted lower bound": float, - "Objective: Predicted upper bound": float, - "Primal: Free": int, - "Primal: One": int, - "Primal: Zero": int, - "Sense": str, - "Solver": str, - "mip_upper_bound": Optional[float], - "mip_wallclock_time": float, - "mip_warm_start_value": Optional[float], - "LazyStatic: Removed": int, - "LazyStatic: Kept": int, - "LazyStatic: Restored": int, - "LazyStatic: Iterations": int, - "UserCuts: Added ahead-of-time": int, - "UserCuts: Added in callback": int, - }, - total=False, -) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 4279fdb..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[tool.black] -py36 = true -include = '\.pyi?$' diff --git a/requirements.txt b/requirements.txt index aefbcb6..b2f91bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ --e .[dev] +-e .[dev] \ No newline at end of file diff --git a/setup.py b/setup.py index ca79628..7873962 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,44 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. from setuptools import setup, find_namespace_packages -with open("README.md", "r") as fh: - long_description = fh.read() - setup( name="miplearn", - version="0.2.0.dev13", + version="0.3.0.dev1", author="Alinson S. Xavier", author_email="axavier@anl.gov", - description="Extensible framework for Learning-Enhanced Mixed-Integer Optimization", - long_description=long_description, - long_description_content_type="text/markdown", + description="Extensible Framework for Learning-Enhanced Mixed-Integer Optimization", url="https://github.com/ANL-CEEESA/MIPLearn/", packages=find_namespace_packages(), - python_requires=">=3.7", + python_requires=">=3.9", install_requires=[ - "decorator>=4,<5", - "h5py==3.5.0", - "matplotlib>=3,<4", - "mypy==0.790", + "Jinja2<3.1", + "gurobipy>=10,<11", + "h5py>=3,<4", "networkx>=2,<3", - "numpy>=1,<1.21", - "overrides>=3,<4", - "p_tqdm>=1,<2", + "numpy>=1,<2", "pandas>=1,<2", - "pyomo>=5,<6", - "pytest>=6,<7", - "python-markdown-math>=0.8,<0.9", - "scikit-learn>=0.24,<0.25", - "seaborn>=0.11,<0.12", + "pathos>=0.2,<0.3", + "pyomo>=6,<7", + "scikit-learn>=1,<2", + "scipy>=1,<2", "tqdm>=4,<5", ], extras_require={ "dev": [ - "docopt>=0.6,<0.7", - "black==20.8b1", - "pre-commit>=2,<3", - "pdoc3>=0.7,<0.8", - "twine>=3,<4", "Sphinx>=3,<4", - "sphinx-book-theme==0.1.0", + "black==22.6.0", + "mypy==0.971", "myst-parser==0.14.0", + "nbsphinx>=0.9,<0.10", + "pyflakes==2.5.0", + "pytest>=7,<8", + "sphinx-book-theme==0.1.0", + "sphinx-multitoc-numbering>=0.1,<0.2", + "twine>=4,<5" ] }, + ) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..4932a43 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. diff --git a/tests/classifiers/__init__.py b/tests/classifiers/__init__.py deleted file mode 100644 index 50d8248..0000000 --- a/tests/classifiers/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from typing import Tuple - -import numpy as np -from sklearn.preprocessing import StandardScaler - - -def _build_circle_training_data() -> Tuple[np.ndarray, np.ndarray]: - x_train = StandardScaler().fit_transform( - np.array( - [ - [ - x1, - x2, - ] - for x1 in range(-10, 11) - for x2 in range(-10, 11) - ] - ) - ) - y_train = np.array( - [ - [ - False, - True, - ] - if x1 * x1 + x2 * x2 <= 100 - else [ - True, - False, - ] - for x1 in range(-10, 11) - for x2 in range(-10, 11) - ] - ) - return x_train, y_train diff --git a/tests/classifiers/test_adaptive.py b/tests/classifiers/test_adaptive.py deleted file mode 100644 index 3c3c8b9..0000000 --- a/tests/classifiers/test_adaptive.py +++ /dev/null @@ -1,40 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from numpy.linalg import norm -from sklearn.svm import SVC - -from miplearn.classifiers.adaptive import CandidateClassifierSpecs, AdaptiveClassifier -from miplearn.classifiers.sklearn import ScikitLearnClassifier -from tests.classifiers import _build_circle_training_data - - -def test_adaptive() -> None: - clf = AdaptiveClassifier( - candidates={ - "linear": CandidateClassifierSpecs( - classifier=ScikitLearnClassifier( - SVC( - probability=True, - random_state=42, - ) - ) - ), - "poly": CandidateClassifierSpecs( - classifier=ScikitLearnClassifier( - SVC( - probability=True, - kernel="poly", - degree=2, - random_state=42, - ) - ) - ), - } - ) - x_train, y_train = _build_circle_training_data() - clf.fit(x_train, y_train) - proba = clf.predict_proba(x_train) - y_pred = (proba[:, 1] > 0.5).astype(float) - assert norm(y_train[:, 1] - y_pred) < 0.1 diff --git a/tests/classifiers/test_counting.py b/tests/classifiers/test_counting.py deleted file mode 100644 index 5030002..0000000 --- a/tests/classifiers/test_counting.py +++ /dev/null @@ -1,38 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import numpy as np -from numpy.linalg import norm - -from miplearn.classifiers.counting import CountingClassifier - -E = 0.1 - - -def test_counting() -> None: - clf = CountingClassifier() - n_features = 25 - x_train = np.zeros((8, n_features)) - y_train = np.array( - [ - [True, False, False], - [True, False, False], - [False, True, False], - [True, False, False], - [False, True, False], - [False, True, False], - [False, True, False], - [False, False, True], - ] - ) - x_test = np.zeros((2, n_features)) - y_expected = np.array( - [ - [3 / 8.0, 4 / 8.0, 1 / 8.0], - [3 / 8.0, 4 / 8.0, 1 / 8.0], - ] - ) - clf.fit(x_train, y_train) - y_actual = clf.predict_proba(x_test) - assert norm(y_actual - y_expected) < E diff --git a/tests/classifiers/test_cv.py b/tests/classifiers/test_cv.py deleted file mode 100644 index 5386fe3..0000000 --- a/tests/classifiers/test_cv.py +++ /dev/null @@ -1,58 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import numpy as np -from numpy.linalg import norm -from sklearn.svm import SVC - -from miplearn.classifiers.cv import CrossValidatedClassifier -from miplearn.classifiers.sklearn import ScikitLearnClassifier -from tests.classifiers import _build_circle_training_data - -E = 0.1 - - -def test_cv() -> None: - x_train, y_train = _build_circle_training_data() - n_samples = x_train.shape[0] - - # Support vector machines with linear kernels do not perform well on this - # data set, so predictor should return the given constant. - clf = CrossValidatedClassifier( - classifier=ScikitLearnClassifier( - SVC( - probability=True, - random_state=42, - ) - ), - threshold=0.90, - constant=[True, False], - cv=30, - ) - clf.fit(x_train, y_train) - proba = clf.predict_proba(x_train) - assert isinstance(proba, np.ndarray) - assert proba.shape == (n_samples, 2) - - y_pred = (proba[:, 1] > 0.5).astype(float) - assert norm(np.zeros(n_samples) - y_pred) < E - - # Support vector machines with quadratic kernels perform almost perfectly - # on this data set, so predictor should return their prediction. - clf = CrossValidatedClassifier( - classifier=ScikitLearnClassifier( - SVC( - probability=True, - kernel="poly", - degree=2, - random_state=42, - ) - ), - threshold=0.90, - cv=30, - ) - clf.fit(x_train, y_train) - proba = clf.predict_proba(x_train) - y_pred = (proba[:, 1] > 0.5).astype(float) - assert norm(y_train[:, 1] - y_pred) < E diff --git a/tests/classifiers/test_sklearn.py b/tests/classifiers/test_sklearn.py deleted file mode 100644 index b281e0e..0000000 --- a/tests/classifiers/test_sklearn.py +++ /dev/null @@ -1,33 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import numpy as np -from numpy.testing import assert_array_equal -from sklearn.linear_model import LinearRegression -from sklearn.neighbors import KNeighborsClassifier - -from miplearn.classifiers.sklearn import ScikitLearnClassifier, ScikitLearnRegressor - - -def test_constant_prediction() -> None: - x_train = np.array([[0.0, 1.0], [1.0, 0.0]]) - y_train = np.array([[True, False], [True, False]]) - clf = ScikitLearnClassifier(KNeighborsClassifier(n_neighbors=1)) - clf.fit(x_train, y_train) - proba = clf.predict_proba(x_train) - assert_array_equal( - proba, - np.array([[1.0, 0.0], [1.0, 0.0]]), - ) - - -def test_regressor() -> None: - x_train = np.array([[0.0, 1.0], [1.0, 4.0], [2.0, 2.0]]) - y_train = np.array([[1.0], [5.0], [4.0]]) - x_test = np.array([[4.0, 4.0], [0.0, 0.0]]) - clf = ScikitLearnRegressor(LinearRegression()) - clf.fit(x_train, y_train) - y_test_actual = clf.predict(x_test) - y_test_expected = np.array([[8.0], [0.0]]) - assert_array_equal(np.round(y_test_actual, 2), y_test_expected) diff --git a/tests/classifiers/test_threshold.py b/tests/classifiers/test_threshold.py deleted file mode 100644 index 821e2be..0000000 --- a/tests/classifiers/test_threshold.py +++ /dev/null @@ -1,56 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -from unittest.mock import Mock - -import numpy as np - -from miplearn.classifiers import Classifier -from miplearn.classifiers.threshold import MinPrecisionThreshold - - -def test_threshold_dynamic() -> None: - clf = Mock(spec=Classifier) - clf.predict_proba = Mock( - return_value=np.array( - [ - [0.10, 0.90], - [0.25, 0.75], - [0.40, 0.60], - [0.90, 0.10], - ] - ) - ) - x_train = np.array( - [ - [0], - [1], - [2], - [3], - ] - ) - y_train = np.array( - [ - [False, True], - [False, True], - [True, False], - [True, False], - ] - ) - - threshold = MinPrecisionThreshold(min_precision=[1.0, 1.0]) - threshold.fit(clf, x_train, y_train) - assert threshold.predict(x_train) == [0.40, 0.75] - - # threshold = MinPrecisionThreshold(min_precision=0.65) - # threshold.fit(clf, x_train, y_train) - # assert threshold.predict(x_train) == [0.0, 0.80] - - # threshold = MinPrecisionThreshold(min_precision=0.50) - # threshold.fit(clf, x_train, y_train) - # assert threshold.predict(x_train) == [0.0, 0.70] - # - # threshold = MinPrecisionThreshold(min_precision=0.00) - # threshold.fit(clf, x_train, y_train) - # assert threshold.predict(x_train) == [0.0, 0.70] diff --git a/tests/components/__init__.py b/tests/components/__init__.py index 5fbccb1..4932a43 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -1,3 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. diff --git a/tests/components/primal/__init__.py b/tests/components/primal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/primal/test_expert.py b/tests/components/primal/test_expert.py new file mode 100644 index 0000000..cb61607 --- /dev/null +++ b/tests/components/primal/test_expert.py @@ -0,0 +1,26 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import List, Dict, Any +from unittest.mock import Mock + +from miplearn.components.primal.actions import SetWarmStart, FixVariables +from miplearn.components.primal.expert import ExpertPrimalComponent + + +def test_expert(multiknapsack_h5: List[str]) -> None: + model = Mock() + stats: Dict[str, Any] = {} + comp = ExpertPrimalComponent(action=SetWarmStart()) + comp.before_mip(multiknapsack_h5[0], model, stats) + model.set_warm_starts.assert_called() + names, starts, _ = model.set_warm_starts.call_args.args + assert names.shape == (100,) + assert starts.shape == (1, 100) + + comp = ExpertPrimalComponent(action=FixVariables()) + comp.before_mip(multiknapsack_h5[0], model, stats) + model.fix_variables.assert_called() + names, v, _ = model.fix_variables.call_args.args + assert names.shape == (100,) + assert v.shape == (100,) diff --git a/tests/components/primal/test_indep.py b/tests/components/primal/test_indep.py new file mode 100644 index 0000000..1d9dad2 --- /dev/null +++ b/tests/components/primal/test_indep.py @@ -0,0 +1,51 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import List, Dict, Any +from unittest.mock import Mock, call + +from sklearn.dummy import DummyClassifier + +from miplearn.components.primal.actions import SetWarmStart +from miplearn.components.primal.indep import IndependentVarsPrimalComponent +from miplearn.extractors.fields import H5FieldsExtractor + + +def test_indep(multiknapsack_h5: List[str]) -> None: + # Create and fit component + clone_fn = Mock(return_value=Mock(wraps=DummyClassifier())) + comp = IndependentVarsPrimalComponent( + base_clf="dummy", + extractor=H5FieldsExtractor(var_fields=["lp_var_values"]), + clone_fn=clone_fn, + action=SetWarmStart(), + ) + comp.fit(multiknapsack_h5) + + # Should call clone 100 times and store the 100 classifiers + clone_fn.assert_has_calls([call("dummy") for _ in range(100)]) + assert len(comp.clf_) == 100 + + for v in [b"x[0]", b"x[1]"]: + # Should pass correct data to fit + comp.clf_[v].fit.assert_called() + x, y = comp.clf_[v].fit.call_args.args + assert x.shape == (3, 1) + assert y.shape == (3,) + + # Call before-mip + stats: Dict[str, Any] = {} + model = Mock() + comp.before_mip(multiknapsack_h5[0], model, stats) + + # Should call predict with correct args + for v in [b"x[0]", b"x[1]"]: + comp.clf_[v].predict.assert_called() + (x_test,) = comp.clf_[v].predict.call_args.args + assert x_test.shape == (1, 1) + + # Should set warm starts + model.set_warm_starts.assert_called() + names, starts, _ = model.set_warm_starts.call_args.args + assert len(names) == 100 + assert starts.shape == (1, 100) diff --git a/tests/components/primal/test_joint.py b/tests/components/primal/test_joint.py new file mode 100644 index 0000000..a477a06 --- /dev/null +++ b/tests/components/primal/test_joint.py @@ -0,0 +1,46 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import List, Dict, Any +from unittest.mock import Mock + +from sklearn.dummy import DummyClassifier + +from miplearn.components.primal.actions import SetWarmStart +from miplearn.components.primal.joint import JointVarsPrimalComponent +from miplearn.extractors.fields import H5FieldsExtractor + + +def test_joint(multiknapsack_h5: List[str]) -> None: + # Create mock classifier + clf = Mock(wraps=DummyClassifier()) + + # Create and fit component + comp = JointVarsPrimalComponent( + clf=clf, + extractor=H5FieldsExtractor(instance_fields=["static_var_obj_coeffs"]), + action=SetWarmStart(), + ) + comp.fit(multiknapsack_h5) + + # Should call fit method with correct arguments + clf.fit.assert_called() + x, y = clf.fit.call_args.args + assert x.shape == (3, 100) + assert y.shape == (3, 100) + + # Call before-mip + stats: Dict[str, Any] = {} + model = Mock() + comp.before_mip(multiknapsack_h5[0], model, stats) + + # Should call predict with correct args + clf.predict.assert_called() + (x_test,) = clf.predict.call_args.args + assert x_test.shape == (1, 100) + + # Should set warm starts + model.set_warm_starts.assert_called() + names, starts, _ = model.set_warm_starts.call_args.args + assert len(names) == 100 + assert starts.shape == (1, 100) diff --git a/tests/components/primal/test_mem.py b/tests/components/primal/test_mem.py new file mode 100644 index 0000000..ce68aa9 --- /dev/null +++ b/tests/components/primal/test_mem.py @@ -0,0 +1,86 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +import logging +from typing import List, Dict, Any +from unittest.mock import Mock + +import numpy as np +from sklearn.dummy import DummyClassifier + +from miplearn.components.primal.actions import SetWarmStart +from miplearn.components.primal.mem import ( + MemorizingPrimalComponent, + SelectTopSolutions, + MergeTopSolutions, +) +from miplearn.extractors.abstract import FeaturesExtractor + +logger = logging.getLogger(__name__) + + +def test_mem_component( + multiknapsack_h5: List[str], default_extractor: FeaturesExtractor +) -> None: + # Create mock classifier + clf = Mock(wraps=DummyClassifier()) + + # Create and fit component + comp = MemorizingPrimalComponent( + clf, + extractor=default_extractor, + constructor=SelectTopSolutions(2), + action=SetWarmStart(), + ) + comp.fit(multiknapsack_h5) + + # Should call fit method with correct arguments + clf.fit.assert_called() + x, y = clf.fit.call_args.args + assert x.shape == (3, 100) + assert y.tolist() == [0, 1, 2] + + # Should store solutions + assert comp.solutions_ is not None + assert comp.solutions_.shape == (3, 100) + assert comp.bin_var_names_ is not None + assert len(comp.bin_var_names_) == 100 + + # Call before-mip + stats: Dict[str, Any] = {} + model = Mock() + comp.before_mip(multiknapsack_h5[0], model, stats) + + # Should call predict_proba with correct args + clf.predict_proba.assert_called() + (x_test,) = clf.predict_proba.call_args.args + assert x_test.shape == (1, 100) + + # Should set warm starts + model.set_warm_starts.assert_called() + names, starts, _ = model.set_warm_starts.call_args.args + assert len(names) == 100 + assert starts.shape == (2, 100) + assert np.all(starts[0, :] == comp.solutions_[0, :]) + assert np.all(starts[1, :] == comp.solutions_[1, :]) + + +def test_merge_top_solutions() -> None: + solutions = np.array( + [ + [0, 1, 0, 0], + [0, 1, 0, 1], + [0, 1, 1, 1], + [0, 1, 1, 1], + [1, 1, 1, 1], + ] + ) + y_proba = np.array([0.25, 0.25, 0.25, 0.25, 0]) + starts = MergeTopSolutions(k=4, thresholds=[0.25, 0.75]).construct( + y_proba, solutions + ) + assert starts.shape == (1, 4) + assert starts[0, 0] == 0 + assert starts[0, 1] == 1 + assert np.isnan(starts[0, 2]) + assert starts[0, 3] == 1 diff --git a/tests/components/test_dynamic_lazy.py b/tests/components/test_dynamic_lazy.py deleted file mode 100644 index 5d1faa8..0000000 --- a/tests/components/test_dynamic_lazy.py +++ /dev/null @@ -1,147 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from typing import List, cast -from unittest.mock import Mock - -import numpy as np -import pytest - -from miplearn.classifiers import Classifier -from miplearn.classifiers.threshold import MinProbabilityThreshold -from miplearn.components import classifier_evaluation_dict -from miplearn.components.dynamic_common import DynamicConstraintsComponent -from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent -from miplearn.features.sample import MemorySample -from miplearn.instance.base import Instance -from miplearn.solvers.tests import assert_equals - -E = 0.1 - - -@pytest.fixture -def training_instances() -> List[Instance]: - instances = [cast(Instance, Mock(spec=Instance)) for _ in range(2)] - samples_0 = [ - MemorySample( - { - "mip_constr_lazy": DynamicConstraintsComponent.encode( - { - b"c1": 0, - b"c2": 0, - } - ), - "static_instance_features": np.array([5.0]), - }, - ), - MemorySample( - { - "mip_constr_lazy": DynamicConstraintsComponent.encode( - { - b"c2": 0, - b"c3": 0, - } - ), - "static_instance_features": np.array([5.0]), - }, - ), - ] - instances[0].get_samples = Mock(return_value=samples_0) # type: ignore - instances[0].get_constraint_categories = Mock( # type: ignore - return_value=np.array(["type-a", "type-a", "type-b", "type-b"], dtype="S") - ) - instances[0].get_constraint_features = Mock( # type: ignore - return_value=np.array( - [ - [1.0, 2.0, 3.0], - [4.0, 5.0, 6.0], - [1.0, 2.0, 0.0], - [3.0, 4.0, 0.0], - ] - ) - ) - instances[0].are_constraints_lazy = Mock( # type: ignore - return_value=np.zeros(4, dtype=bool) - ) - samples_1 = [ - MemorySample( - { - "mip_constr_lazy": DynamicConstraintsComponent.encode( - { - b"c3": 0, - b"c4": 0, - } - ), - "static_instance_features": np.array([8.0]), - }, - ) - ] - instances[1].get_samples = Mock(return_value=samples_1) # type: ignore - instances[1].get_constraint_categories = Mock( # type: ignore - return_value=np.array(["", "type-a", "type-b", "type-b"], dtype="S") - ) - instances[1].get_constraint_features = Mock( # type: ignore - return_value=np.array( - [ - [7.0, 8.0, 9.0], - [5.0, 6.0, 0.0], - [7.0, 8.0, 0.0], - ] - ) - ) - instances[1].are_constraints_lazy = Mock( # type: ignore - return_value=np.zeros(4, dtype=bool) - ) - return instances - - -def test_sample_xy(training_instances: List[Instance]) -> None: - comp = DynamicLazyConstraintsComponent() - comp.pre_fit( - [ - {b"c1": 0, b"c3": 0, b"c4": 0}, - {b"c1": 0, b"c2": 0, b"c4": 0}, - ] - ) - x_expected = { - b"type-a": np.array([[5.0, 1.0, 2.0, 3.0], [5.0, 4.0, 5.0, 6.0]]), - b"type-b": np.array([[5.0, 1.0, 2.0, 0.0], [5.0, 3.0, 4.0, 0.0]]), - } - y_expected = { - b"type-a": np.array([[False, True], [False, True]]), - b"type-b": np.array([[True, False], [True, False]]), - } - x_actual, y_actual = comp.sample_xy( - training_instances[0], - training_instances[0].get_samples()[0], - ) - assert_equals(x_actual, x_expected) - assert_equals(y_actual, y_expected) - - -def test_sample_predict_evaluate(training_instances: List[Instance]) -> None: - comp = DynamicLazyConstraintsComponent() - comp.known_violations[b"c1"] = 0 - comp.known_violations[b"c2"] = 0 - comp.known_violations[b"c3"] = 0 - comp.known_violations[b"c4"] = 0 - comp.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5]) - comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5]) - comp.classifiers[b"type-a"] = Mock(spec=Classifier) - comp.classifiers[b"type-b"] = Mock(spec=Classifier) - comp.classifiers[b"type-a"].predict_proba = Mock( # type: ignore - side_effect=lambda _: np.array([[0.1, 0.9], [0.8, 0.2]]) - ) - comp.classifiers[b"type-b"].predict_proba = Mock( # type: ignore - side_effect=lambda _: np.array([[0.9, 0.1], [0.1, 0.9]]) - ) - pred = comp.sample_predict( - training_instances[0], - training_instances[0].get_samples()[0], - ) - assert pred == [b"c1", b"c4"] - ev = comp.sample_evaluate( - training_instances[0], - training_instances[0].get_samples()[0], - ) - assert ev == classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1) diff --git a/tests/components/test_dynamic_user_cuts.py b/tests/components/test_dynamic_user_cuts.py deleted file mode 100644 index f8b3a5f..0000000 --- a/tests/components/test_dynamic_user_cuts.py +++ /dev/null @@ -1,105 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -import json -import logging -from typing import Any, List, Dict - -import gurobipy -import gurobipy as gp -import networkx as nx -import pytest -from gurobipy import GRB -from networkx import Graph -from overrides import overrides - -from miplearn.components.dynamic_user_cuts import UserCutsComponent -from miplearn.instance.base import Instance -from miplearn.solvers.gurobi import GurobiSolver -from miplearn.solvers.learning import LearningSolver -from miplearn.types import ConstraintName - -logger = logging.getLogger(__name__) - - -class GurobiStableSetProblem(Instance): - def __init__(self, graph: Graph) -> None: - super().__init__() - self.graph: Graph = graph - - @overrides - def to_model(self) -> Any: - model = gp.Model() - x = [model.addVar(vtype=GRB.BINARY) for _ in range(len(self.graph.nodes))] - model.setObjective(gp.quicksum(x), GRB.MAXIMIZE) - for e in list(self.graph.edges): - model.addConstr(x[e[0]] + x[e[1]] <= 1) - return model - - @overrides - def has_user_cuts(self) -> bool: - return True - - @overrides - def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]: - assert isinstance(model, gp.Model) - try: - vals = model.cbGetNodeRel(model.getVars()) - except gurobipy.GurobiError: - return {} - violations = {} - for clique in nx.find_cliques(self.graph): - if sum(vals[i] for i in clique) > 1: - vname = (",".join([str(i) for i in clique])).encode() - violations[vname] = list(clique) - return violations - - @overrides - def enforce_user_cut( - self, - solver: GurobiSolver, - model: Any, - clique: List[int], - ) -> Any: - x = model.getVars() - constr = gp.quicksum([x[i] for i in clique]) <= 1 - if solver.cb_where: - model.cbCut(constr) - else: - model.addConstr(constr) - - -@pytest.fixture -def stab_instance() -> Instance: - graph = nx.generators.random_graphs.binomial_graph(50, 0.50, seed=42) - return GurobiStableSetProblem(graph) - - -@pytest.fixture -def solver() -> LearningSolver: - return LearningSolver( - solver=GurobiSolver(params={"Threads": 1}), - components=[UserCutsComponent()], - ) - - -def test_usage( - stab_instance: Instance, - solver: LearningSolver, -) -> None: - stats_before = solver._solve(stab_instance) - sample = stab_instance.get_samples()[0] - user_cuts_encoded = sample.get_scalar("mip_user_cuts") - assert user_cuts_encoded is not None - user_cuts = json.loads(user_cuts_encoded) - assert user_cuts is not None - assert len(user_cuts) > 0 - assert stats_before["UserCuts: Added ahead-of-time"] == 0 - assert stats_before["UserCuts: Added in callback"] > 0 - - solver._fit([stab_instance]) - stats_after = solver._solve(stab_instance) - assert ( - stats_after["UserCuts: Added ahead-of-time"] - == stats_before["UserCuts: Added in callback"] - ) diff --git a/tests/components/test_objective.py b/tests/components/test_objective.py deleted file mode 100644 index f81eb8d..0000000 --- a/tests/components/test_objective.py +++ /dev/null @@ -1,141 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from typing import Dict -from unittest.mock import Mock - -import numpy as np -import pytest -from numpy.testing import assert_array_equal - -from miplearn.classifiers import Regressor -from miplearn.components.objective import ObjectiveValueComponent -from miplearn.features.sample import Sample, MemorySample -from miplearn.solvers.learning import LearningSolver -from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver -from miplearn.solvers.tests import assert_equals - - -@pytest.fixture -def sample() -> Sample: - sample = MemorySample( - { - "mip_lower_bound": 1.0, - "mip_upper_bound": 2.0, - "lp_instance_features": np.array([1.0, 2.0, 3.0]), - }, - ) - return sample - - -def test_sample_xy(sample: Sample) -> None: - x_expected = { - "Lower bound": np.array([[1.0, 2.0, 3.0]]), - "Upper bound": np.array([[1.0, 2.0, 3.0]]), - } - y_expected = { - "Lower bound": np.array([[1.0]]), - "Upper bound": np.array([[2.0]]), - } - xy = ObjectiveValueComponent().sample_xy(None, sample) - assert xy is not None - x_actual, y_actual = xy - assert_equals(x_actual, x_expected) - assert_equals(y_actual, y_expected) - - -def test_fit_xy() -> None: - x: Dict[str, np.ndarray] = { - "Lower bound": np.array([[0.0, 0.0], [1.0, 2.0]]), - "Upper bound": np.array([[0.0, 0.0], [1.0, 2.0]]), - } - y: Dict[str, np.ndarray] = { - "Lower bound": np.array([[100.0]]), - "Upper bound": np.array([[200.0]]), - } - reg = Mock(spec=Regressor) - reg.clone = Mock(side_effect=lambda: Mock(spec=Regressor)) - comp = ObjectiveValueComponent(regressor=reg) - assert "Upper bound" not in comp.regressors - assert "Lower bound" not in comp.regressors - comp.fit_xy(x, y) - assert reg.clone.call_count == 2 - assert "Upper bound" in comp.regressors - assert "Lower bound" in comp.regressors - assert comp.regressors["Upper bound"].fit.call_count == 1 # type: ignore - assert comp.regressors["Lower bound"].fit.call_count == 1 # type: ignore - assert_array_equal( - comp.regressors["Upper bound"].fit.call_args[0][0], # type: ignore - x["Upper bound"], - ) - assert_array_equal( - comp.regressors["Lower bound"].fit.call_args[0][0], # type: ignore - x["Lower bound"], - ) - assert_array_equal( - comp.regressors["Upper bound"].fit.call_args[0][1], # type: ignore - y["Upper bound"], - ) - assert_array_equal( - comp.regressors["Lower bound"].fit.call_args[0][1], # type: ignore - y["Lower bound"], - ) - - -def test_sample_predict(sample: Sample) -> None: - x, y = ObjectiveValueComponent().sample_xy(None, sample) - comp = ObjectiveValueComponent() - comp.regressors["Lower bound"] = Mock(spec=Regressor) - comp.regressors["Upper bound"] = Mock(spec=Regressor) - comp.regressors["Lower bound"].predict = Mock( # type: ignore - side_effect=lambda _: np.array([[50.0]]) - ) - comp.regressors["Upper bound"].predict = Mock( # type: ignore - side_effect=lambda _: np.array([[60.0]]) - ) - pred = comp.sample_predict(sample) - assert pred == { - "Lower bound": 50.0, - "Upper bound": 60.0, - } - assert_array_equal( - comp.regressors["Upper bound"].predict.call_args[0][0], # type: ignore - x["Upper bound"], - ) - assert_array_equal( - comp.regressors["Lower bound"].predict.call_args[0][0], # type: ignore - x["Lower bound"], - ) - - -def test_sample_evaluate(sample: Sample) -> None: - comp = ObjectiveValueComponent() - comp.regressors["Lower bound"] = Mock(spec=Regressor) - comp.regressors["Lower bound"].predict = lambda _: np.array([[1.05]]) # type: ignore - comp.regressors["Upper bound"] = Mock(spec=Regressor) - comp.regressors["Upper bound"].predict = lambda _: np.array([[2.50]]) # type: ignore - ev = comp.sample_evaluate(None, sample) - assert ev == { - "Lower bound": { - "Actual value": 1.0, - "Predicted value": 1.05, - "Absolute error": 0.05, - "Relative error": 0.05, - }, - "Upper bound": { - "Actual value": 2.0, - "Predicted value": 2.50, - "Absolute error": 0.5, - "Relative error": 0.25, - }, - } - - -def test_usage() -> None: - solver = LearningSolver(components=[ObjectiveValueComponent()]) - instance = GurobiPyomoSolver().build_test_instance_knapsack() - solver._solve(instance) - solver._fit([instance]) - stats = solver._solve(instance) - assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"] - assert stats["mip_upper_bound"] == stats["Objective: Predicted upper bound"] diff --git a/tests/components/test_primal.py b/tests/components/test_primal.py deleted file mode 100644 index aa6074a..0000000 --- a/tests/components/test_primal.py +++ /dev/null @@ -1,166 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from unittest.mock import Mock - -import numpy as np -import pytest -from numpy.testing import assert_array_equal -from scipy.stats import randint - -from miplearn.classifiers import Classifier -from miplearn.classifiers.threshold import Threshold -from miplearn.components import classifier_evaluation_dict -from miplearn.components.primal import PrimalSolutionComponent -from miplearn.features.sample import Sample, MemorySample -from miplearn.problems.tsp import TravelingSalesmanGenerator, TravelingSalesmanInstance -from miplearn.solvers.learning import LearningSolver -from miplearn.solvers.tests import assert_equals - - -@pytest.fixture -def sample() -> Sample: - sample = MemorySample( - { - "static_var_names": np.array(["x[0]", "x[1]", "x[2]", "x[3]"], dtype="S"), - "static_var_types": np.array(["B", "B", "B", "B"], dtype="S"), - "static_var_categories": np.array( - ["default", "", "default", "default"], - dtype="S", - ), - "mip_var_values": np.array([0.0, 1.0, 1.0, 0.0]), - "static_instance_features": np.array([5.0]), - "static_var_features": np.array( - [ - [0.0, 0.0], - [0.0, 0.0], - [1.0, 0.0], - [1.0, 1.0], - ] - ), - "lp_var_features": np.array( - [ - [0.0, 0.0, 2.0, 2.0], - [0.0, 0.0, 0.0, 0.0], - [1.0, 0.0, 3.0, 2.0], - [1.0, 1.0, 3.0, 3.0], - ] - ), - }, - ) - return sample - - -def test_xy(sample: Sample) -> None: - x_expected = { - b"default": [ - [5.0, 0.0, 0.0, 2.0, 2.0], - [5.0, 1.0, 0.0, 3.0, 2.0], - [5.0, 1.0, 1.0, 3.0, 3.0], - ] - } - y_expected = { - b"default": [ - [True, False], - [False, True], - [True, False], - ] - } - xy = PrimalSolutionComponent().sample_xy(None, sample) - assert xy is not None - x_actual, y_actual = xy - assert x_actual == x_expected - assert y_actual == y_expected - - -def test_fit_xy() -> None: - clf = Mock(spec=Classifier) - clf.clone = lambda: Mock(spec=Classifier) # type: ignore - thr = Mock(spec=Threshold) - thr.clone = lambda: Mock(spec=Threshold) - comp = PrimalSolutionComponent(classifier=clf, threshold=thr) - x = { - b"type-a": np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]), - b"type-b": np.array([[7.0, 8.0, 9.0]]), - } - y = { - b"type-a": np.array([[True, False], [False, True]]), - b"type-b": np.array([[True, False]]), - } - comp.fit_xy(x, y) - for category in [b"type-a", b"type-b"]: - assert category in comp.classifiers - assert category in comp.thresholds - clf = comp.classifiers[category] # type: ignore - clf.fit.assert_called_once() - assert_array_equal(x[category], clf.fit.call_args[0][0]) - assert_array_equal(y[category], clf.fit.call_args[0][1]) - thr = comp.thresholds[category] # type: ignore - thr.fit.assert_called_once() - assert_array_equal(x[category], thr.fit.call_args[0][1]) - assert_array_equal(y[category], thr.fit.call_args[0][2]) - - -def test_usage() -> None: - solver = LearningSolver( - components=[ - PrimalSolutionComponent(), - ] - ) - gen = TravelingSalesmanGenerator(n=randint(low=5, high=6)) - data = gen.generate(1) - instance = TravelingSalesmanInstance(data[0].n_cities, data[0].distances) - solver._solve(instance) - solver._fit([instance]) - stats = solver._solve(instance) - assert stats["Primal: Free"] == 0 - assert stats["Primal: One"] + stats["Primal: Zero"] == 10 - assert stats["mip_lower_bound"] == stats["mip_warm_start_value"] - - -def test_evaluate(sample: Sample) -> None: - comp = PrimalSolutionComponent() - comp.sample_predict = lambda _: { # type: ignore - b"x[0]": 1.0, - b"x[1]": 1.0, - b"x[2]": 0.0, - b"x[3]": None, - } - ev = comp.sample_evaluate(None, sample) - assert_equals( - ev, - { - "0": classifier_evaluation_dict(tp=0, fp=1, tn=1, fn=2), - "1": classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1), - }, - ) - - -def test_predict(sample: Sample) -> None: - clf = Mock(spec=Classifier) - clf.predict_proba = Mock( - return_value=np.array( - [ - [0.9, 0.1], - [0.5, 0.5], - [0.1, 0.9], - ] - ) - ) - thr = Mock(spec=Threshold) - thr.predict = Mock(return_value=[0.75, 0.75]) - comp = PrimalSolutionComponent() - x, _ = comp.sample_xy(None, sample) - comp.classifiers = {b"default": clf} - comp.thresholds = {b"default": thr} - pred = comp.sample_predict(sample) - clf.predict_proba.assert_called_once() - thr.predict.assert_called_once() - assert_array_equal(x[b"default"], clf.predict_proba.call_args[0][0]) - assert_array_equal(x[b"default"], thr.predict.call_args[0][0]) - assert pred == { - b"x[0]": 0.0, - b"x[1]": None, - b"x[2]": None, - b"x[3]": 1.0, - } diff --git a/tests/components/test_static_lazy.py b/tests/components/test_static_lazy.py deleted file mode 100644 index 9455dcd..0000000 --- a/tests/components/test_static_lazy.py +++ /dev/null @@ -1,238 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from typing import Dict, cast -from unittest.mock import Mock, call - -import numpy as np -import pytest -from numpy.testing import assert_array_equal - -from miplearn.classifiers import Classifier -from miplearn.classifiers.threshold import Threshold, MinProbabilityThreshold -from miplearn.components.static_lazy import StaticLazyConstraintsComponent -from miplearn.features.sample import Sample, MemorySample -from miplearn.instance.base import Instance -from miplearn.solvers.internal import InternalSolver, Constraints -from miplearn.solvers.learning import LearningSolver -from miplearn.types import ( - LearningSolveStats, - ConstraintCategory, -) -from miplearn.solvers.tests import assert_equals - - -@pytest.fixture -def sample() -> Sample: - sample = MemorySample( - { - "static_constr_categories": [ - b"type-a", - b"type-a", - b"type-a", - b"type-b", - b"type-b", - ], - "static_constr_lazy": np.array([True, True, True, True, False]), - "static_constr_names": np.array(["c1", "c2", "c3", "c4", "c5"], dtype="S"), - "static_instance_features": [5.0], - "mip_constr_lazy_enforced": np.array(["c1", "c2", "c4"], dtype="S"), - "lp_constr_features": np.array( - [ - [1.0, 1.0, 0.0], - [1.0, 2.0, 0.0], - [1.0, 3.0, 0.0], - [1.0, 4.0, 0.0], - [0.0, 0.0, 0.0], - ] - ), - "static_constr_lazy_count": 4, - }, - ) - return sample - - -@pytest.fixture -def instance(sample: Sample) -> Instance: - instance = Mock(spec=Instance) - instance.get_samples = Mock(return_value=[sample]) # type: ignore - instance.has_static_lazy_constraints = Mock(return_value=True) - return instance - - -def test_usage_with_solver(instance: Instance) -> None: - solver = Mock(spec=LearningSolver) - solver.use_lazy_cb = False - solver.gap_tolerance = 1e-4 - - internal = solver.internal_solver = Mock(spec=InternalSolver) - internal.is_constraint_satisfied_old = Mock(return_value=False) - internal.are_constraints_satisfied = Mock( - side_effect=lambda cf, tol=1.0: [False for i in range(len(cf.names))] - ) - - component = StaticLazyConstraintsComponent(violation_tolerance=1.0) - component.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5]) - component.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5]) - component.classifiers = { - b"type-a": Mock(spec=Classifier), - b"type-b": Mock(spec=Classifier), - } - component.classifiers[b"type-a"].predict_proba = Mock( # type: ignore - return_value=np.array( - [ - [0.00, 1.00], # c1 - [0.20, 0.80], # c2 - [0.99, 0.01], # c3 - ] - ) - ) - component.classifiers[b"type-b"].predict_proba = Mock( # type: ignore - return_value=np.array( - [ - [0.02, 0.98], # c4 - ] - ) - ) - - stats: LearningSolveStats = {} - sample = instance.get_samples()[0] - assert sample.get_array("mip_constr_lazy_enforced") is not None - - # LearningSolver calls before_solve_mip - component.before_solve_mip( - solver=solver, - instance=instance, - model=None, - stats=stats, - sample=sample, - ) - - # Should ask ML to predict whether each lazy constraint should be enforced - component.classifiers[b"type-a"].predict_proba.assert_called_once() - component.classifiers[b"type-b"].predict_proba.assert_called_once() - - # Should ask internal solver to remove some constraints - assert internal.remove_constraints.call_count == 1 - internal.remove_constraints.assert_has_calls([call([b"c3"])]) - - # LearningSolver calls after_iteration (first time) - should_repeat = component.iteration_cb(solver, instance, None) - assert should_repeat - - # Should ask internal solver to verify if constraints in the pool are - # satisfied and add the ones that are not - c = Constraints.from_sample(sample)[[False, False, True, False, False]] - internal.are_constraints_satisfied.assert_called_once_with(c, tol=1.0) - internal.are_constraints_satisfied.reset_mock() - internal.add_constraints.assert_called_once_with(c) - internal.add_constraints.reset_mock() - - # LearningSolver calls after_iteration (second time) - should_repeat = component.iteration_cb(solver, instance, None) - assert not should_repeat - - # The lazy constraint pool should be empty by now, so no calls should be made - internal.are_constraints_satisfied.assert_not_called() - internal.add_constraints.assert_not_called() - - # LearningSolver calls after_solve_mip - component.after_solve_mip( - solver=solver, - instance=instance, - model=None, - stats=stats, - sample=sample, - ) - - # Should update training sample - mip_constr_lazy_enforced = sample.get_array("mip_constr_lazy_enforced") - assert mip_constr_lazy_enforced is not None - assert_equals( - sorted(mip_constr_lazy_enforced), - np.array(["c1", "c2", "c3", "c4"], dtype="S"), - ) - - # Should update stats - assert stats["LazyStatic: Removed"] == 1 - assert stats["LazyStatic: Kept"] == 3 - assert stats["LazyStatic: Restored"] == 1 - assert stats["LazyStatic: Iterations"] == 1 - - -def test_sample_predict(sample: Sample) -> None: - comp = StaticLazyConstraintsComponent() - comp.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5]) - comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5]) - comp.classifiers[b"type-a"] = Mock(spec=Classifier) - comp.classifiers[b"type-a"].predict_proba = lambda _: np.array( # type:ignore - [ - [0.0, 1.0], # c1 - [0.0, 0.9], # c2 - [0.9, 0.1], # c3 - ] - ) - comp.classifiers[b"type-b"] = Mock(spec=Classifier) - comp.classifiers[b"type-b"].predict_proba = lambda _: np.array( # type:ignore - [ - [0.0, 1.0], # c4 - ] - ) - pred = comp.sample_predict(sample) - assert pred == [b"c1", b"c2", b"c4"] - - -def test_fit_xy() -> None: - x = cast( - Dict[ConstraintCategory, np.ndarray], - { - b"type-a": np.array([[1.0, 1.0], [1.0, 2.0], [1.0, 3.0]]), - b"type-b": np.array([[1.0, 4.0, 0.0]]), - }, - ) - y = cast( - Dict[ConstraintCategory, np.ndarray], - { - b"type-a": np.array([[False, True], [False, True], [True, False]]), - b"type-b": np.array([[False, True]]), - }, - ) - clf: Classifier = Mock(spec=Classifier) - thr: Threshold = Mock(spec=Threshold) - clf.clone = Mock(side_effect=lambda: Mock(spec=Classifier)) # type: ignore - thr.clone = Mock(side_effect=lambda: Mock(spec=Threshold)) # type: ignore - comp = StaticLazyConstraintsComponent( - classifier=clf, - threshold=thr, - ) - comp.fit_xy(x, y) - assert clf.clone.call_count == 2 - clf_a = comp.classifiers[b"type-a"] - clf_b = comp.classifiers[b"type-b"] - assert clf_a.fit.call_count == 1 # type: ignore - assert clf_b.fit.call_count == 1 # type: ignore - assert_array_equal(clf_a.fit.call_args[0][0], x[b"type-a"]) # type: ignore - assert_array_equal(clf_b.fit.call_args[0][0], x[b"type-b"]) # type: ignore - assert thr.clone.call_count == 2 - thr_a = comp.thresholds[b"type-a"] - thr_b = comp.thresholds[b"type-b"] - assert thr_a.fit.call_count == 1 # type: ignore - assert thr_b.fit.call_count == 1 # type: ignore - assert thr_a.fit.call_args[0][0] == clf_a # type: ignore - assert thr_b.fit.call_args[0][0] == clf_b # type: ignore - - -def test_sample_xy(sample: Sample) -> None: - x_expected = { - b"type-a": [[5.0, 1.0, 1.0, 0.0], [5.0, 1.0, 2.0, 0.0], [5.0, 1.0, 3.0, 0.0]], - b"type-b": [[5.0, 1.0, 4.0, 0.0]], - } - y_expected = { - b"type-a": [[False, True], [False, True], [True, False]], - b"type-b": [[False, True]], - } - xy = StaticLazyConstraintsComponent().sample_xy(None, sample) - assert xy is not None - x_actual, y_actual = xy - assert x_actual == x_expected - assert y_actual == y_expected diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c4c8b61 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from glob import glob +from os.path import dirname +from typing import List + +import pytest + +from miplearn.extractors.fields import H5FieldsExtractor +from miplearn.extractors.abstract import FeaturesExtractor + + +@pytest.fixture() +def multiknapsack_h5() -> List[str]: + return sorted(glob(f"{dirname(__file__)}/fixtures/multiknapsack*.h5")) + + +@pytest.fixture() +def default_extractor() -> FeaturesExtractor: + return H5FieldsExtractor( + instance_fields=["static_var_obj_coeffs"], + var_fields=["lp_var_features"], + ) diff --git a/miplearn/features/__init__.py b/tests/extractors/__init__.py similarity index 72% rename from miplearn/features/__init__.py rename to tests/extractors/__init__.py index 5fbccb1..4932a43 100644 --- a/miplearn/features/__init__.py +++ b/tests/extractors/__init__.py @@ -1,3 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. diff --git a/tests/extractors/test_dummy.py b/tests/extractors/test_dummy.py new file mode 100644 index 0000000..5bba508 --- /dev/null +++ b/tests/extractors/test_dummy.py @@ -0,0 +1,19 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from typing import List + +from miplearn.extractors.dummy import DummyExtractor +from miplearn.h5 import H5File + + +def test_dummy(multiknapsack_h5: List[str]) -> None: + ext = DummyExtractor() + with H5File(multiknapsack_h5[0], "r") as h5: + x = ext.get_instance_features(h5) + assert x.shape == (1,) + x = ext.get_var_features(h5) + assert x.shape == (100, 1) + x = ext.get_constr_features(h5) + assert x.shape == (4, 1) diff --git a/tests/extractors/test_fields.py b/tests/extractors/test_fields.py new file mode 100644 index 0000000..4d6fcc4 --- /dev/null +++ b/tests/extractors/test_fields.py @@ -0,0 +1,33 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from typing import List + +import pytest + +from miplearn.extractors.fields import H5FieldsExtractor +from miplearn.h5 import H5File + + +def test_fields_instance(multiknapsack_h5: List[str]) -> None: + ext = H5FieldsExtractor( + instance_fields=[ + "lp_obj_value", + "lp_var_values", + "static_var_obj_coeffs", + ], + var_fields=["lp_var_values"], + ) + with H5File(multiknapsack_h5[0], "r") as h5: + x = ext.get_instance_features(h5) + assert x.shape == (201,) + + x = ext.get_var_features(h5) + assert x.shape == (100, 1) + + +def test_fields_instance_none(multiknapsack_h5: List[str]) -> None: + ext = H5FieldsExtractor(instance_fields=None) + with H5File(multiknapsack_h5[0], "r") as h5: + with pytest.raises(Exception): + ext.get_instance_features(h5) diff --git a/tests/features/test_extractor.py b/tests/features/test_extractor.py deleted file mode 100644 index 8fdab91..0000000 --- a/tests/features/test_extractor.py +++ /dev/null @@ -1,709 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import cProfile -import os -import sys -from typing import Any - -import gurobipy as gp -import numpy as np -from scipy.sparse import coo_matrix - -from miplearn.features.extractor import FeaturesExtractor -from miplearn.features.sample import Hdf5Sample, MemorySample -from miplearn.instance.base import Instance -from miplearn.solvers.gurobi import GurobiSolver -from miplearn.solvers.internal import Variables, Constraints -from miplearn.solvers.tests import assert_equals - -inf = float("inf") - - -def test_knapsack() -> None: - solver = GurobiSolver() - instance = solver.build_test_instance_knapsack() - model = instance.to_model() - solver.set_instance(instance, model) - extractor = FeaturesExtractor() - sample = MemorySample() - - # after-load - # ------------------------------------------------------- - extractor.extract_after_load_features(instance, solver, sample) - assert_equals( - sample.get_array("static_instance_features"), - np.array([67.0, 21.75]), - ) - assert_equals( - sample.get_array("static_var_names"), - np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"), - ) - assert_equals( - sample.get_array("static_var_lower_bounds"), - np.array([0.0, 0.0, 0.0, 0.0, 0.0]), - ) - assert_equals( - sample.get_array("static_var_obj_coeffs"), - np.array([505.0, 352.0, 458.0, 220.0, 0.0]), - ) - assert_equals( - sample.get_array("static_var_types"), - np.array(["B", "B", "B", "B", "C"], dtype="S"), - ) - assert_equals( - sample.get_array("static_var_upper_bounds"), - np.array([1.0, 1.0, 1.0, 1.0, 67.0]), - ) - assert_equals( - sample.get_array("static_var_categories"), - np.array(["default", "default", "default", "default", ""], dtype="S"), - ) - assert_equals( - sample.get_array("static_var_features"), - np.array( - [ - [ - 23.0, - 505.0, - 1.0, - 0.32899, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 21.956522, - 1.0, - 21.956522, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.264368, - 1.0, - 0.264368, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 23.0, - 1.0, - 23.0, - 0.0, - 0.0, - 0.0, - 0.0, - ], - [ - 26.0, - 352.0, - 1.0, - 0.229316, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 13.538462, - 1.0, - 13.538462, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.298851, - 1.0, - 0.298851, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 26.0, - 1.0, - 26.0, - 0.0, - 0.0, - 0.0, - 0.0, - ], - [ - 20.0, - 458.0, - 1.0, - 0.298371, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 22.9, - 1.0, - 22.9, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.229885, - 1.0, - 0.229885, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 20.0, - 1.0, - 20.0, - 0.0, - 0.0, - 0.0, - 0.0, - ], - [ - 18.0, - 220.0, - 1.0, - 0.143322, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 12.222222, - 1.0, - 12.222222, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.206897, - 1.0, - 0.206897, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 18.0, - 1.0, - 18.0, - 0.0, - 0.0, - 0.0, - 0.0, - ], - [ - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.011494, - 1.0, - 0.011494, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - ], - ] - ), - ) - assert_equals( - sample.get_array("static_constr_names"), - np.array(["eq_capacity"], dtype="S"), - ) - assert_equals( - sample.get_sparse("static_constr_lhs"), - [[23.0, 26.0, 20.0, 18.0, -1.0]], - ) - assert_equals( - sample.get_array("static_constr_rhs"), - np.array([0.0]), - ) - assert_equals( - sample.get_array("static_constr_senses"), - np.array(["="], dtype="S"), - ) - assert_equals( - sample.get_array("static_constr_features"), - np.array([[0.0]]), - ) - assert_equals( - sample.get_array("static_constr_categories"), - np.array(["eq_capacity"], dtype="S"), - ) - assert_equals( - sample.get_array("static_constr_lazy"), - np.array([False]), - ) - assert_equals( - sample.get_array("static_instance_features"), - np.array([67.0, 21.75]), - ) - assert_equals(sample.get_scalar("static_constr_lazy_count"), 0) - - # after-lp - # ------------------------------------------------------- - lp_stats = solver.solve_lp() - extractor.extract_after_lp_features(solver, sample, lp_stats) - assert_equals( - sample.get_array("lp_var_basis_status"), - np.array(["U", "B", "U", "L", "U"], dtype="S"), - ) - assert_equals( - sample.get_array("lp_var_reduced_costs"), - [193.615385, 0.0, 187.230769, -23.692308, 13.538462], - ) - assert_equals( - sample.get_array("lp_var_sa_lb_down"), - [-inf, -inf, -inf, -0.111111, -inf], - ) - assert_equals( - sample.get_array("lp_var_sa_lb_up"), - [1.0, 0.923077, 1.0, 1.0, 67.0], - ) - assert_equals( - sample.get_array("lp_var_sa_obj_down"), - [311.384615, 317.777778, 270.769231, -inf, -13.538462], - ) - assert_equals( - sample.get_array("lp_var_sa_obj_up"), - [inf, 570.869565, inf, 243.692308, inf], - ) - assert_equals( - sample.get_array("lp_var_sa_ub_down"), - np.array([0.913043, 0.923077, 0.9, 0.0, 43.0]), - ) - assert_equals( - sample.get_array("lp_var_sa_ub_up"), - np.array([2.043478, inf, 2.2, inf, 69.0]), - ) - assert_equals( - sample.get_array("lp_var_values"), - np.array([1.0, 0.923077, 1.0, 0.0, 67.0]), - ) - assert_equals( - sample.get_array("lp_var_features"), - np.array( - [ - [ - 23.0, - 505.0, - 1.0, - 0.32899, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 21.956522, - 1.0, - 21.956522, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.264368, - 1.0, - 0.264368, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 23.0, - 1.0, - 23.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - 5.265874, - 0.0, - 193.615385, - -0.111111, - 1.0, - 311.384615, - 570.869565, - 0.913043, - 2.043478, - 1.0, - ], - [ - 26.0, - 352.0, - 1.0, - 0.229316, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 13.538462, - 1.0, - 13.538462, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.298851, - 1.0, - 0.298851, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 26.0, - 1.0, - 26.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.076923, - 1.0, - 1.0, - 3.532875, - 0.0, - 0.0, - -0.111111, - 0.923077, - 317.777778, - 570.869565, - 0.923077, - 69.0, - 0.923077, - ], - [ - 20.0, - 458.0, - 1.0, - 0.298371, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 22.9, - 1.0, - 22.9, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.229885, - 1.0, - 0.229885, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 20.0, - 1.0, - 20.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - 5.232342, - 0.0, - 187.230769, - -0.111111, - 1.0, - 270.769231, - 570.869565, - 0.9, - 2.2, - 1.0, - ], - [ - 18.0, - 220.0, - 1.0, - 0.143322, - 1e20, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 12.222222, - 1.0, - 12.222222, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.206897, - 1.0, - 0.206897, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 18.0, - 1.0, - 18.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - -1.0, - 5.265874, - 0.0, - -23.692308, - -0.111111, - 1.0, - -13.538462, - 243.692308, - 0.0, - 69.0, - 0.0, - ], - [ - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.011494, - 1.0, - 0.011494, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - 1.0, - 1.0, - 0.0, - 1.0, - -1.0, - 5.265874, - 0.0, - 13.538462, - -0.111111, - 67.0, - -13.538462, - 570.869565, - 43.0, - 69.0, - 67.0, - ], - ] - ), - ) - assert_equals( - sample.get_array("lp_constr_basis_status"), - np.array(["N"], dtype="S"), - ) - assert_equals( - sample.get_array("lp_constr_dual_values"), - np.array([13.538462]), - ) - assert_equals( - sample.get_array("lp_constr_sa_rhs_down"), - np.array([-24.0]), - ) - assert_equals( - sample.get_array("lp_constr_sa_rhs_up"), - np.array([2.0]), - ) - assert_equals( - sample.get_array("lp_constr_slacks"), - np.array([0.0]), - ) - assert_equals( - sample.get_array("lp_constr_features"), - np.array([[0.0, 13.538462, -24.0, 2.0, 0.0]]), - ) - - # after-mip - # ------------------------------------------------------- - solver.solve() - extractor.extract_after_mip_features(solver, sample) - assert_equals( - sample.get_array("mip_var_values"), np.array([1.0, 0.0, 1.0, 1.0, 61.0]) - ) - assert_equals(sample.get_array("mip_constr_slacks"), np.array([0.0])) - - -def test_constraint_getindex() -> None: - cf = Constraints( - names=np.array(["c1", "c2", "c3"], dtype="S"), - rhs=np.array([1.0, 2.0, 3.0]), - senses=np.array(["=", "<", ">"], dtype="S"), - lhs=coo_matrix( - [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], - ] - ), - ) - assert_equals( - cf[[True, False, True]], - Constraints( - names=np.array(["c1", "c3"], dtype="S"), - rhs=np.array([1.0, 3.0]), - senses=np.array(["=", ">"], dtype="S"), - lhs=coo_matrix( - [ - [1, 2, 3], - [7, 8, 9], - ] - ), - ), - ) - - -def test_assert_equals() -> None: - assert_equals("hello", "hello") - assert_equals([1.0, 2.0], [1.0, 2.0]) - assert_equals(np.array([1.0, 2.0]), np.array([1.0, 2.0])) - assert_equals( - np.array([[1.0, 2.0], [3.0, 4.0]]), - np.array([[1.0, 2.0], [3.0, 4.0]]), - ) - assert_equals( - Variables(values=np.array([1.0, 2.0])), # type: ignore - Variables(values=np.array([1.0, 2.0])), # type: ignore - ) - assert_equals(np.array([True, True]), [True, True]) - assert_equals((1.0,), (1.0,)) - assert_equals({"x": 10}, {"x": 10}) - - -class MpsInstance(Instance): - def __init__(self, filename: str) -> None: - super().__init__() - self.filename = filename - - def to_model(self) -> Any: - return gp.read(self.filename) - - -def main() -> None: - solver = GurobiSolver() - instance = MpsInstance(sys.argv[1]) - solver.set_instance(instance) - extractor = FeaturesExtractor(with_lhs=False) - sample = Hdf5Sample("tmp/prof.h5", mode="w") - extractor.extract_after_load_features(instance, solver, sample) - lp_stats = solver.solve_lp(tee=True) - extractor.extract_after_lp_features(solver, sample, lp_stats) - - -if __name__ == "__main__": - cProfile.run("main()", filename="tmp/prof") - os.system("flameprof tmp/prof > tmp/prof.svg") diff --git a/tests/features/test_sample.py b/tests/features/test_sample.py deleted file mode 100644 index 92b795d..0000000 --- a/tests/features/test_sample.py +++ /dev/null @@ -1,71 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from tempfile import NamedTemporaryFile -from typing import Any - -import numpy as np -from scipy.sparse import coo_matrix - -from miplearn.features.sample import MemorySample, Sample, Hdf5Sample - - -def test_memory_sample() -> None: - _test_sample(MemorySample()) - - -def test_hdf5_sample() -> None: - file = NamedTemporaryFile() - _test_sample(Hdf5Sample(file.name)) - - -def _test_sample(sample: Sample) -> None: - _assert_roundtrip_scalar(sample, "A") - _assert_roundtrip_scalar(sample, True) - _assert_roundtrip_scalar(sample, 1) - _assert_roundtrip_scalar(sample, 1.0) - assert sample.get_scalar("unknown-key") is None - - _assert_roundtrip_array(sample, np.array([True, False])) - _assert_roundtrip_array(sample, np.array([1, 2, 3])) - _assert_roundtrip_array(sample, np.array([1.0, 2.0, 3.0])) - _assert_roundtrip_array(sample, np.array(["A", "BB", "CCC"], dtype="S")) - assert sample.get_array("unknown-key") is None - - _assert_roundtrip_sparse( - sample, - coo_matrix( - [ - [1.0, 0.0, 0.0], - [0.0, 2.0, 3.0], - [0.0, 0.0, 4.0], - ], - ), - ) - assert sample.get_sparse("unknown-key") is None - - -def _assert_roundtrip_array(sample: Sample, original: np.ndarray) -> None: - sample.put_array("key", original) - recovered = sample.get_array("key") - assert recovered is not None - assert isinstance(recovered, np.ndarray) - assert (recovered == original).all() - - -def _assert_roundtrip_scalar(sample: Sample, original: Any) -> None: - sample.put_scalar("key", original) - recovered = sample.get_scalar("key") - assert recovered == original - assert recovered is not None - assert isinstance( - recovered, original.__class__ - ), f"Expected {original.__class__}, found {recovered.__class__} instead" - - -def _assert_roundtrip_sparse(sample: Sample, original: coo_matrix) -> None: - sample.put_sparse("key", original) - recovered = sample.get_sparse("key") - assert recovered is not None - assert isinstance(recovered, coo_matrix) - assert (original != recovered).sum() == 0 diff --git a/tests/instance/__init__.py b/tests/instance/__init__.py deleted file mode 100644 index 5fbccb1..0000000 --- a/tests/instance/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. diff --git a/tests/instance/test_file.py b/tests/instance/test_file.py deleted file mode 100644 index 446cb2e..0000000 --- a/tests/instance/test_file.py +++ /dev/null @@ -1,32 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import tempfile - -from miplearn.solvers.learning import LearningSolver -from miplearn.solvers.gurobi import GurobiSolver -from miplearn.features.sample import Hdf5Sample -from miplearn.instance.file import FileInstance - - -def test_usage() -> None: - # Create original instance - original = GurobiSolver().build_test_instance_knapsack() - - # Save instance to disk - filename = tempfile.mktemp() - FileInstance.save(original, filename) - sample = Hdf5Sample(filename) - assert len(sample.get_array("pickled")) > 0 - - # Solve instance from disk - solver = LearningSolver(solver=GurobiSolver()) - solver._solve(FileInstance(filename)) - - # Assert HDF5 contains training data - sample = FileInstance(filename).get_samples()[0] - assert sample.get_scalar("mip_lower_bound") == 1183.0 - assert sample.get_scalar("mip_upper_bound") == 1183.0 - assert len(sample.get_array("lp_var_values")) == 5 - assert len(sample.get_array("mip_var_values")) == 5 diff --git a/tests/instance/test_picklegz.py b/tests/instance/test_picklegz.py deleted file mode 100644 index e7b14e3..0000000 --- a/tests/instance/test_picklegz.py +++ /dev/null @@ -1,33 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import tempfile -from typing import cast, IO - -from miplearn.instance.picklegz import write_pickle_gz, PickleGzInstance -from miplearn.solvers.gurobi import GurobiSolver -from miplearn import save -from os.path import exists -import gzip -import pickle - - -def test_usage() -> None: - original = GurobiSolver().build_test_instance_knapsack() - file = tempfile.NamedTemporaryFile() - write_pickle_gz(original, file.name) - pickled = PickleGzInstance(file.name) - pickled.load() - assert pickled.to_model() is not None - - -def test_save() -> None: - objs = [1, "ABC", True] - with tempfile.TemporaryDirectory() as dirname: - filenames = save(objs, dirname) - assert len(filenames) == 3 - for (idx, f) in enumerate(filenames): - assert exists(f) - with gzip.GzipFile(f, "rb") as file: - assert pickle.load(cast(IO[bytes], file)) == objs[idx] diff --git a/tests/problems/__init__.py b/tests/problems/__init__.py index 5fbccb1..4932a43 100644 --- a/tests/problems/__init__.py +++ b/tests/problems/__init__.py @@ -1,3 +1,3 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. diff --git a/tests/problems/test_binpack.py b/tests/problems/test_binpack.py new file mode 100644 index 0000000..7ffe5ac --- /dev/null +++ b/tests/problems/test_binpack.py @@ -0,0 +1,58 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import numpy as np +from scipy.stats import uniform, randint + +from miplearn.problems.binpack import build_binpack_model, BinPackData, BinPackGenerator + + +def test_binpack_generator() -> None: + np.random.seed(42) + gen = BinPackGenerator( + n=randint(low=10, high=11), + sizes=uniform(loc=0, scale=10), + capacity=uniform(loc=100, scale=0), + sizes_jitter=uniform(loc=0.9, scale=0.2), + capacity_jitter=uniform(loc=0.9, scale=0.2), + fix_items=True, + ) + data = gen.generate(2) + assert data[0].sizes.tolist() == [ + 3.39, + 10.4, + 7.81, + 5.64, + 1.46, + 1.46, + 0.56, + 8.7, + 5.93, + 6.79, + ] + assert data[0].capacity == 102.24 + assert data[1].sizes.tolist() == [ + 3.48, + 9.11, + 7.12, + 5.93, + 1.65, + 1.47, + 0.58, + 8.82, + 5.47, + 7.23, + ] + assert data[1].capacity == 93.41 + + +def test_binpack() -> None: + model = build_binpack_model( + BinPackData( + sizes=np.array([4, 8, 1, 4, 2, 1]), + capacity=10, + ) + ) + model.optimize() + assert model.inner.objVal == 2.0 diff --git a/tests/problems/test_knapsack.py b/tests/problems/test_knapsack.py deleted file mode 100644 index 760b58c..0000000 --- a/tests/problems/test_knapsack.py +++ /dev/null @@ -1,39 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import numpy as np -from scipy.stats import uniform, randint - -from miplearn import LearningSolver -from miplearn.problems.knapsack import MultiKnapsackGenerator, MultiKnapsackInstance - - -def test_knapsack_generator() -> None: - gen = MultiKnapsackGenerator( - n=randint(low=100, high=101), - m=randint(low=30, high=31), - w=randint(low=0, high=1000), - K=randint(low=500, high=501), - u=uniform(loc=1.0, scale=1.0), - alpha=uniform(loc=0.50, scale=0.0), - ) - data = gen.generate(100) - w_sum = sum(d.weights for d in data) / len(data) - b_sum = sum(d.capacities for d in data) / len(data) - assert round(float(np.mean(w_sum)), -1) == 500.0 - assert round(float(np.mean(b_sum)), -3) == 25000.0 - - -def test_knapsack() -> None: - data = MultiKnapsackGenerator( - n=randint(low=5, high=6), - m=randint(low=5, high=6), - ).generate(1) - instance = MultiKnapsackInstance( - prices=data[0].prices, - capacities=data[0].capacities, - weights=data[0].weights, - ) - solver = LearningSolver() - solver._solve(instance) diff --git a/tests/problems/test_multiknapsack.py b/tests/problems/test_multiknapsack.py new file mode 100644 index 0000000..2a86338 --- /dev/null +++ b/tests/problems/test_multiknapsack.py @@ -0,0 +1,61 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import numpy as np +from scipy.stats import uniform, randint + +from miplearn.problems.multiknapsack import ( + MultiKnapsackGenerator, + MultiKnapsackData, + build_multiknapsack_model, +) + + +def test_knapsack_generator() -> None: + np.random.seed(42) + gen = MultiKnapsackGenerator( + n=randint(low=5, high=6), + m=randint(low=3, high=4), + w=randint(low=0, high=1000), + K=randint(low=500, high=501), + u=uniform(loc=0.0, scale=1.0), + alpha=uniform(loc=0.25, scale=0.0), + fix_w=True, + w_jitter=uniform(loc=0.9, scale=0.2), + p_jitter=uniform(loc=0.9, scale=0.2), + round=True, + ) + data = gen.generate(2) + assert data[0].prices.tolist() == [433.0, 477.0, 802.0, 494.0, 458.0] + assert data[0].capacities.tolist() == [458.0, 357.0, 392.0] + assert data[0].weights.tolist() == [ + [111.0, 392.0, 945.0, 276.0, 108.0], + [64.0, 633.0, 20.0, 602.0, 110.0], + [510.0, 203.0, 303.0, 469.0, 85.0], + ] + + assert data[1].prices.tolist() == [344.0, 527.0, 658.0, 519.0, 460.0] + assert data[1].capacities.tolist() == [449.0, 377.0, 380.0] + assert data[1].weights.tolist() == [ + [92.0, 473.0, 871.0, 264.0, 96.0], + [67.0, 664.0, 21.0, 628.0, 129.0], + [436.0, 209.0, 309.0, 481.0, 86.0], + ] + + +def test_knapsack_model() -> None: + data = MultiKnapsackData( + prices=np.array([344.0, 527.0, 658.0, 519.0, 460.0]), + capacities=np.array([449.0, 377.0, 380.0]), + weights=np.array( + [ + [92.0, 473.0, 871.0, 264.0, 96.0], + [67.0, 664.0, 21.0, 628.0, 129.0], + [436.0, 209.0, 309.0, 481.0, 86.0], + ] + ), + ) + model = build_multiknapsack_model(data) + model.optimize() + assert model.inner.objVal == -460.0 diff --git a/tests/problems/test_pmedian.py b/tests/problems/test_pmedian.py new file mode 100644 index 0000000..4e4d40f --- /dev/null +++ b/tests/problems/test_pmedian.py @@ -0,0 +1,53 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import numpy as np +from scipy.stats import uniform, randint + +from miplearn.problems.pmedian import PMedianGenerator, build_pmedian_model + + +def test_pmedian() -> None: + np.random.seed(42) + gen = PMedianGenerator( + x=uniform(loc=0.0, scale=100.0), + y=uniform(loc=0.0, scale=100.0), + n=randint(low=5, high=6), + p=randint(low=2, high=3), + demands=uniform(loc=0, scale=20), + capacities=uniform(loc=0, scale=100), + distances_jitter=uniform(loc=0.95, scale=0.1), + demands_jitter=uniform(loc=0.95, scale=0.1), + capacities_jitter=uniform(loc=0.95, scale=0.1), + fixed=True, + ) + data = gen.generate(2) + + assert data[0].p == 2 + assert data[0].demands.tolist() == [0.41, 19.4, 16.65, 4.25, 3.64] + assert data[0].capacities.tolist() == [18.34, 30.42, 52.48, 43.19, 29.12] + assert data[0].distances.tolist() == [ + [0.0, 50.17, 82.42, 32.76, 33.2], + [50.17, 0.0, 72.64, 72.51, 17.06], + [82.42, 72.64, 0.0, 71.69, 70.92], + [32.76, 72.51, 71.69, 0.0, 56.56], + [33.2, 17.06, 70.92, 56.56, 0.0], + ] + + assert data[1].p == 2 + assert data[1].demands.tolist() == [0.42, 19.03, 16.68, 4.27, 3.53] + assert data[1].capacities.tolist() == [19.2, 31.26, 54.79, 44.9, 29.41] + assert data[1].distances.tolist() == [ + [0.0, 51.6, 83.31, 33.77, 31.95], + [51.6, 0.0, 70.25, 71.09, 17.05], + [83.31, 70.25, 0.0, 68.81, 67.62], + [33.77, 71.09, 68.81, 0.0, 58.88], + [31.95, 17.05, 67.62, 58.88, 0.0], + ] + + model = build_pmedian_model(data[0]) + assert model.inner.numVars == 30 + assert model.inner.numConstrs == 11 + model.optimize() + assert round(model.inner.objVal) == 107 diff --git a/tests/problems/test_setcover.py b/tests/problems/test_setcover.py new file mode 100644 index 0000000..caf36d3 --- /dev/null +++ b/tests/problems/test_setcover.py @@ -0,0 +1,91 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from tempfile import NamedTemporaryFile + +import numpy as np +from scipy.stats import randint, uniform + +from miplearn.h5 import H5File +from miplearn.problems.setcover import ( + SetCoverData, + build_setcover_model_gurobipy, + SetCoverGenerator, + build_setcover_model_pyomo, +) + + +def test_set_cover_generator() -> None: + np.random.seed(42) + gen = SetCoverGenerator( + n_elements=randint(low=3, high=4), + n_sets=randint(low=5, high=6), + costs=uniform(loc=0.0, scale=100.0), + costs_jitter=uniform(loc=0.95, scale=0.10), + density=uniform(loc=0.5, scale=0), + K=uniform(loc=25, scale=0), + fix_sets=False, + ) + data = gen.generate(2) + + assert data[0].costs.round(1).tolist() == [136.8, 86.2, 25.7, 27.3, 102.5] + assert data[0].incidence_matrix.tolist() == [ + [1, 0, 1, 0, 1], + [1, 1, 0, 0, 0], + [1, 0, 0, 1, 1], + ] + assert data[1].costs.round(1).tolist() == [63.5, 76.6, 48.1, 74.1, 93.3] + assert data[1].incidence_matrix.tolist() == [ + [1, 1, 0, 1, 1], + [0, 1, 0, 1, 0], + [0, 1, 1, 0, 0], + ] + + +def test_set_cover_generator_with_fixed_sets() -> None: + np.random.seed(42) + gen = SetCoverGenerator( + n_elements=randint(low=3, high=4), + n_sets=randint(low=5, high=6), + costs=uniform(loc=0.0, scale=100.0), + costs_jitter=uniform(loc=0.95, scale=0.10), + density=uniform(loc=0.5, scale=0.00), + fix_sets=True, + ) + data = gen.generate(3) + + assert data[0].costs.tolist() == [136.75, 86.17, 25.71, 27.31, 102.48] + assert data[1].costs.tolist() == [135.38, 82.26, 26.92, 26.58, 98.28] + assert data[2].costs.tolist() == [138.37, 85.15, 26.95, 27.22, 106.17] + + print(data[0].incidence_matrix) + + for i in range(3): + assert data[i].incidence_matrix.tolist() == [ + [1, 0, 1, 0, 1], + [1, 1, 0, 0, 0], + [1, 0, 0, 1, 1], + ] + + +def test_set_cover() -> None: + data = SetCoverData( + costs=np.array([5, 10, 12, 6, 8]), + incidence_matrix=np.array( + [ + [1, 0, 0, 1, 0], + [1, 1, 0, 0, 0], + [0, 0, 1, 1, 1], + ], + ), + ) + for model in [ + build_setcover_model_pyomo(data), + build_setcover_model_gurobipy(data), + ]: + with NamedTemporaryFile() as tempfile: + with H5File(tempfile.name) as h5: + model.optimize() + model.extract_after_mip(h5) + assert h5.get_scalar("mip_obj_value") == 11.0 diff --git a/tests/problems/test_setpack.py b/tests/problems/test_setpack.py new file mode 100644 index 0000000..4ef0dd9 --- /dev/null +++ b/tests/problems/test_setpack.py @@ -0,0 +1,26 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import numpy as np + +from miplearn.problems.setpack import ( + SetPackData, + build_setpack_model, +) + + +def test_setpack() -> None: + data = SetPackData( + costs=np.array([5, 10, 12, 6, 8]), + incidence_matrix=np.array( + [ + [1, 0, 0, 1, 0], + [1, 1, 0, 0, 0], + [0, 0, 1, 1, 1], + ], + ), + ) + model = build_setpack_model(data) + model.optimize() + assert model.inner.objval == -22.0 diff --git a/tests/problems/test_stab.py b/tests/problems/test_stab.py index 27a2e78..09ac06f 100644 --- a/tests/problems/test_stab.py +++ b/tests/problems/test_stab.py @@ -1,53 +1,30 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +from tempfile import NamedTemporaryFile import networkx as nx import numpy as np -from scipy.stats import uniform, randint -from miplearn.problems.stab import MaxWeightStableSetInstance -from miplearn.solvers.learning import LearningSolver +from miplearn.h5 import H5File +from miplearn.problems.stab import ( + MaxWeightStableSetData, + build_stab_model_pyomo, + build_stab_model_gurobipy, +) def test_stab() -> None: - graph = nx.cycle_graph(5) - weights = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) - instance = MaxWeightStableSetInstance(graph, weights) - solver = LearningSolver() - stats = solver._solve(instance) - assert stats["mip_lower_bound"] == 2.0 - - -def test_stab_generator_fixed_graph() -> None: - np.random.seed(42) - from miplearn.problems.stab import MaxWeightStableSetGenerator - - gen = MaxWeightStableSetGenerator( - w=uniform(loc=50.0, scale=10.0), - n=randint(low=10, high=11), - p=uniform(loc=0.05, scale=0.0), - fix_graph=True, - ) - data = gen.generate(1_000) - weights = np.array([d.weights for d in data]) - weights_avg_actual = np.round(np.average(weights, axis=0)) - weights_avg_expected = [55.0] * 10 - assert list(weights_avg_actual) == weights_avg_expected - - -def test_stab_generator_random_graph() -> None: - np.random.seed(42) - from miplearn.problems.stab import MaxWeightStableSetGenerator - - gen = MaxWeightStableSetGenerator( - w=uniform(loc=50.0, scale=10.0), - n=randint(low=30, high=41), - p=uniform(loc=0.5, scale=0.0), - fix_graph=False, + data = MaxWeightStableSetData( + graph=nx.cycle_graph(5), + weights=np.array([1.0, 1.0, 1.0, 1.0, 1.0]), ) - data = gen.generate(1_000) - n_nodes = [d.graph.number_of_nodes() for d in data] - n_edges = [d.graph.number_of_edges() for d in data] - assert np.round(np.mean(n_nodes)) == 35.0 - assert np.round(np.mean(n_edges), -1) == 300.0 + for model in [ + build_stab_model_pyomo(data), + build_stab_model_gurobipy(data), + ]: + with NamedTemporaryFile() as tempfile: + with H5File(tempfile.name) as h5: + model.optimize() + model.extract_after_mip(h5) + assert h5.get_scalar("mip_obj_value") == -2.0 diff --git a/tests/problems/test_tsp.py b/tests/problems/test_tsp.py index f3cc510..46b3a3b 100644 --- a/tests/problems/test_tsp.py +++ b/tests/problems/test_tsp.py @@ -1,100 +1,72 @@ # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -import json import numpy as np -from numpy.linalg import norm +from miplearn.problems.tsp import ( + TravelingSalesmanData, + TravelingSalesmanGenerator, + build_tsp_model, +) from scipy.spatial.distance import pdist, squareform -from scipy.stats import uniform, randint +from scipy.stats import randint, uniform -from miplearn.problems.tsp import TravelingSalesmanGenerator, TravelingSalesmanInstance -from miplearn.solvers.learning import LearningSolver -from miplearn.solvers.tests import assert_equals - -def test_generator() -> None: - data = TravelingSalesmanGenerator( +def test_tsp_generator() -> None: + np.random.seed(42) + gen = TravelingSalesmanGenerator( x=uniform(loc=0.0, scale=1000.0), y=uniform(loc=0.0, scale=1000.0), - n=randint(low=100, high=101), - gamma=uniform(loc=0.95, scale=0.1), + n=randint(low=3, high=4), + gamma=uniform(loc=1.0, scale=0.25), fix_cities=True, - ).generate(100) - assert len(data) == 100 - assert data[0].n_cities == 100 - assert norm(data[0].distances - data[0].distances.T) < 1e-6 - d = [d.distances[0, 1] for d in data] - assert np.std(d) > 0 - - -def test_instance() -> None: - n_cities = 4 - distances = np.array( - [ - [0.0, 1.0, 2.0, 1.0], - [1.0, 0.0, 1.0, 2.0], - [2.0, 1.0, 0.0, 1.0], - [1.0, 2.0, 1.0, 0.0], - ] - ) - instance = TravelingSalesmanInstance(n_cities, distances) - solver = LearningSolver() - solver._solve(instance) - assert len(instance.get_samples()) == 1 - sample = instance.get_samples()[0] - assert_equals(sample.get_array("mip_var_values"), [1.0, 0.0, 1.0, 1.0, 0.0, 1.0]) - assert sample.get_scalar("mip_lower_bound") == 4.0 - assert sample.get_scalar("mip_upper_bound") == 4.0 - - -def test_subtour() -> None: - n_cities = 6 - cities = np.array( - [ - [0.0, 0.0], - [1.0, 0.0], - [2.0, 0.0], - [3.0, 0.0], - [0.0, 1.0], - [3.0, 1.0], - ] + round=True, ) - distances = squareform(pdist(cities)) - instance = TravelingSalesmanInstance(n_cities, distances) - solver = LearningSolver() - solver._solve(instance) - samples = instance.get_samples() - assert len(samples) == 1 - sample = samples[0] + data = gen.generate(2) + assert data[0].distances.tolist() == [ + [0.0, 591.0, 996.0], + [591.0, 0.0, 765.0], + [996.0, 765.0, 0.0], + ] + assert data[1].distances.tolist() == [ + [0.0, 556.0, 853.0], + [556.0, 0.0, 779.0], + [853.0, 779.0, 0.0], + ] - lazy_encoded = sample.get_scalar("mip_constr_lazy") - assert lazy_encoded is not None - lazy = json.loads(lazy_encoded) - assert lazy == { - "st[0,1,4]": [0, 1, 4], - "st[2,3,5]": [2, 3, 5], - } - assert_equals( - sample.get_array("mip_var_values"), - [ - 1.0, - 0.0, - 0.0, - 1.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 1.0, - 1.0, - ], +def test_tsp() -> None: + data = TravelingSalesmanData( + n_cities=6, + distances=squareform( + pdist( + [ + [0.0, 0.0], + [1.0, 0.0], + [2.0, 0.0], + [3.0, 0.0], + [0.0, 1.0], + [3.0, 1.0], + ] + ) + ), ) - solver._fit([instance]) - solver._solve(instance) + model = build_tsp_model(data) + model.optimize() + assert model.inner.getAttr("x", model.inner.getVars()) == [ + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + ] diff --git a/tests/problems/test_uc.py b/tests/problems/test_uc.py new file mode 100644 index 0000000..d27d8fb --- /dev/null +++ b/tests/problems/test_uc.py @@ -0,0 +1,71 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import numpy as np +from scipy.stats import uniform, randint + +from miplearn.problems.uc import ( + UnitCommitmentData, + build_uc_model, + UnitCommitmentGenerator, +) + + +def test_generator() -> None: + np.random.seed(42) + gen = UnitCommitmentGenerator( + n_units=randint(low=3, high=4), + n_periods=randint(low=4, high=5), + max_power=uniform(loc=50, scale=450), + min_power=uniform(loc=0.25, scale=0.5), + cost_startup=uniform(loc=1, scale=1), + cost_prod=uniform(loc=1, scale=1), + cost_fixed=uniform(loc=1, scale=1), + min_uptime=randint(low=1, high=8), + min_downtime=randint(low=1, high=8), + cost_jitter=uniform(loc=0.75, scale=0.5), + demand_jitter=uniform(loc=0.9, scale=0.2), + fix_units=True, + ) + data = gen.generate(2) + + assert data[0].demand.tolist() == [430.3, 518.65, 448.16, 860.61] + assert data[0].min_power.tolist() == [120.05, 156.73, 124.44] + assert data[0].max_power.tolist() == [218.54, 477.82, 379.4] + assert data[0].min_uptime.tolist() == [3, 3, 5] + assert data[0].min_downtime.tolist() == [4, 3, 6] + assert data[0].cost_startup.tolist() == [1.06, 1.72, 1.94] + assert data[0].cost_prod.tolist() == [1.0, 1.99, 1.62] + assert data[0].cost_fixed.tolist() == [1.61, 1.01, 1.02] + + assert data[1].demand.tolist() == [407.3, 476.18, 458.77, 840.38] + assert data[1].min_power.tolist() == [120.05, 156.73, 124.44] + assert data[1].max_power.tolist() == [218.54, 477.82, 379.4] + assert data[1].min_uptime.tolist() == [3, 3, 5] + assert data[1].min_downtime.tolist() == [4, 3, 6] + assert data[1].cost_startup.tolist() == [1.32, 1.69, 2.29] + assert data[1].cost_prod.tolist() == [1.09, 1.94, 1.23] + assert data[1].cost_fixed.tolist() == [1.97, 1.04, 0.96] + + +def test_uc() -> None: + data = UnitCommitmentData( + demand=np.array([10, 12, 15, 10, 8, 5]), + min_power=np.array([5, 5, 10]), + max_power=np.array([10, 8, 20]), + min_uptime=np.array([4, 3, 2]), + min_downtime=np.array([4, 3, 2]), + cost_startup=np.array([100, 120, 200]), + cost_prod=np.array([1.0, 1.25, 1.5]), + cost_fixed=np.array([10, 12, 9]), + ) + model = build_uc_model(data) + model.optimize() + assert model.inner.objVal == 154.5 + + +if __name__ == "__main__": + data = UnitCommitmentGenerator().generate(1)[0] + model = build_uc_model(data) + model.optimize() diff --git a/tests/problems/test_vertexcover.py b/tests/problems/test_vertexcover.py new file mode 100644 index 0000000..327d6bd --- /dev/null +++ b/tests/problems/test_vertexcover.py @@ -0,0 +1,21 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +import networkx as nx +import numpy as np + +from miplearn.problems.vertexcover import ( + MinWeightVertexCoverData, + build_vertexcover_model, +) + + +def test_stab() -> None: + data = MinWeightVertexCoverData( + graph=nx.cycle_graph(5), + weights=np.array([1.0, 1.0, 1.0, 1.0, 1.0]), + ) + model = build_vertexcover_model(data) + model.optimize() + assert model.inner.objVal == 3.0 diff --git a/tests/solvers/__init__.py b/tests/solvers/__init__.py deleted file mode 100644 index 27484ca..0000000 --- a/tests/solvers/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from io import StringIO - -from miplearn.solvers import _RedirectOutput - - -def test_redirect_output() -> None: - import sys - - original_stdout = sys.stdout - io = StringIO() - with _RedirectOutput([io]): - print("Hello world") - assert sys.stdout == original_stdout - assert io.getvalue() == "Hello world\n" diff --git a/tests/solvers/test_internal_solver.py b/tests/solvers/test_internal_solver.py deleted file mode 100644 index 4d50a98..0000000 --- a/tests/solvers/test_internal_solver.py +++ /dev/null @@ -1,37 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -from typing import List - -import pytest - -from miplearn.solvers.gurobi import GurobiSolver -from miplearn.solvers.internal import InternalSolver -from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver -from miplearn.solvers.pyomo.xpress import XpressPyomoSolver -from miplearn.solvers.tests import run_internal_solver_tests - -logger = logging.getLogger(__name__) - - -@pytest.fixture -def internal_solvers() -> List[InternalSolver]: - return [ - XpressPyomoSolver(), - GurobiSolver(), - GurobiPyomoSolver(), - ] - - -def test_xpress_pyomo_solver() -> None: - run_internal_solver_tests(XpressPyomoSolver()) - - -def test_gurobi_pyomo_solver() -> None: - run_internal_solver_tests(GurobiPyomoSolver()) - - -def test_gurobi_solver() -> None: - run_internal_solver_tests(GurobiSolver()) diff --git a/tests/solvers/test_learning_solver.py b/tests/solvers/test_learning_solver.py deleted file mode 100644 index 7a70bee..0000000 --- a/tests/solvers/test_learning_solver.py +++ /dev/null @@ -1,163 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import logging -import os -import tempfile -from os.path import exists -from typing import List, cast - -import dill -from scipy.stats import randint - -from miplearn.features.sample import Hdf5Sample -from miplearn.instance.base import Instance -from miplearn.instance.picklegz import ( - PickleGzInstance, - write_pickle_gz, - read_pickle_gz, - save, -) -from miplearn.problems.stab import MaxWeightStableSetGenerator, build_stab_model -from miplearn.solvers.internal import InternalSolver -from miplearn.solvers.learning import LearningSolver -from miplearn.solvers.tests import assert_equals - -# noinspection PyUnresolvedReferences -from tests.solvers.test_internal_solver import internal_solvers - -logger = logging.getLogger(__name__) - - -def test_learning_solver( - internal_solvers: List[InternalSolver], -) -> None: - for mode in ["exact", "heuristic"]: - for internal_solver in internal_solvers: - logger.info("Solver: %s" % internal_solver) - instance = internal_solver.build_test_instance_knapsack() - solver = LearningSolver( - solver=internal_solver, - mode=mode, - ) - - solver._solve(instance) - assert len(instance.get_samples()) > 0 - sample = instance.get_samples()[0] - - assert_equals( - sample.get_array("mip_var_values"), [1.0, 0.0, 1.0, 1.0, 61.0] - ) - assert sample.get_scalar("mip_lower_bound") == 1183.0 - assert sample.get_scalar("mip_upper_bound") == 1183.0 - mip_log = sample.get_scalar("mip_log") - assert mip_log is not None - assert len(mip_log) > 100 - - assert_equals( - sample.get_array("lp_var_values"), [1.0, 0.923077, 1.0, 0.0, 67.0] - ) - assert_equals(sample.get_scalar("lp_value"), 1287.923077) - lp_log = sample.get_scalar("lp_log") - assert lp_log is not None - assert len(lp_log) > 100 - - solver._fit([instance], n_jobs=4) - solver._solve(instance) - - # Assert solver is picklable - with tempfile.TemporaryFile() as file: - dill.dump(solver, file) - - -def test_solve_without_lp( - internal_solvers: List[InternalSolver], -) -> None: - for internal_solver in internal_solvers: - logger.info("Solver: %s" % internal_solver) - instance = internal_solver.build_test_instance_knapsack() - solver = LearningSolver( - solver=internal_solver, - solve_lp=False, - ) - solver._solve(instance) - solver._fit([instance]) - solver._solve(instance) - - -def test_parallel_solve( - internal_solvers: List[InternalSolver], -) -> None: - for internal_solver in internal_solvers: - instances = [internal_solver.build_test_instance_knapsack() for _ in range(10)] - solver = LearningSolver(solver=internal_solver) - results = solver.parallel_solve(instances, n_jobs=3) - assert len(results) == 10 - for instance in instances: - assert len(instance.get_samples()) == 1 - - -def test_solve_fit_from_disk( - internal_solvers: List[InternalSolver], -) -> None: - for internal_solver in internal_solvers: - # Create instances and pickle them - instances: List[Instance] = [] - for k in range(3): - instance = internal_solver.build_test_instance_knapsack() - with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as file: - instances += [PickleGzInstance(file.name)] - write_pickle_gz(instance, file.name) - - # Test: solve - solver = LearningSolver(solver=internal_solver) - solver._solve(instances[0]) - instance_loaded = read_pickle_gz(cast(PickleGzInstance, instances[0]).filename) - assert len(instance_loaded.get_samples()) > 0 - - # Test: parallel_solve - solver.parallel_solve(instances) - for instance in instances: - instance_loaded = read_pickle_gz(cast(PickleGzInstance, instance).filename) - assert len(instance_loaded.get_samples()) > 0 - - # Delete temporary files - for instance in instances: - os.remove(cast(PickleGzInstance, instance).filename) - - -def test_basic_usage() -> None: - with tempfile.TemporaryDirectory() as dirname: - # Generate instances - data = MaxWeightStableSetGenerator(n=randint(low=20, high=21)).generate(4) - train_files = save(data[0:3], f"{dirname}/train") - test_files = save(data[3:4], f"{dirname}/test") - - # Solve training instances - solver = LearningSolver() - stats = solver.solve(train_files, build_stab_model) - assert len(stats) == 3 - for f in train_files: - sample_filename = f.replace(".pkl.gz", ".h5") - assert exists(sample_filename) - sample = Hdf5Sample(sample_filename) - assert sample.get_scalar("mip_lower_bound") > 0 - - # Fit - solver.fit(train_files, build_stab_model) - - # Solve test instances - stats = solver.solve(test_files, build_stab_model) - assert isinstance(stats, list) - assert "Objective: Predicted lower bound" in stats[0].keys() - - -def test_gap() -> None: - assert LearningSolver._compute_gap(ub=0.0, lb=0.0) == 0.0 - assert LearningSolver._compute_gap(ub=1.0, lb=0.5) == 0.5 - assert LearningSolver._compute_gap(ub=1.0, lb=1.0) == 0.0 - assert LearningSolver._compute_gap(ub=1.0, lb=-1.0) is None - assert LearningSolver._compute_gap(ub=1.0, lb=None) is None - assert LearningSolver._compute_gap(ub=None, lb=1.0) is None - assert LearningSolver._compute_gap(ub=None, lb=None) is None diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py deleted file mode 100644 index da1c096..0000000 --- a/tests/test_benchmark.py +++ /dev/null @@ -1,48 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. - -import os.path - -from scipy.stats import randint - -from miplearn.benchmark import BenchmarkRunner -from miplearn.problems.stab import ( - MaxWeightStableSetInstance, - MaxWeightStableSetGenerator, -) -from miplearn.solvers.learning import LearningSolver - - -def test_benchmark() -> None: - for n_jobs in [1, 4]: - # Generate training and test instances - generator = MaxWeightStableSetGenerator(n=randint(low=25, high=26)) - train_instances = [ - MaxWeightStableSetInstance(data.graph, data.weights) - for data in generator.generate(5) - ] - test_instances = [ - MaxWeightStableSetInstance(data.graph, data.weights) - for data in generator.generate(3) - ] - - # Solve training instances - training_solver = LearningSolver() - training_solver.parallel_solve(train_instances, n_jobs=n_jobs) # type: ignore - - # Benchmark - test_solvers = { - "Strategy A": LearningSolver(), - "Strategy B": LearningSolver(), - } - benchmark = BenchmarkRunner(test_solvers) - benchmark.fit(train_instances, n_jobs=n_jobs) # type: ignore - benchmark.parallel_solve( - test_instances, # type: ignore - n_jobs=n_jobs, - n_trials=2, - ) - benchmark.write_csv("/tmp/benchmark.csv") - assert os.path.isfile("/tmp/benchmark.csv") - assert benchmark.results.values.shape == (12, 21) diff --git a/tests/test_h5.py b/tests/test_h5.py new file mode 100644 index 0000000..9aeb321 --- /dev/null +++ b/tests/test_h5.py @@ -0,0 +1,64 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from tempfile import NamedTemporaryFile +from typing import Any + +import numpy as np +from scipy.sparse import coo_matrix + +from miplearn.h5 import H5File + + +def test_h5() -> None: + file = NamedTemporaryFile() + h5 = H5File(file.name) + _assert_roundtrip_scalar(h5, "A") + _assert_roundtrip_scalar(h5, True) + _assert_roundtrip_scalar(h5, 1) + _assert_roundtrip_scalar(h5, 1.0) + assert h5.get_scalar("unknown-key") is None + + _assert_roundtrip_array(h5, np.array([True, False])) + _assert_roundtrip_array(h5, np.array([1, 2, 3])) + _assert_roundtrip_array(h5, np.array([1.0, 2.0, 3.0])) + _assert_roundtrip_array(h5, np.array(["A", "BB", "CCC"], dtype="S")) + assert h5.get_array("unknown-key") is None + + _assert_roundtrip_sparse( + h5, + coo_matrix( + [ + [1.0, 0.0, 0.0], + [0.0, 2.0, 3.0], + [0.0, 0.0, 4.0], + ], + ), + ) + assert h5.get_sparse("unknown-key") is None + + +def _assert_roundtrip_array(h5: H5File, original: np.ndarray) -> None: + h5.put_array("key", original) + recovered = h5.get_array("key") + assert recovered is not None + assert isinstance(recovered, np.ndarray) + assert (recovered == original).all() + + +def _assert_roundtrip_scalar(h5: H5File, original: Any) -> None: + h5.put_scalar("key", original) + recovered = h5.get_scalar("key") + assert recovered == original + assert recovered is not None + assert isinstance( + recovered, original.__class__ + ), f"Expected {original.__class__}, found {recovered.__class__} instead" + + +def _assert_roundtrip_sparse(h5: H5File, original: coo_matrix) -> None: + h5.put_sparse("key", original) + recovered = h5.get_sparse("key") + assert recovered is not None + assert isinstance(recovered, coo_matrix) + assert (original != recovered).sum() == 0 diff --git a/tests/test_solvers.py b/tests/test_solvers.py new file mode 100644 index 0000000..774ab61 --- /dev/null +++ b/tests/test_solvers.py @@ -0,0 +1,181 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2022, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. + +from tempfile import NamedTemporaryFile + +import numpy as np +import pytest + +from miplearn.h5 import H5File +from miplearn.problems.setcover import ( + SetCoverData, + build_setcover_model_gurobipy, + build_setcover_model_pyomo, +) +from miplearn.solvers.abstract import AbstractModel + +inf = float("inf") + + +@pytest.fixture +def data() -> SetCoverData: + return SetCoverData( + costs=np.array([5, 10, 12, 6, 8]), + incidence_matrix=np.array( + [ + [1, 0, 0, 1, 0], + [1, 1, 0, 0, 0], + [0, 0, 1, 1, 1], + ], + ), + ) + + +def test_gurobi(data: SetCoverData) -> None: + _test_solver(build_setcover_model_gurobipy, data) + + +def test_pyomo_persistent(data: SetCoverData) -> None: + _test_solver(lambda d: build_setcover_model_pyomo(d, "gurobi_persistent"), data) + + +def _test_solver(build_model, data): + _test_extract(build_model(data)) + _test_add_constr(build_model(data)) + _test_fix_vars(build_model(data)) + _test_infeasible(build_model(data)) + + +def _test_extract(model): + with NamedTemporaryFile() as tempfile: + with H5File(tempfile.name) as h5: + + def test_scalar(key, expected_value): + actual_value = h5.get_scalar(key) + assert actual_value is not None + assert actual_value == expected_value + + def test_array(key, expected_value): + actual_value = h5.get_array(key) + assert actual_value is not None + assert actual_value.tolist() == expected_value + + def test_sparse(key, expected_value): + actual_value = h5.get_sparse(key) + assert actual_value is not None + assert actual_value.todense().tolist() == expected_value + + model.extract_after_load(h5) + test_sparse( + "static_constr_lhs", + [ + [1.0, 0.0, 0.0, 1.0, 0.0], + [1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 1.0, 1.0], + ], + ) + test_array("static_constr_names", [b"eqs[0]", b"eqs[1]", b"eqs[2]"]) + test_array("static_constr_rhs", [1, 1, 1]) + test_array("static_constr_sense", [b">", b">", b">"]) + test_scalar("static_obj_offset", 0.0) + test_scalar("static_sense", "min") + test_array("static_var_lower_bounds", [0.0, 0.0, 0.0, 0.0, 0.0]) + test_array( + "static_var_names", + [ + b"x[0]", + b"x[1]", + b"x[2]", + b"x[3]", + b"x[4]", + ], + ) + test_array("static_var_obj_coeffs", [5.0, 10.0, 12.0, 6.0, 8.0]) + test_array("static_var_types", [b"B", b"B", b"B", b"B", b"B"]) + test_array("static_var_upper_bounds", [1.0, 1.0, 1.0, 1.0, 1.0]) + + relaxed = model.relax() + relaxed.optimize() + relaxed.extract_after_lp(h5) + test_array("lp_constr_dual_values", [0, 5, 6]) + test_array("lp_constr_slacks", [1, 0, 0]) + test_scalar("lp_obj_value", 11.0) + test_array("lp_var_reduced_costs", [0.0, 5.0, 6.0, 0.0, 2.0]) + test_array("lp_var_values", [1.0, 0.0, 0.0, 1.0, 0.0]) + if model._supports_basis_status: + test_array("lp_var_basis_status", [b"B", b"L", b"L", b"B", b"L"]) + test_array("lp_constr_basis_status", [b"B", b"N", b"N"]) + if model._supports_sensitivity_analysis: + test_array("lp_constr_sa_rhs_up", [2, 1, 1]) + test_array("lp_constr_sa_rhs_down", [-inf, 0, 0]) + test_array("lp_var_sa_obj_up", [10.0, inf, inf, 8.0, inf]) + test_array("lp_var_sa_obj_down", [0.0, 5.0, 6.0, 0.0, 6.0]) + test_array("lp_var_sa_ub_up", [inf, inf, inf, inf, inf]) + test_array("lp_var_sa_ub_down", [1.0, 0.0, 0.0, 1.0, 0.0]) + test_array("lp_var_sa_lb_up", [1.0, 1.0, 1.0, 1.0, 1.0]) + test_array("lp_var_sa_lb_down", [-inf, 0.0, 0.0, -inf, 0.0]) + lp_wallclock_time = h5.get_scalar("lp_wallclock_time") + assert lp_wallclock_time is not None + assert lp_wallclock_time >= 0 + + model.optimize() + model.extract_after_mip(h5) + test_array("mip_constr_slacks", [1, 0, 0]) + test_array("mip_var_values", [1.0, 0.0, 0.0, 1.0, 0.0]) + test_scalar("mip_gap", 0) + test_scalar("mip_obj_bound", 11.0) + test_scalar("mip_obj_value", 11.0) + mip_wallclock_time = h5.get_scalar("mip_wallclock_time") + assert mip_wallclock_time is not None + assert mip_wallclock_time > 0 + if model._supports_node_count: + count = h5.get_scalar("mip_node_count") + assert count is not None + assert count >= 0 + if model._supports_solution_pool: + pool_var_values = h5.get_array("pool_var_values") + pool_obj_values = h5.get_array("pool_obj_values") + assert pool_var_values is not None + assert pool_obj_values is not None + assert len(pool_obj_values.shape) == 1 + n_sols = len(pool_obj_values) + assert pool_var_values.shape == (n_sols, 5) + + +def _test_add_constr(model: AbstractModel): + with NamedTemporaryFile() as tempfile: + with H5File(tempfile.name) as h5: + model.add_constrs( + np.array([b"x[2]", b"x[3]"], dtype="S"), + np.array([[0, 1], [1, 0]]), + np.array(["=", "="], dtype="S"), + np.array([0, 0]), + ) + model.optimize() + model.extract_after_mip(h5) + assert h5.get_array("mip_var_values").tolist() == [1, 0, 0, 0, 1] + + +def _test_fix_vars(model: AbstractModel): + with NamedTemporaryFile() as tempfile: + with H5File(tempfile.name) as h5: + model.fix_variables( + var_names=np.array([b"x[2]", b"x[3]"], dtype="S"), + var_values=np.array([0, 0]), + ) + model.optimize() + model.extract_after_mip(h5) + assert h5.get_array("mip_var_values").tolist() == [1, 0, 0, 0, 1] + + +def _test_infeasible(model: AbstractModel): + with NamedTemporaryFile() as tempfile: + with H5File(tempfile.name) as h5: + model.fix_variables( + var_names=np.array([b"x[0]", b"x[3]"], dtype="S"), + var_values=np.array([0, 0]), + ) + model.optimize() + model.extract_after_mip(h5) + assert h5.get_array("mip_var_values") is None